diff --git a/backend/src/servers/control.py b/backend/src/servers/control.py
index d2e6c533..13f76539 100644
--- a/backend/src/servers/control.py
+++ b/backend/src/servers/control.py
@@ -4,6 +4,7 @@
import json
import os
import socket
+from pathlib import Path
from src.servers import inventory
@@ -56,13 +57,27 @@ def plan(server: str, mode: str) -> dict:
if mode == "latest":
if inventory.github_org() != inventory.DEFAULT_ORG:
raise ControlError("release_org_not_supported", 400)
- # A write must never silently use stale release information.
- releases, _, failed = inventory.releases_cache.get(wait=True)
- if releases is None or failed:
+ variable = "SERVER_MAIN_DIR" if server == "main" else "SERVER_DEV_DIR"
+ root = os.getenv(variable, "").strip()
+ if not root or not (Path(root) / "plugins").is_dir():
+ raise ControlError("server_files_unavailable", 503)
+ releases, servers = inventory.check_servers({server: (Path(root), server == "main")})
+ if releases.error or releases.errors:
raise ControlError("releases_unavailable", 503)
+ plugins, others = inventory.compare(releases, servers)
+ if any(p["servers"][server]["state"] == "unverified" for p in plugins) or any(o.get("unverified") for o in others):
+ raise ControlError("releases_unavailable", 503)
+ expected = [{"file": ("update/" if j.update else "") + j.file, "sha256": j.sha256,
+ "name": j.name, "version": j.version} for j in servers[server]["jars"]]
for repo, found in releases.items():
release = found[0]
candidates.append({"repo": repo, "tag": release.tag, "asset": release.asset,
"sha256": release.sha256,
"known": [{"sha256": r.sha256, "version": r.version} for r in found]})
- return request("plan", server=server, mode=mode, releases=candidates)
+ try:
+ return request("plan", server=server, mode=mode, releases=candidates,
+ **({"expected": expected} if mode == "latest" else {}))
+ except ControlError as error:
+ if mode == "latest" and error.code == "bad_control_request":
+ raise ControlError("control_unavailable", 503) from None
+ raise
diff --git a/backend/src/servers/host.py b/backend/src/servers/host.py
index 59099639..434c03de 100644
--- a/backend/src/servers/host.py
+++ b/backend/src/servers/host.py
@@ -156,14 +156,14 @@ def snapshot(self, server: str) -> list[dict]:
if path.is_symlink() or not path.is_file():
raise ControlError("unsafe_plugin_file")
try:
- _, name, version = inventory._describe(path)
+ sha, name, version = inventory._describe(path)
if not name or not version or name.lower() in names:
raise ControlError("plugin_inventory_conflict")
if prefix:
raise ControlError("pending_plugin_update")
names.add(name.lower())
- # Always hash afresh before a write, even for unchanged size/mtime.
- result.append({"file": path.name, "sha256": digest(path), "name": name, "version": version})
+ # _describe reads fresh bytes and metadata from the same open file.
+ result.append({"file": path.name, "sha256": sha, "name": name, "version": version})
except PermissionError:
raise ControlError("plugin_unreadable") from None
return result
@@ -181,7 +181,7 @@ def _idle(self, server):
for key, job in self.jobs.items()):
raise ControlError("server_job_active")
- def plan(self, server: str, mode: str, releases: list[dict]) -> dict:
+ def plan(self, server: str, mode: str, releases: list[dict], expected: list[dict] | None = None) -> dict:
if mode not in {"restart", "latest"} or not isinstance(releases, list) or len(releases) > 100:
raise ControlError("bad_server_action", 400)
with self.lock:
@@ -194,6 +194,8 @@ def plan(self, server: str, mode: str, releases: list[dict]) -> dict:
if not (folder / "job.json").exists() and (not path.exists() or json.loads(path.read_text())["expires_at"] < time.time()):
shutil.rmtree(folder)
baseline = self.snapshot(server)
+ if expected is not None and baseline != expected:
+ raise ControlError("plugins_changed")
_, logs = self._paths(server)
startup = inventory.read_startup(logs)
if not startup or not startup["done"] or self._maintenance(server):
@@ -216,7 +218,7 @@ def plan(self, server: str, mode: str, releases: list[dict]) -> dict:
version = release["tag"].removeprefix("v")
prefix = release["asset"].removesuffix(".jar").removesuffix("-" + version)
old = [jar for jar in baseline if jar["file"].lower().startswith(prefix.lower() + "-")
- or jar["name"].lower() == release["repo"].lower()]
+ or inventory.plugin_identifier(jar["name"]) == release["repo"].lower()]
if not old:
continue # Never add plugins that are not already installed.
if len(old) != 1 or old[0]["file"] in used:
@@ -630,15 +632,19 @@ def dispatch(self, body: dict) -> dict:
"cancel": {"operation", "server", "job_id", "actor", "reason"},
"reopen": {"operation", "server", "job_id", "actor", "reason"},
"resolve": {"operation", "server", "job_id", "actor", "reason"}}
- # Older websites omit the optional test-build list.
- if operation not in allowed or set(body) - ({"replace_test_builds"} if operation == "start" else set()) != allowed[operation]:
+ # Older websites omit the optional baseline and test-build choices.
+ optional = {"replace_test_builds"} if operation == "start" else {"expected"} if operation == "plan" else set()
+ if operation not in allowed or set(body) - optional != allowed[operation]:
raise ControlError("bad_control_request", 400)
if operation == "status":
return {"jobs": json.loads(self.status_json)}
server = body["server"]
self._paths(server)
if operation == "plan":
- return self.plan(server, body["mode"], body["releases"])
+ expected = body.get("expected")
+ if "expected" in body and (not isinstance(expected, list) or len(expected) > 300):
+ raise ControlError("bad_control_request", 400)
+ return self.plan(server, body["mode"], body["releases"], expected)
if any(not isinstance(body[key], str) or not 3 <= len(body[key]) <= 500 for key in ("actor", "reason")):
raise ControlError("bad_control_request", 400)
if operation == "start":
diff --git a/backend/src/servers/inventory.py b/backend/src/servers/inventory.py
index 499456ec..94db3ce1 100644
--- a/backend/src/servers/inventory.py
+++ b/backend/src/servers/inventory.py
@@ -37,13 +37,6 @@
("dev", "Dev", "SERVER_DEV_DIR", False),
)
DEFAULT_ORG = "TF-Minecraft"
-# Releases are fetched again after this many seconds; failures are retried sooner.
-# With a token a check is about 13 GraphQL queries; without one it is about 40 REST
-# requests, and GitHub allows 60 an hour.
-RELEASES_TTL = 600
-RELEASES_TTL_WITHOUT_TOKEN = 3600
-RETRY_TTL = 60
-RELEASES_PER_REPO = 30
# Older rotated logs are read until one holds a startup.
MAX_LOGS = 12
@@ -85,6 +78,24 @@ def prefix(self) -> str:
# GitHub
# --------------------
+# Each check owns its results. The slots bound work, never share responses.
+CHECK_TIMEOUT = 20
+GRAPHQL_CHUNK = 8
+GITHUB_WORKERS = 6
+RELEASES_PER_REPO = 30
+_check_slots = threading.BoundedSemaphore(2)
+
+
+class Releases(dict):
+ def __init__(self):
+ super().__init__()
+ self.repos: set[str] = set()
+ self.errors: dict[str, str] = {}
+ self.complete: set[str] = set()
+ self.error: str | None = None
+ self.checked_at: str | None = None
+
+
def github_org() -> str:
return os.getenv("SERVERS_GITHUB_ORG", "").strip() or DEFAULT_ORG
@@ -93,243 +104,340 @@ def _token() -> str:
return os.getenv("SERVERS_GITHUB_TOKEN", "").strip() or os.getenv("PATCHNOTES_GITHUB_TOKEN", "").strip()
-def _github(path: str, body: dict | None = None) -> Any:
- """A REST GET, or a POST of `body` (a GraphQL query to `/graphql`)."""
- headers = {"User-Agent": "TFMC-Servers/1.0", "Accept": "application/vnd.github+json"}
- token = _token()
- if token:
+def _github(path: str, body: dict | None = None, *, timeout: float = CHECK_TIMEOUT) -> Any:
+ headers = {"User-Agent": "TFMC-Servers/1.0", "Accept": "application/vnd.github+json", "Cache-Control": "no-cache"}
+ if token := _token():
headers["Authorization"] = f"Bearer {token}"
data = None if body is None else json.dumps(body).encode()
request = urllib.request.Request(f"https://api.github.com{path}", data=data, headers=headers)
- with urllib.request.urlopen(request, timeout=20) as response:
- return json.loads(response.read().decode() or "null")
+ deadline = time.monotonic() + timeout
+ with urllib.request.urlopen(request, timeout=timeout) as response:
+ chunks, size = [], 0
+ while True:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise TimeoutError("GitHub check deadline")
+ sock = getattr(getattr(getattr(response, "fp", None), "raw", None), "_sock", None)
+ if sock is not None:
+ sock.settimeout(remaining)
+ chunk = response.read1(65536)
+ if time.monotonic() >= deadline:
+ raise TimeoutError("GitHub check deadline")
+ if not chunk:
+ break
+ size += len(chunk)
+ if size > 8 << 20:
+ raise ValueError("GitHub response too large")
+ chunks.append(chunk)
+ return json.loads(b"".join(chunks).decode() or "null")
+
+
+def _fetcher(fetch: Fetch | None, deadline: float) -> Fetch:
+ def request(path: str, body: dict | None = None):
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise TimeoutError("GitHub check deadline")
+ result = fetch(path, body) if fetch else _github(path, body, timeout=remaining)
+ if time.monotonic() >= deadline:
+ raise TimeoutError("GitHub check deadline")
+ return result
+ return request
def repo_releases(repo: str, data: Any) -> list[Release]:
- """A repository's published plugin releases, newest first. Drafts and pre-releases don't count."""
found = []
for release in data if isinstance(data, list) else []:
if not isinstance(release, dict) or release.get("draft") or release.get("prerelease"):
continue
- published = release.get("published_at")
- tag = str(release.get("tag_name") or "")
- jar = next((a for a in release.get("assets") or []
- if isinstance(a, dict) and str(a.get("name", "")).lower().endswith(".jar")
- and str(a.get("digest") or "").startswith("sha256:")), None)
- if not published or not tag or jar is None:
+ published, tag = release.get("published_at"), str(release.get("tag_name") or "")
+ if not published or not tag:
continue
- found.append(Release(repo=repo, version=tag[1:] if tag[:1] in "vV" else tag, published_at=published,
- url=str(release.get("html_url") or ""), asset=str(jar["name"]),
- sha256=str(jar["digest"]).split(":", 1)[1].lower(), tag=tag))
+ for jar in release.get("assets") or []:
+ if not isinstance(jar, dict) or not str(jar.get("name", "")).lower().endswith(".jar"):
+ continue
+ digest = str(jar.get("digest") or "")
+ if not digest.startswith("sha256:") or not digest[7:]:
+ continue
+ found.append(Release(repo=repo, version=tag[1:] if tag[:1] in "vV" else tag, published_at=published,
+ url=str(release.get("html_url") or ""), asset=str(jar["name"]),
+ sha256=digest[7:].lower(), tag=tag))
return sorted(found, key=lambda r: r.published_at, reverse=True)
-def fetch_releases(fetch: Fetch | None = None) -> dict[str, list[Release]]:
- """Every active repository's published plugin releases, by repository name.
+class HistoryPage(list):
+ def __init__(self, repo: str, rows: list[dict]):
+ super().__init__(repo_releases(repo, rows))
+ # Missing asset digests prevent a negative proof even when every page was read.
+ self.verifiable = not any(
+ isinstance(r, dict) and r.get("published_at") and not r.get("draft") and not r.get("prerelease")
+ and any(isinstance(a, dict) and str(a.get("name", "")).lower().endswith(".jar")
+ and (not str(a.get("digest") or "").startswith("sha256:") or not str(a.get("digest") or "")[7:])
+ for a in r.get("assets") or [])
+ for r in rows)
- With a token this takes a handful of GraphQL queries, and each latest release
- is compared with its default branch. Without one GraphQL isn't available, so it
- reads each repository's releases over REST and skips the comparison.
- """
- fetch = fetch or _github
- if _token():
- return _graphql_releases(fetch)
- org = urllib.parse.quote(github_org())
- repos: list[str] = []
- for page in range(1, 4):
- data = fetch(f"/orgs/{org}/repos?per_page=100&page={page}")
- if not isinstance(data, list):
- raise ValueError("unexpected repository list")
- repos += [str(r["name"]) for r in data if isinstance(r, dict) and r.get("name") and not r.get("archived")]
- if len(data) < 100:
- break
-
- def one(repo: str) -> tuple[str, list[Release]]:
- data = fetch(f"/repos/{org}/{urllib.parse.quote(repo)}/releases?per_page={RELEASES_PER_REPO}")
- return repo, repo_releases(repo, data)
-
- with ThreadPoolExecutor(max_workers=8) as pool:
- return {repo: found for repo, found in pool.map(one, repos) if found}
-
-
-# Repositories per releases (and comparison) query: GitHub answers several small
-# queries in parallel much sooner than one large one.
-GRAPHQL_CHUNK = 8
_REPOSITORIES = """query($org: String!, $after: String) {
- organization(login: $org) {
- repositories(first: 100, after: $after, isArchived: false) {
- pageInfo { hasNextPage endCursor }
- nodes { name defaultBranchRef { name } }
- }
- }
+ organization(login: $org) { repositories(first: 100, after: $after, isArchived: false) {
+ pageInfo { hasNextPage endCursor } nodes { name }
+ } }
}"""
-_RELEASES = """fragment releases on Repository {
- releases(first: %d, orderBy: {field: CREATED_AT, direction: DESC}) {
- nodes { tagName publishedAt isDraft isPrerelease url releaseAssets(first: 20) { nodes { name digest } } }
- }
-}""" % RELEASES_PER_REPO
+_ASSETS = "releaseAssets(first: 20) { pageInfo { hasNextPage endCursor } nodes { name digest } }"
+_RELEASE_FIELDS = "tagName publishedAt isDraft isPrerelease url " + _ASSETS
+_LATEST = "name defaultBranchRef { name target { oid } } latestRelease { tagCommit { oid } " + _RELEASE_FIELDS + " }"
def _graphql(fetch: Fetch, query: str, variables: dict, partial: bool = False) -> dict:
- """A query's data. GitHub answers errors in some fields with the rest of the data;
- that fails the query unless `partial` allows it."""
- data = fetch("/graphql", {"query": query, "variables": variables})
- if not isinstance(data, dict) or not isinstance(data.get("data"), dict) or (data.get("errors") and not partial):
+ raw = fetch("/graphql", {"query": query, "variables": variables})
+ if not isinstance(raw, dict) or not isinstance(raw.get("data"), dict) or (raw.get("errors") and not partial):
raise ValueError("unexpected GraphQL answer")
- return data["data"]
+ data = raw["data"]
+ if partial and raw.get("errors"):
+ data = {**data, "_errors": raw["errors"]}
+ return data
-def _aliased(repos: list[str], body: Callable[[int], str], extra: Callable[[int, str], dict] | None = None
- ) -> tuple[str, dict]:
- """One `rN: repository(...)` field per repository, with its name passed as a variable."""
- fields, params, variables = [], ["$org: String!"], {"org": github_org()}
+def _aliased(repos: list[str], body: Callable[[int], str], extra: Callable[[int, str], dict] | None = None):
+ params, variables, fields = ["$org: String!"], {"org": github_org()}, []
for i, repo in enumerate(repos):
- params.append(f"$r{i}: String!")
- variables[f"r{i}"] = repo
+ params.append(f"$r{i}: String!"); variables[f"r{i}"] = repo
for name, value in (extra(i, repo) if extra else {}).items():
- params.append(f"${name}: String!")
- variables[name] = value
+ params.append(f"${name}: String!"); variables[name] = value
fields.append(f"r{i}: repository(owner: $org, name: $r{i}) {{ {body(i)} }}")
- return f"query({', '.join(params)}) {{\n " + "\n ".join(fields) + "\n}", variables
-
-
-def _graphql_releases(fetch: Fetch) -> dict[str, list[Release]]:
- branches: dict[str, str] = {}
- after = None
- for _ in range(3):
- page = _graphql(fetch, _REPOSITORIES, {"org": github_org(), "after": after})["organization"]["repositories"]
- branches.update((str(r["name"]), str((r.get("defaultBranchRef") or {}).get("name") or "main"))
- for r in page["nodes"] if isinstance(r, dict) and r.get("name"))
- if not page["pageInfo"]["hasNextPage"]:
- break
- after = page["pageInfo"]["endCursor"]
-
- def chunk(repos: list[str]) -> dict[str, list[Release]]:
- query, variables = _aliased(repos, lambda i: "...releases")
- data = _graphql(fetch, f"{query}\n{_RELEASES}", variables)
- found = {}
- for i, repo in enumerate(repos):
- nodes = ((data.get(f"r{i}") or {}).get("releases") or {}).get("nodes") or []
- found[repo] = repo_releases(repo, [{
- "tag_name": n.get("tagName"), "published_at": n.get("publishedAt"), "draft": n.get("isDraft"),
- "prerelease": n.get("isPrerelease"), "html_url": n.get("url"),
- "assets": (n.get("releaseAssets") or {}).get("nodes") or [],
- } for n in nodes if isinstance(n, dict)])
- found = {repo: f for repo, f in found.items() if f}
- compared = _compare_branches(fetch, {repo: (f[0].tag, branches[repo]) for repo, f in found.items()})
- for repo, state in compared.items():
- found[repo][0] = replace(found[repo][0], branch=state)
- return found
-
- names = sorted(branches)
- releases: dict[str, list[Release]] = {}
- with ThreadPoolExecutor(max_workers=6) as pool:
- for found in pool.map(chunk, [names[i:i + GRAPHQL_CHUNK] for i in range(0, len(names), GRAPHQL_CHUNK)]):
- releases.update(found)
- return releases
+ return f"query({', '.join(params)}) {{ " + " ".join(fields) + " }", variables
+
+
+def _assets(repo: str, node: dict, fetch: Fetch) -> dict:
+ connection = node.get("releaseAssets") or {}
+ assets = list(connection.get("nodes") or [])
+ while (connection.get("pageInfo") or {}).get("hasNextPage"):
+ cursor = connection["pageInfo"].get("endCursor")
+ if not cursor:
+ raise ValueError("missing asset cursor")
+ query = """query($org:String!,$repo:String!,$tag:String!,$after:String!){repository(owner:$org,name:$repo){
+ release(tagName:$tag){releaseAssets(first:20,after:$after){pageInfo{hasNextPage endCursor} nodes{name digest}}}}}"""
+ raw = _graphql(fetch, query, {"org": github_org(), "repo": repo, "tag": node["tagName"], "after": cursor})
+ release = (raw.get("repository") or {}).get("release")
+ if not release:
+ raise ValueError("release disappeared")
+ connection = release["releaseAssets"]
+ if (connection.get("pageInfo") or {}).get("endCursor") == cursor and connection["pageInfo"].get("hasNextPage"):
+ raise ValueError("asset cursor did not advance")
+ assets.extend(connection.get("nodes") or [])
+ return {"tag_name": node.get("tagName"), "published_at": node.get("publishedAt"),
+ "draft": node.get("isDraft"), "prerelease": node.get("isPrerelease"),
+ "html_url": node.get("url"), "assets": assets}
def _compare_branches(fetch: Fetch, latest: dict[str, tuple[str, str]]) -> dict[str, dict]:
- """Each repository's default branch against its latest release's tag, in one query.
-
- One large query is much slower than several small ones, so each releases
- query is followed by the comparisons for its own repositories.
-
- A failure leaves the releases without a comparison rather than failing them.
- """
- repos = sorted(latest)
- if not repos:
+ if not latest:
return {}
- query, variables = _aliased(
- repos, lambda i: f"ref(qualifiedName: $t{i}) {{ compare(headRef: $b{i}) {{ status aheadBy }} }}",
+ repos = sorted(latest)
+ query, variables = _aliased(repos,
+ lambda i: f"ref(qualifiedName: $t{i}) {{ compare(headRef: $b{i}) {{ status aheadBy }} }}",
lambda i, repo: {f"t{i}": f"refs/tags/{latest[repo][0]}", f"b{i}": latest[repo][1]})
try:
data = _graphql(fetch, query, variables, partial=True)
- except (urllib.error.URLError, http.client.HTTPException, OSError, ValueError):
+ except (OSError, ValueError, http.client.HTTPException):
return {}
- org = urllib.parse.quote(github_org())
found = {}
for i, repo in enumerate(repos):
compare = (((data.get(f"r{i}") or {}).get("ref") or {}).get("compare") or {})
status = str(compare.get("status") or "").lower()
- if status not in {"identical", "ahead", "behind", "diverged"}:
- continue
- tag, branch = latest[repo]
- found[repo] = {"name": branch, "status": status, "ahead": int(compare.get("aheadBy") or 0),
- "url": f"https://github.com/{org}/{urllib.parse.quote(repo)}/compare/"
- f"{urllib.parse.quote(tag, safe='')}...{urllib.parse.quote(branch, safe='')}"}
+ if status in {"identical", "ahead", "behind", "diverged"}:
+ tag, branch = latest[repo]
+ found[repo] = _branch(repo, tag, branch, status, int(compare.get("aheadBy") or 0))
return found
-class _ReleaseCache:
- """Releases, refreshed after a while.
+def _branch(repo, tag, branch, status, ahead=0):
+ return {"name": branch, "status": status, "ahead": ahead,
+ "url": f"https://github.com/{urllib.parse.quote(github_org())}/{urllib.parse.quote(repo)}/compare/"
+ f"{urllib.parse.quote(tag, safe='')}...{urllib.parse.quote(branch, safe='')}"}
+
+
+def _history(repo: str, fetch: Fetch, *, graphql: bool):
+ """Yield pages until exhausted; a deadline/error must not prove 'not a release'."""
+ cursor, page = None, 1
+ while True:
+ if graphql:
+ query = "query($org:String!,$repo:String!,$after:String){repository(owner:$org,name:$repo){releases(first:30,after:$after,orderBy:{field:CREATED_AT,direction:DESC}){pageInfo{hasNextPage endCursor} nodes{" + _RELEASE_FIELDS + "}}}}"
+ data = _graphql(fetch, query, {"org": github_org(), "repo": repo, "after": cursor})
+ repository = data.get("repository")
+ if not repository or not isinstance(repository.get("releases"), dict):
+ raise ValueError("repository disappeared")
+ connection = repository["releases"]
+ yield HistoryPage(repo, [_assets(repo, n, fetch) for n in connection.get("nodes") or []])
+ info = connection.get("pageInfo") or {}
+ if not info.get("hasNextPage"):
+ return
+ new_cursor = info.get("endCursor")
+ if not new_cursor or new_cursor == cursor:
+ raise ValueError("release cursor did not advance")
+ cursor = new_cursor
+ else:
+ rows = fetch(f"/repos/{urllib.parse.quote(github_org())}/{urllib.parse.quote(repo)}/releases?per_page=30&page={page}")
+ if not isinstance(rows, list):
+ raise ValueError("unexpected release list")
+ yield HistoryPage(repo, rows)
+ if len(rows) < RELEASES_PER_REPO:
+ return
+ page += 1
+
+
+def _latest(fetch: Fetch) -> Releases:
+ found = Releases()
+ if not _token():
+ for page in range(1, 100):
+ rows = fetch(f"/orgs/{urllib.parse.quote(github_org())}/repos?per_page=100&page={page}")
+ if not isinstance(rows, list):
+ raise ValueError("unexpected repository list")
+ found.repos.update(r["name"] for r in rows if isinstance(r, dict) and r.get("name") and not r.get("archived"))
+ if len(rows) < 100:
+ break
+ def rest(repo):
+ try:
+ try:
+ data = fetch(f"/repos/{urllib.parse.quote(github_org())}/{urllib.parse.quote(repo)}/releases/latest")
+ except urllib.error.HTTPError as error:
+ if error.code != 404:
+ raise
+ return repo, [], None
+ releases = repo_releases(repo, [data])
+ if not releases:
+ releases = next((p for p in _history(repo, fetch, graphql=False) if p), [])
+ return repo, releases[:1], None
+ except (OSError, ValueError, http.client.HTTPException):
+ return repo, [], "github_unavailable"
+ with ThreadPoolExecutor(max_workers=GITHUB_WORKERS) as pool:
+ for repo, releases, error in pool.map(rest, sorted(found.repos)):
+ if releases: found[repo] = releases
+ if error: found.errors[repo] = error
+ else:
+ cursor = None
+ while True:
+ data = _graphql(fetch, _REPOSITORIES, {"org": github_org(), "after": cursor})
+ connection = (data.get("organization") or {}).get("repositories")
+ if not isinstance(connection, dict):
+ raise ValueError("unexpected repository list")
+ found.repos.update(n["name"] for n in connection.get("nodes") or [] if isinstance(n, dict) and n.get("name"))
+ info = connection.get("pageInfo") or {}
+ if not info.get("hasNextPage"): break
+ next_cursor = info.get("endCursor")
+ if not next_cursor or next_cursor == cursor: raise ValueError("missing repository cursor")
+ cursor = next_cursor
+ def chunk(repos):
+ local, errors, unequal = {}, {}, {}
+ try:
+ query, variables = _aliased(repos, lambda i: _LATEST)
+ data = _graphql(fetch, query, variables, partial=True)
+ except (OSError, ValueError, http.client.HTTPException):
+ return {}, {r: "github_unavailable" for r in repos}
+ for i, repo in enumerate(repos):
+ try:
+ if any((error.get("path") or [None])[0] == f"r{i}" for error in data.get("_errors", [])):
+ raise ValueError("repository lookup failed")
+ node = data.get(f"r{i}")
+ if not isinstance(node, dict) or "latestRelease" not in node:
+ raise ValueError("missing repository")
+ latest = node["latestRelease"]
+ if latest is None: continue
+ releases = repo_releases(repo, [_assets(repo, latest, fetch)])
+ if not releases:
+ releases = next((p for p in _history(repo, fetch, graphql=True) if p), [])
+ if not releases: continue
+ release = releases[0]; branch = node.get("defaultBranchRef") or {}
+ head = (branch.get("target") or {}).get("oid")
+ commit = (latest.get("tagCommit") or {}).get("oid") if release.tag == latest.get("tagName") else None
+ if branch.get("name") and head and commit:
+ if head == commit:
+ release = replace(release, branch=_branch(repo, release.tag, branch["name"], "identical"))
+ else: unequal[repo] = (release.tag, branch["name"])
+ local[repo] = [release]
+ except (OSError, ValueError, KeyError, TypeError, http.client.HTTPException):
+ errors[repo] = "github_unavailable"
+ for repo, state in _compare_branches(fetch, unequal).items():
+ local[repo][0] = replace(local[repo][0], branch=state)
+ return local, errors
+ names = sorted(found.repos)
+ with ThreadPoolExecutor(max_workers=GITHUB_WORKERS) as pool:
+ for releases, errors in pool.map(chunk, [names[i:i + GRAPHQL_CHUNK] for i in range(0, len(names), GRAPHQL_CHUNK)]):
+ found.update(releases); found.errors.update(errors)
+ found.checked_at = _now()
+ return found
- GitHub is read by one fetch at a time, in a thread of its own. Only the
- first fetch makes callers wait; after that an expired cache is refreshed in
- the background and callers get the releases already known, unless they ask
- to wait for the refresh.
- """
- def __init__(self):
- self._lock = threading.Lock()
- self._releases: dict[str, list[Release]] | None = None
- self._checked_at: str | None = None
- self._next = 0.0
- self._failed = False
- self._fetch: threading.Thread | None = None
- # Bumped by clear(), so a fetch running then is ignored.
- self._generation = 0
-
- def get(self, fetch: Fetch | None = None, wait: bool = False
- ) -> tuple[dict[str, list[Release]] | None, str | None, bool]:
- """(releases, when they were fetched, whether the last fetch failed). Keeps old releases on failure."""
- with self._lock:
- if time.monotonic() < self._next:
- return self._state()
- if self._fetch is None or not self._fetch.is_alive():
- self._fetch = threading.Thread(target=self._run, args=(fetch, self._generation), daemon=True)
- self._fetch.start()
- running = self._fetch
- if self._releases is not None and not wait:
- return self._state()
- running.join()
- with self._lock:
- return self._state()
-
- def wait_for_refresh(self):
- running = self._fetch
- if running is not None:
- running.join()
-
- def _state(self) -> tuple[dict[str, list[Release]] | None, str | None, bool]:
- return self._releases, self._checked_at, self._failed
-
- def _run(self, fetch: Fetch | None, generation: int):
+def _resolve(found: Releases, jars: list[Jar], fetch: Fetch):
+ by_hash = {r.sha256: r for rows in found.values() for r in rows}
+ prefixes = sorted({(r.prefix.lower(), repo) for repo, rows in found.items() for r in rows}, key=lambda x: -len(x[0]))
+ needed: dict[str, list[Jar]] = {}
+ for jar in jars:
+ repo = _match(jar, by_hash, prefixes)
+ if repo in found and jar.sha256 not in by_hash:
+ needed.setdefault(repo, []).append(jar)
+ def resolve(item):
+ repo, candidates = item; releases = list(found[repo]); complete = False
+ wanted = {j.sha256 for j in candidates}
try:
- releases, failed = fetch_releases(fetch), False
- except Exception:
- # Whatever went wrong, record it: a caller waiting on this fetch must not take the old releases as fresh.
- releases, failed = None, True
- with self._lock:
- if generation != self._generation:
- return
- now = time.monotonic()
- if failed:
- self._failed = True
- self._next = now + RETRY_TTL
+ if _token():
+ versions = sorted({j.version for j in candidates if j.version and re.fullmatch(r"\d+(?:\.\d+)+(?:-[0-9A-Za-z.-]+)?", j.version)})
+ tags = list(dict.fromkeys(t for v in versions for t in ("v" + v, v)))
+ if tags:
+ fields = " ".join(f"t{i}:release(tagName:$t{i}){{{_RELEASE_FIELDS}}}" for i in range(len(tags)))
+ params = ",".join(f"$t{i}:String!" for i in range(len(tags)))
+ query = f"query($org:String!,$repo:String!,{params}){{repository(owner:$org,name:$repo){{{fields}}}}}"
+ data = _graphql(fetch, query, {"org": github_org(), "repo": repo, **{f"t{i}": t for i,t in enumerate(tags)}})
+ repository = data.get("repository")
+ if not isinstance(repository, dict): raise ValueError("repository disappeared")
+ releases.extend(repo_releases(repo, [_assets(repo, n, fetch) for n in repository.values() if n]))
+ if not wanted <= {r.sha256 for r in releases}:
+ verifiable = True
+ for page in _history(repo, fetch, graphql=bool(_token())):
+ verifiable = verifiable and page.verifiable
+ releases.extend(page)
+ if wanted <= {r.sha256 for r in releases}: break
+ else: complete = verifiable
+ except (OSError, ValueError, KeyError, TypeError, http.client.HTTPException):
+ pass # Unknown bytes stay unverified until history has been exhausted.
+ return repo, list({r.sha256: r for r in reversed(releases)}.values()), complete
+ with ThreadPoolExecutor(max_workers=GITHUB_WORKERS) as pool:
+ for repo, releases, complete in pool.map(resolve, needed.items()):
+ # Keep the designated latest at index zero even after exact/history lookups.
+ latest = found[repo][0]
+ found[repo] = [latest] + [r for r in releases if r.sha256 != latest.sha256]
+ if complete: found.complete.add(repo)
+
+
+def _local_server(root: Path, releases_only: bool):
+ return {"jars": read_jars(root / "plugins"), "startup": read_startup(root / "logs"), "releases_only": releases_only}
+
+
+def check_servers(roots: dict[str, tuple[Path, bool]], fetch: Fetch | None = None):
+ """Fresh GitHub and local reads overlap; no response survives this call."""
+ deadline = time.monotonic() + CHECK_TIMEOUT
+ request = _fetcher(fetch, deadline)
+ acquired = _check_slots.acquire(blocking=False)
+ found = Releases()
+ try:
+ with ThreadPoolExecutor(max_workers=max(1, len(roots) + 1)) as pool:
+ locals_ = {key: pool.submit(_local_server, root, only) for key, (root, only) in roots.items()}
+ remote = pool.submit(_latest, request) if acquired else None
+ servers = {key: future.result() for key, future in locals_.items()}
+ if remote is None:
+ found.error = "busy"
else:
- self._releases, self._checked_at, self._failed = releases, _now(), False
- self._next = now + (RELEASES_TTL if _token() else RELEASES_TTL_WITHOUT_TOKEN)
-
- def clear(self):
- with self._lock:
- self._releases, self._checked_at, self._next, self._failed = None, None, 0.0, False
- self._fetch = None
- self._generation += 1
+ try:
+ found = remote.result()
+ _resolve(found, [j for server in servers.values() for j in server["jars"]], request)
+ except (OSError, ValueError, KeyError, TypeError, http.client.HTTPException):
+ found = Releases(); found.error = "github_unavailable"
+ return found, servers
+ finally:
+ if acquired: _check_slots.release()
-releases_cache = _ReleaseCache()
+def fetch_releases(fetch: Fetch | None = None) -> Releases:
+ """Fresh latest-release metadata; installed history is resolved by check_servers."""
+ return check_servers({}, fetch)[0]
# --------------------
@@ -346,35 +454,27 @@ class Jar:
update: bool
-_hashes: dict[tuple[str, int, int], tuple[str, str | None, str | None]] = {}
-_hashes_lock = threading.Lock()
+def _archive_metadata(archive) -> tuple[str | None, str | None]:
+ for entry in ("paper-plugin.yml", "plugin.yml"):
+ if entry in archive.namelist():
+ fields = dict(_YAML_FIELD.findall(archive.read(entry).decode("utf-8", "replace")))
+ return fields.get("name") or None, fields.get("version") or None
+ return None, None
def _describe(path: Path) -> tuple[str, str | None, str | None]:
- """A jar's SHA-256 and its plugin.yml name and version, cached until the file changes."""
- stat = path.stat()
- key = (str(path), stat.st_size, stat.st_mtime_ns)
- with _hashes_lock:
- if key in _hashes:
- return _hashes[key]
digest = hashlib.sha256()
+ name = version = None
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1 << 20), b""):
digest.update(chunk)
- name = version = None
- try:
- with zipfile.ZipFile(path) as archive:
- for entry in ("paper-plugin.yml", "plugin.yml"):
- if entry in archive.namelist():
- fields = dict(_YAML_FIELD.findall(archive.read(entry).decode("utf-8", "replace")))
- name, version = fields.get("name") or None, fields.get("version") or None
- break
- except (zipfile.BadZipFile, OSError):
- pass
- value = (digest.hexdigest(), name, version)
- with _hashes_lock:
- _hashes[key] = value
- return value
+ handle.seek(0)
+ try:
+ with zipfile.ZipFile(handle) as archive:
+ name, version = _archive_metadata(archive)
+ except zipfile.BadZipFile:
+ pass
+ return digest.hexdigest(), name, version
def read_jars(plugins: Path) -> list[Jar]:
@@ -440,7 +540,7 @@ def read_startup(logs: Path) -> dict | None:
# --------------------
def _plugin_state(jars: list[Jar], latest: Release, by_hash: dict[str, Release], startup: dict | None,
- releases_only: bool) -> dict:
+ releases_only: bool, complete: bool = True) -> dict:
if not jars:
return {"state": "missing", "files": [], "version": None, "release": None, "loaded": None,
"loaded_state": "unknown", "update": False, "duplicate": False}
@@ -453,7 +553,7 @@ def _plugin_state(jars: list[Jar], latest: Release, by_hash: dict[str, Release],
jar = next((j for j in main_jars if loaded is not None and j.version == loaded), main_jars[0])
release = by_hash.get(jar.sha256)
if release is None:
- state = "not_release" if releases_only else "test_build"
+ state = ("not_release" if releases_only else "test_build") if complete else "unverified"
elif release.version == latest.version:
state = "latest"
else:
@@ -478,11 +578,21 @@ def _plugin_state(jars: list[Jar], latest: Release, by_hash: dict[str, Release],
}
+def plugin_identifier(name: str | None) -> str:
+ lowered = (name or "").lower()
+ return {"activity": "activitytf", "geiger_counter": "geigercounters"}.get(lowered, lowered)
+
+
def _match(jar: Jar, by_hash: dict[str, Release], prefixes: list[tuple[str, str]]) -> str | None:
if jar.sha256 in by_hash:
return by_hash[jar.sha256].repo
lowered = jar.file.lower()
- return next((repo for prefix, repo in prefixes if lowered.startswith(prefix + "-")), None)
+ matched = next((repo for prefix, repo in prefixes if lowered.startswith(prefix + "-")), None)
+ if matched:
+ return matched
+ # Stable Bukkit identifiers differ from repository names for these two plugins.
+ name = plugin_identifier(jar.name)
+ return next((repo for _, repo in prefixes if repo.lower() == name), None)
def compare(releases: dict[str, list[Release]], servers: dict[str, dict]) -> tuple[list[dict], list[dict]]:
@@ -496,22 +606,29 @@ def compare(releases: dict[str, list[Release]], servers: dict[str, dict]) -> tup
for key, server in servers.items():
for jar in server["jars"]:
repo = _match(jar, by_hash, prefixes)
+ if repo is None:
+ name = plugin_identifier(jar.name)
+ repo = next((r for r in getattr(releases, "errors", {}) if r.lower() == name), None)
if repo is not None:
grouped[key].setdefault(repo, []).append(jar)
continue
name = jar.name or jar.file.removesuffix(".jar")
entry = others.setdefault(name.lower(), {"name": name, "servers": {k: None for k in servers}})
entry["servers"][key] = {"file": jar.file, "version": jar.version}
+ if getattr(releases, "error", None) or getattr(releases, "errors", {}):
+ entry["unverified"] = True
plugins = []
- for repo in sorted(releases, key=str.lower):
- latest = releases[repo][0]
+ for repo in sorted(set(releases) | set(getattr(releases, "errors", {})), key=str.lower):
+ latest = releases[repo][0] if repo in releases else None
plugins.append({
"repo": repo,
"latest": {"version": latest.version, "published_at": latest.published_at, "url": latest.url,
- "branch": latest.branch},
- "servers": {key: _plugin_state(grouped[key].get(repo, []), latest, by_hash,
- server["startup"], server["releases_only"])
+ "branch": latest.branch} if latest else None,
+ **({"error": "github_unavailable"} if latest is None else {}),
+ "servers": {key: _plugin_state(grouped[key].get(repo, []), latest or Release(repo, "", "", "", "", ""), by_hash,
+ server["startup"], server["releases_only"],
+ repo in releases.complete if isinstance(releases, Releases) else True)
for key, server in servers.items()},
})
other_list = []
@@ -527,27 +644,21 @@ def _now() -> str:
def overview(fetch: Fetch | None = None) -> dict:
- """The staff Servers tab: each server's state and every plugin it has."""
- server_info: list[dict] = []
- readable: dict[str, dict] = {}
- for key, label, variable, releases_only in SERVERS:
- root = os.getenv(variable, "").strip()
- info: dict[str, Any] = {"key": key, "label": label, "releases_only": releases_only,
- "status": "not_configured", "paper": None, "started": None, "log": None}
- if root:
- plugins = Path(root) / "plugins"
- if plugins.is_dir():
- startup = read_startup(Path(root) / "logs")
- info.update(status="available", paper=startup and startup["paper"],
- started=startup and startup["done"], log=startup and startup["log"])
- readable[key] = {"jars": read_jars(plugins), "startup": startup, "releases_only": releases_only}
- else:
- info["status"] = "missing"
- server_info.append(info)
-
- releases, checked_at, failed = releases_cache.get(fetch)
- github = {"status": "available" if releases is not None else "unavailable",
- "checked_at": checked_at, "stale": failed and releases is not None}
- plugins, others = compare(releases or {}, readable) if releases is not None else ([], [])
- return {"checked_at": _now(), "github": github, "servers": server_info, "plugins": plugins, "others": others}
-
+ roots = {}
+ server_info = []
+ for key, label, variable, only in SERVERS:
+ value = os.getenv(variable, "").strip()
+ available = bool(value) and (Path(value) / "plugins").is_dir()
+ server_info.append({"key": key, "label": label, "releases_only": only,
+ "status": "available" if available else "missing" if value else "not_configured",
+ "paper": None, "started": None, "log": None})
+ if available: roots[key] = (Path(value), only)
+ releases, servers = check_servers(roots, fetch)
+ for info in server_info:
+ startup = servers.get(info["key"], {}).get("startup")
+ if startup:
+ info.update(paper=startup["paper"], started=startup["done"], log=startup["log"])
+ plugins, others = compare(releases, servers)
+ status = "busy" if releases.error == "busy" else "unavailable" if releases.error else "partial" if releases.errors else "available"
+ return {"checked_at": _now(), "github": {"status": status, "checked_at": releases.checked_at},
+ "servers": server_info, "plugins": plugins, "others": others}
diff --git a/backend/src/servers/test_control.py b/backend/src/servers/test_control.py
index 0c33b32d..7ac451be 100644
--- a/backend/src/servers/test_control.py
+++ b/backend/src/servers/test_control.py
@@ -706,3 +706,136 @@ def dispatch(self, body):
assert refused.value.status == 409
server.shutdown()
worker.join()
+
+
+def test_latest_plan_fetches_fresh_releases_and_passes_verified_full_inventory(controller, monkeypatch):
+ from src.servers.test_inventory import Github, release
+ plugins, _ = controller.roots["dev"]
+ monkeypatch.setenv("SERVER_DEV_DIR", str(plugins.parent))
+ monkeypatch.delenv("SERVERS_GITHUB_TOKEN", raising=False)
+ monkeypatch.delenv("PATCHNOTES_GITHUB_TOKEN", raising=False)
+ monkeypatch.delenv("SERVERS_GITHUB_ORG", raising=False)
+ baseline = controller.snapshot("dev")
+ rows = [release("v2.0.0", "2026-10-01T00:00:00Z", "core-2.0.0.jar", "2" * 64),
+ release("v1.0.0", "2026-09-01T00:00:00Z", "core-1.0.0.jar", baseline[0]["sha256"])]
+ fetch = Github({"Core": rows})
+ monkeypatch.setattr(inventory, "_github", lambda path, body=None, **_: fetch(path, body))
+ sent = []
+ monkeypatch.setattr(control, "request", lambda operation, **body: sent.append((operation, body)) or {"id": "plan"})
+ assert control.plan("dev", "latest") == {"id": "plan"}
+ rows.insert(0, release("v3.0.0", "2026-10-02T00:00:00Z", "core-3.0.0.jar", "3" * 64))
+ control.plan("dev", "latest")
+ assert [body["releases"][0]["tag"] for _, body in sent] == ["v2.0.0", "v3.0.0"]
+ assert all(operation == "plan" and body["expected"] == baseline for operation, body in sent)
+ assert all({"sha256": baseline[0]["sha256"], "version": "1.0.0"} in body["releases"][0]["known"] for _, body in sent)
+ assert sum(path.startswith("/orgs/") for path, _ in fetch.calls) == 2
+ assert controller.commands == []
+
+
+@pytest.mark.parametrize("failure", ["global", "partial", "history", "renamed"])
+def test_latest_plan_fails_closed_before_helper_for_unverified_releases(controller, monkeypatch, failure):
+ plugins, _ = controller.roots["dev"]
+ monkeypatch.setenv("SERVER_DEV_DIR", str(plugins.parent))
+ monkeypatch.delenv("SERVERS_GITHUB_ORG", raising=False)
+ found = inventory.Releases()
+ found.repos.add("Core")
+ found["Core"] = [inventory.Release("Core", "2.0.0", "2026-10-01T00:00:00Z", "url", "core-2.0.0.jar", "2" * 64, "v2.0.0")]
+ if failure == "global":
+ found.error = "github_unavailable"
+ elif failure == "partial":
+ found.errors["Core"] = "github_unavailable"
+ elif failure == "renamed":
+ found.clear()
+ found.repos = {"DifferentRepo"}
+ local = {"dev": {"jars": inventory.read_jars(plugins), "startup": None, "releases_only": False}}
+ if failure == "renamed":
+ monkeypatch.setattr(inventory, "compare", lambda *_: ([], [{"name": "Core", "unverified": True}]))
+ monkeypatch.setattr(inventory, "check_servers", lambda *_: (found, local))
+ monkeypatch.setattr(control, "request", lambda *_args, **_kwargs: pytest.fail("unverified data reached helper"))
+ with pytest.raises(control.ControlError, match="releases_unavailable"):
+ control.plan("dev", "latest")
+ assert controller.commands == []
+
+
+def test_latest_plan_refuses_an_old_helper_protocol_instead_of_retrying_unsafely(controller, monkeypatch):
+ plugins, _ = controller.roots["dev"]
+ monkeypatch.setenv("SERVER_DEV_DIR", str(plugins.parent))
+ monkeypatch.delenv("SERVERS_GITHUB_ORG", raising=False)
+ found = inventory.Releases()
+ calls = []
+ monkeypatch.setattr(inventory, "check_servers", lambda *_: (found, {"dev": {"jars": []}}))
+ def refused(operation, **body):
+ calls.append(body)
+ assert "expected" in body
+ raise control.ControlError("bad_control_request", 400)
+ monkeypatch.setattr(control, "request", refused)
+ with pytest.raises(control.ControlError, match="control_unavailable") as error:
+ control.plan("dev", "latest")
+ assert error.value.status == 503 and len(calls) == 1
+
+
+def test_restart_plan_keeps_legacy_protocol_without_a_github_check(monkeypatch):
+ monkeypatch.setattr(inventory, "check_servers", lambda *_: pytest.fail("restart looked up releases"))
+ sent = []
+ monkeypatch.setattr(control, "request", lambda op, **body: sent.append((op, body)) or {"id": "legacy"})
+ assert control.plan("dev", "restart") == {"id": "legacy"}
+ assert sent == [("plan", {"server": "dev", "mode": "restart", "releases": []})]
+
+
+def test_helper_plan_accepts_old_protocol_and_matching_new_inventory(controller):
+ old = controller.dispatch({"operation": "plan", "server": "dev", "mode": "restart", "releases": []})
+ expected = controller.snapshot("dev")
+ new = controller.dispatch({"operation": "plan", "server": "dev", "mode": "restart", "releases": [], "expected": expected})
+ assert old["id"] != new["id"]
+ assert controller._plan(new["id"])["baseline"] == expected
+
+
+def test_helper_rejects_changed_expected_inventory_before_commands_or_new_files(controller):
+ expected = controller.snapshot("dev")
+ old = controller.roots["dev"][0] / expected[0]["file"]
+ import os
+ stamp = old.stat()
+ raw = old.read_bytes()
+ old.write_bytes(raw[:-1] + bytes([raw[-1] ^ 1]))
+ os.utime(old, ns=(stamp.st_atime_ns, stamp.st_mtime_ns))
+ before = set(controller.state.iterdir())
+ with pytest.raises(control.ControlError, match="plugins_changed"):
+ controller.dispatch({"operation": "plan", "server": "dev", "mode": "latest", "releases": [], "expected": expected})
+ assert controller.commands == []
+ assert set(controller.state.iterdir()) == before
+
+
+@pytest.mark.parametrize("expected", [None, "invalid", {}, [{}] * 301])
+def test_helper_rejects_malformed_new_inventory_field(controller, expected):
+ with pytest.raises(control.ControlError, match="bad_control_request"):
+ controller.dispatch({"operation": "plan", "server": "dev", "mode": "latest", "releases": [], "expected": expected})
+ assert controller.commands == []
+
+
+def test_snapshot_hashes_each_jar_once(controller, monkeypatch):
+ original = inventory._describe
+ calls = []
+ def describe(path):
+ calls.append(path)
+ return original(path)
+ monkeypatch.setattr(inventory, "_describe", describe)
+ monkeypatch.setattr(host, "digest", lambda *_: pytest.fail("snapshot hashed the jar twice"))
+ [copy] = controller.snapshot("dev")
+ assert calls == [controller.roots["dev"][0] / "core-1.0.0.jar"]
+ assert copy["name"] == "Core" and len(copy["sha256"]) == 64
+
+
+def test_latest_plan_upgrades_renamed_geiger_counter_from_its_verified_old_hash(controller, tmp_path):
+ plugins, _ = controller.roots["dev"]
+ old_sha = jar(plugins / "radiation-old.jar", name="geiger_counter", version="1.0.0")
+ target = tmp_path / "geiger-release.jar"
+ new_sha = jar(target, name="geiger_counter", version="2.0.0")
+ controller.fetch = lambda release, path: path.write_bytes(target.read_bytes())
+ release = {"repo": "GeigerCounters", "tag": "v2.0.0", "asset": "geigercounters-2.0.0.jar", "sha256": new_sha,
+ "known": [{"sha256": old_sha, "version": "1.0.0"}]}
+ plan = controller.plan("dev", "latest", [release], expected=controller.snapshot("dev"))
+ [change] = plan["changes"]
+ assert change["repo"] == "GeigerCounters" and change["name"] == "geiger_counter"
+ assert change["old_file"] == "radiation-old.jar" and change["old_version"] == "1.0.0"
+ assert change["sha256"] == new_sha and "test_build" not in change
+ assert plan["skipped"] == []
diff --git a/backend/src/servers/test_inventory.py b/backend/src/servers/test_inventory.py
index f572d3f6..1ff875ed 100644
--- a/backend/src/servers/test_inventory.py
+++ b/backend/src/servers/test_inventory.py
@@ -1,8 +1,10 @@
import gzip
import hashlib
import http.client
+import os
import threading
import urllib.error
+import urllib.parse
import zipfile
import pytest
@@ -14,8 +16,9 @@ def make_jar(folder, file, name, version, extra=b""):
folder.mkdir(parents=True, exist_ok=True)
path = folder / file
with zipfile.ZipFile(path, "w") as archive:
- archive.writestr("plugin.yml", f"name: {name}\nversion: '{version}'\nmain: x.Y\n")
- archive.writestr("x/Y.class", extra or file.encode())
+ for entry, content in (("plugin.yml", f"name: {name}\nversion: '{version}'\nmain: x.Y\n"),
+ ("x/Y.class", extra or file.encode())):
+ archive.writestr(zipfile.ZipInfo(entry, date_time=(2026, 10, 15, 0, 0, 0)), content)
return hashlib.sha256(path.read_bytes()).hexdigest()
@@ -35,47 +38,15 @@ def startup_log(*plugins, paper="1.21.10-130", done=True):
@pytest.fixture(autouse=True)
-def fresh_cache(monkeypatch):
- monkeypatch.delenv("SERVERS_GITHUB_TOKEN", raising=False)
- monkeypatch.delenv("PATCHNOTES_GITHUB_TOKEN", raising=False)
- inventory.releases_cache.clear()
- yield
- inventory.releases_cache.clear()
-
-
-def test_releases_skip_drafts_prereleases_and_releases_without_a_jar():
- data = [
- release("v1.1.0", None, "core-1.1.0.jar", "d1", draft=True),
- release("v1.0.1", "2026-10-02T00:00:00Z", "core-1.0.1.jar", "b2", prerelease=True),
- release("v1.0.0", "2026-10-01T00:00:00Z", "core-1.0.0.jar", "A1"),
- release("v1.0.2", "2026-10-03T00:00:00Z", "core-1.0.2.tar.gz", "c3"),
- release("2.0.0", "2026-10-04T00:00:00Z", "core-2.0.0.jar", "e4"),
- ]
- found = inventory.repo_releases("Core", data)
- assert [(r.version, r.sha256, r.prefix) for r in found] == [("2.0.0", "e4", "core"), ("1.0.0", "a1", "core")]
- assert inventory.repo_releases("Core", {"message": "Not Found"}) == []
- odd = release("v3.0.0", "2026-10-05T00:00:00Z", "core-3.0.0.jar", "f5")
- odd["assets"].insert(0, "not an asset")
- assert [r.version for r in inventory.repo_releases("Core", [odd])] == ["3.0.0"]
-
-
-def test_startup_comes_from_the_newest_log_that_has_one(tmp_path):
- logs = tmp_path / "logs"
- logs.mkdir()
- # After midnight, latest.log holds no startup; the rotated logs are read newest first.
- (logs / "latest.log").write_text("[00:01:00] [Server thread/INFO]: nothing\n")
- with gzip.open(logs / "2026-10-10-2.log.gz", "wt") as handle:
- handle.write(startup_log(("Core", "0.9"), paper="old"))
- with gzip.open(logs / "2026-10-10-10.log.gz", "wt") as handle:
- handle.write(startup_log(("Core", "1.1"), done=False))
- startup = inventory.read_startup(logs)
- assert startup == {"paper": "1.21.10-130", "done": False, "log": "2026-10-10-10.log.gz",
- "enabled": {"core": "1.1"}, "failed": {}}
- assert inventory.read_startup(tmp_path / "nowhere") is None
+def fresh_environment(monkeypatch):
+ for name in ("SERVERS_GITHUB_TOKEN", "PATCHNOTES_GITHUB_TOKEN", "SERVERS_GITHUB_ORG",
+ "SERVER_MAIN_DIR", "SERVER_DEV_DIR"):
+ monkeypatch.delenv(name, raising=False)
+ monkeypatch.setattr(inventory, "_check_slots", threading.BoundedSemaphore(2))
def servers_fixture(tmp_path):
- """Main and Dev folders with a release, an older release, a test build and a pending update."""
+ """Main and Dev include an old release, test build, duplicate and pending update."""
main, dev = tmp_path / "main", tmp_path / "dev"
shas = {
"core-new": make_jar(tmp_path / "rel", "core-1.1.0.jar", "Core", "1.1.0"),
@@ -105,88 +76,143 @@ def repo_data(shas):
}
-def github(shas, fail=False):
- """GitHub's REST API, as read without a token."""
- calls = []
-
- def fetch(path, body=None):
- calls.append(path)
- if fail:
- raise urllib.error.URLError("down")
- assert body is None, "GraphQL needs a token"
- repos = repo_data(shas)
+class Github:
+ """A strict REST/GraphQL fixture with independent discovery, latest and history calls."""
+ def __init__(self, repos, *, failed=(), history_failed=(), heads=None, commits=None, latest=None):
+ self.repos = repos
+ self.failed = set(failed)
+ self.history_failed = set(history_failed)
+ self.heads = heads or {}
+ self.commits = commits or {}
+ self.latest = latest or {}
+ self.calls = []
+
+ @property
+ def queries(self):
+ return [body for path, body in self.calls if path == "/graphql"]
+
+ def node(self, repo, row):
+ assets = row["assets"]
+ return {"tagName": row["tag_name"], "publishedAt": row["published_at"], "isDraft": row["draft"],
+ "isPrerelease": row["prerelease"], "url": row["html_url"],
+ "tagCommit": {"oid": self.commits.get(repo, "same")},
+ "releaseAssets": {"nodes": assets[:20], "pageInfo": {
+ "hasNextPage": len(assets) > 20, "endCursor": "20"}}}
+
+ def latest_row(self, repo):
+ rows = self.repos[repo]
+ return next((r for r in rows if r["tag_name"] == self.latest[repo]), None) if repo in self.latest else next(iter(rows), None)
+
+ def __call__(self, path, body=None):
+ self.calls.append((path, body))
if path.startswith("/orgs/"):
- return [{"name": name} for name in repos] + [{"name": "Old", "archived": True}]
- if "/Old/" in path:
- raise AssertionError("archived repositories are not read")
- return next((found for name, found in repos.items() if f"/{name}/" in path), [])
-
- fetch.calls = calls
- return fetch
-
+ assert body is None
+ return [{"name": name} for name in self.repos] + [{"name": "Old", "archived": True}]
+ if path != "/graphql":
+ assert body is None
+ parts = urllib.parse.urlsplit(path)
+ repo = parts.path.split("/")[3]
+ assert repo != "Old", "archived repositories must be excluded"
+ if repo in self.failed:
+ raise urllib.error.URLError("repo down")
+ if parts.path.endswith("/releases/latest"):
+ row = self.latest_row(repo)
+ if row is None:
+ raise urllib.error.HTTPError(path, 404, "not found", {}, None)
+ return row
+ assert parts.path.endswith("/releases")
+ assert repo not in self.history_failed or self._history_failure()
+ query = urllib.parse.parse_qs(parts.query)
+ assert query["per_page"] == ["30"]
+ start = (int(query["page"][0]) - 1) * 30
+ return self.repos[repo][start:start + 30]
+ query, variables = body["query"], body["variables"]
+ if "organization(login" in query:
+ assert "defaultBranchRef" not in query
+ names = list(self.repos)
+ page = names[:2] if variables["after"] is None else names[2:]
+ return {"data": {"organization": {"repositories": {"nodes": [{"name": n} for n in page],
+ "pageInfo": {"hasNextPage": variables["after"] is None and len(names) > 2, "endCursor": "repos2"}}}}}
+ if "latestRelease" in query:
+ data = {}
+ for alias in sorted(k for k in variables if k.startswith("r") and k[1:].isdigit()):
+ repo = variables[alias]
+ row = self.latest_row(repo)
+ data[alias] = None if repo in self.failed else {
+ "name": repo, "defaultBranchRef": {"name": "master" if repo == "Lib" else "main",
+ "target": {"oid": self.heads.get(repo, "same")}},
+ "latestRelease": self.node(repo, row) if row else None}
+ return {"data": data, **({"errors": [{"type": "NOT_FOUND"}]} if self.failed else {})}
+ if "compare(headRef" in query:
+ return {"data": {alias: {"ref": {"compare": {"status": "AHEAD", "aheadBy": 3}}}
+ for alias in variables if alias.startswith("r") and alias[1:].isdigit()}}
+ repo = variables["repo"]
+ if "releaseAssets(first:20,after:" in query:
+ row = next(r for r in self.repos[repo] if r["tag_name"] == variables["tag"])
+ start = int(variables["after"])
+ assets = row["assets"][start:start + 20]
+ return {"data": {"repository": {"release": {"releaseAssets": {"nodes": assets,
+ "pageInfo": {"hasNextPage": start + 20 < len(row["assets"]), "endCursor": str(start + 20)}}}}}}
+ if "releases(first:30" in query:
+ if repo in self.history_failed:
+ self._history_failure()
+ start = int(variables["after"] or 0)
+ rows = self.repos[repo][start:start + 30]
+ return {"data": {"repository": {"releases": {"nodes": [self.node(repo, r) for r in rows],
+ "pageInfo": {"hasNextPage": start + 30 < len(self.repos[repo]), "endCursor": str(start + 30)}}}}}
+ assert "release(tagName:$t" in query, query
+ tags = {k: tag for k, tag in variables.items() if k.startswith("t") and k[1:].isdigit()}
+ return {"data": {"repository": {alias: next((self.node(repo, row) for row in self.repos[repo]
+ if row["tag_name"] == tag), None) for alias, tag in tags.items()}}}
+
+ @staticmethod
+ def _history_failure():
+ raise http.client.IncompleteRead(b"")
-def graphql(shas):
- """GitHub's GraphQL API, as read with a token."""
- queries = []
- repos = repo_data(shas)
- branches = {"Core": "main", "Lib": "master", "Docs": "main"}
- def node(r):
- return {"tagName": r["tag_name"], "publishedAt": r["published_at"], "isDraft": r["draft"],
- "isPrerelease": r["prerelease"], "url": r["html_url"], "releaseAssets": {"nodes": r["assets"]}}
+def github(shas, fail=False):
+ if fail:
+ def failed(path, body=None, **_kwargs):
+ raise urllib.error.URLError("down")
+ return failed
+ return Github(repo_data(shas))
- def fetch(path, body=None):
- assert path == "/graphql"
- query, variables = body["query"], body["variables"]
- queries.append(body)
- aliases = sorted(k for k in variables if k[0] == "r")
- if "pageInfo" in query:
- # Two pages, to follow the cursor.
- names = ["Core", "Lib"] if variables["after"] is None else ["Docs"]
- nodes = [{"name": n, "defaultBranchRef": {"name": branches[n]}} for n in names]
- return {"data": {"organization": {"repositories": {
- "pageInfo": {"hasNextPage": variables["after"] is None, "endCursor": "c1"}, "nodes": nodes}}}}
- if "compare" in query:
- data = {}
- for alias in aliases:
- # GitHub can't find Lib's tag.
- data[alias] = {"ref": None} if variables[alias] == "Lib" else {"ref": {"compare": {
- "status": "AHEAD", "aheadBy": 3}}}
- return {"data": data}
- return {"data": {a: {"releases": {"nodes": [node(r) for r in repos[variables[a]]]}} for a in aliases}}
- fetch.queries = queries
- return fetch
+def test_releases_skip_drafts_prereleases_and_releases_without_a_jar():
+ data = [release("v1.1.0", None, "core-1.1.0.jar", "d1", draft=True),
+ release("v1.0.1", "2026-10-02T00:00:00Z", "core-1.0.1.jar", "b2", prerelease=True),
+ release("v1.0.0", "2026-10-01T00:00:00Z", "core-1.0.0.jar", "A1"),
+ release("v1.0.2", "2026-10-03T00:00:00Z", "core-1.0.2.tar.gz", "c3"),
+ release("2.0.0", "2026-10-04T00:00:00Z", "core-2.0.0.jar", "e4")]
+ found = inventory.repo_releases("Core", data)
+ assert [(r.version, r.sha256, r.prefix) for r in found] == [("2.0.0", "e4", "core"), ("1.0.0", "a1", "core")]
+ assert inventory.repo_releases("Core", {"message": "Not Found"}) == []
+ odd = release("v3.0.0", "2026-10-05T00:00:00Z", "core-3.0.0.jar", "f5")
+ odd["assets"].insert(0, "not an asset")
+ assert [r.version for r in inventory.repo_releases("Core", [odd])] == ["3.0.0"]
-def test_overview_compares_each_server_with_the_releases(tmp_path, monkeypatch):
+def test_overview_compares_each_server_with_verified_releases(tmp_path, monkeypatch):
main, dev, shas = servers_fixture(tmp_path)
monkeypatch.setenv("SERVER_MAIN_DIR", str(main))
monkeypatch.setenv("SERVER_DEV_DIR", str(dev))
result = inventory.overview(github(shas))
-
- assert result["github"]["status"] == "available" and not result["github"]["stale"]
+ assert result["github"]["status"] == "available" and result["github"]["checked_at"]
+ assert "stale" not in result["github"]
assert [s["status"] for s in result["servers"]] == ["available", "available"]
assert result["servers"][0]["paper"] == "1.21.10-130" and result["servers"][0]["started"] is True
plugins = {p["repo"]: p for p in result["plugins"]}
assert set(plugins) == {"Core", "Lib"}
assert plugins["Core"]["latest"]["version"] == "1.1.0"
- # Without a token, releases aren't compared with the branch.
assert plugins["Core"]["latest"]["branch"] is None
-
core_main = plugins["Core"]["servers"]["main"]
assert core_main["state"] == "older_release" and core_main["release"] == "1.0.0"
assert core_main["update"] is True and core_main["loaded_state"] == "same"
assert core_main["files"] == ["core-1.0.0.jar", "core-1.1.0.jar"]
-
- # Same name and version as the release, different bytes: Main only runs releases.
- lib_main = plugins["Lib"]["servers"]["main"]
- assert lib_main["state"] == "not_release" and lib_main["release"] is None
-
+ assert plugins["Lib"]["servers"]["main"]["state"] == "not_release"
core_dev = plugins["Core"]["servers"]["dev"]
assert core_dev["state"] == "test_build" and core_dev["duplicate"] is True
assert core_dev["loaded"] == "main-SNAPSHOT" and core_dev["loaded_state"] == "same"
-
assert plugins["Lib"]["servers"]["dev"]["state"] == "missing"
assert result["others"] == [{"name": "Essentials", "differs": True, "servers": {
"main": {"file": "Essentials-2.22.jar", "version": "2.22"},
@@ -197,11 +223,9 @@ def test_a_newer_jar_than_the_running_one_waits_for_a_restart(tmp_path, monkeypa
main, _, shas = servers_fixture(tmp_path)
(main / "logs" / "latest.log").write_text(startup_log(("Core", "0.9.0")))
monkeypatch.setenv("SERVER_MAIN_DIR", str(main))
- monkeypatch.delenv("SERVER_DEV_DIR", raising=False)
result = inventory.overview(github(shas))
plugins = {p["repo"]: p["servers"]["main"] for p in result["plugins"]}
assert plugins["Core"]["loaded"] == "0.9.0" and plugins["Core"]["loaded_state"] == "different"
- # Lib never enabled in a startup that finished.
assert plugins["Lib"]["loaded_state"] == "not_loaded"
assert result["servers"][1]["status"] == "not_configured"
assert set(result["plugins"][0]["servers"]) == {"main"}
@@ -209,179 +233,231 @@ def test_a_newer_jar_than_the_running_one_waits_for_a_restart(tmp_path, monkeypa
def test_unreadable_servers_and_github(tmp_path, monkeypatch):
monkeypatch.setenv("SERVER_MAIN_DIR", str(tmp_path / "gone"))
- monkeypatch.setenv("SERVER_DEV_DIR", "")
result = inventory.overview(github({}, fail=True))
assert [s["status"] for s in result["servers"]] == ["missing", "not_configured"]
- assert result["github"] == {"status": "unavailable", "checked_at": None, "stale": False}
+ assert result["github"] == {"status": "unavailable", "checked_at": None}
assert result["plugins"] == [] and result["others"] == []
-def test_releases_are_cached_and_kept_when_github_fails(tmp_path, monkeypatch):
- main, _, shas = servers_fixture(tmp_path)
- monkeypatch.setenv("SERVER_MAIN_DIR", str(main))
+@pytest.mark.parametrize("authenticated", [False, True])
+def test_every_check_reads_the_designated_latest_release_fresh(tmp_path, monkeypatch, authenticated):
+ _, _, shas = servers_fixture(tmp_path)
+ if authenticated:
+ monkeypatch.setenv("SERVERS_GITHUB_TOKEN", "token")
fetch = github(shas)
- inventory.overview(fetch)
- calls = len(fetch.calls)
- inventory.overview(fetch)
- assert len(fetch.calls) == calls
-
- inventory.releases_cache._next = 0
- inventory.overview(github(shas, fail=True))
- inventory.releases_cache.wait_for_refresh()
- result = inventory.overview(github(shas, fail=True))
- assert result["github"]["status"] == "available" and result["github"]["stale"] is True
- assert {p["repo"] for p in result["plugins"]} == {"Core", "Lib"}
-
-
-def test_with_a_token_releases_come_from_graphql_and_are_compared_with_the_default_branch(tmp_path, monkeypatch):
- main, _, shas = servers_fixture(tmp_path)
- monkeypatch.setenv("SERVER_MAIN_DIR", str(main))
- monkeypatch.setenv("SERVERS_GITHUB_TOKEN", "token")
- fetch = graphql(shas)
- result = inventory.overview(fetch)
- plugins = {p["repo"]: p for p in result["plugins"]}
- assert set(plugins) == {"Core", "Lib"}
- assert plugins["Core"]["servers"]["main"]["state"] == "older_release"
- assert plugins["Core"]["latest"]["branch"] == {"name": "main", "status": "ahead", "ahead": 3,
- "url": "https://github.com/TF-Minecraft/Core/compare/v1.1.0...main"}
- # GitHub couldn't compare Lib's release with master, so nothing is claimed.
- assert plugins["Lib"]["latest"]["branch"] is None
- # Two pages of repositories, one releases query and one comparison of the latest releases.
- assert len(fetch.queries) == 4
- compare = fetch.queries[-1]["variables"]
- assert {compare["t0"], compare["b0"], compare["t1"], compare["b1"]} == {
- "refs/tags/v1.1.0", "main", "refs/tags/v2.0.0", "master"}
-
-
-def test_queries_are_split_into_small_groups(tmp_path, monkeypatch):
+ first = inventory.fetch_releases(fetch)
+ fetch.repos["Core"].insert(0, release("v1.2.0", "2026-10-03T00:00:00Z", "core-1.2.0.jar", "new"))
+ second = inventory.fetch_releases(fetch)
+ assert first["Core"][0].version == "1.1.0"
+ assert second["Core"][0].version == "1.2.0"
+ assert first is not second
+ assert first.error is None and second.checked_at
+ if authenticated:
+ assert sum("organization(login" in b["query"] for b in fetch.queries) == 4
+ else:
+ assert sum(p.startswith("/orgs/") for p, _ in fetch.calls) == 2
+
+
+@pytest.mark.parametrize("authenticated", [False, True])
+def test_latest_is_github_designation_instead_of_newest_published_date(monkeypatch, authenticated):
+ if authenticated:
+ monkeypatch.setenv("SERVERS_GITHUB_TOKEN", "token")
+ fetch = Github({"Core": [release("v1.0.1", "2026-10-15T00:00:00Z", "core-1.0.1.jar", "old"),
+ release("v2.0.0", "2026-10-01T00:00:00Z", "core-2.0.0.jar", "new")]},
+ latest={"Core": "v2.0.0"})
+ assert inventory.fetch_releases(fetch)["Core"][0].version == "2.0.0"
+
+
+def test_identical_commits_annotated_tag_commit_skip_compare(tmp_path, monkeypatch):
_, _, shas = servers_fixture(tmp_path)
monkeypatch.setenv("SERVERS_GITHUB_TOKEN", "token")
- monkeypatch.setattr(inventory, "GRAPHQL_CHUNK", 1)
- fetch = graphql(shas)
- assert set(inventory.fetch_releases(fetch)) == {"Core", "Lib"}
- # 2 repository pages, then releases for each of 3 repositories and comparisons for the 2 with releases.
- assert len(fetch.queries) == 7
-
-
-def test_a_bad_graphql_answer_fails_the_check(monkeypatch):
- monkeypatch.setenv("SERVERS_GITHUB_TOKEN", "token")
- releases, _, failed = inventory.releases_cache.get(lambda path, body=None: {"errors": [{"message": "nope"}]})
- assert releases is None and failed is True
+ fetch = github(shas)
+ found = inventory.fetch_releases(fetch)
+ assert found["Core"][0].branch["status"] == "identical"
+ assert found["Lib"][0].branch["name"] == "master"
+ assert not any("compare(headRef" in q["query"] for q in fetch.queries)
+ latest_queries = [q["query"] for q in fetch.queries if "latestRelease" in q["query"]]
+ assert latest_queries and all("tagCommit { oid }" in q for q in latest_queries)
+ assert all("target { oid }" in q for q in latest_queries)
-def test_after_the_first_check_releases_refresh_in_the_background(tmp_path, monkeypatch):
+def test_unequal_commits_only_compare_mismatches_and_unknown_tag_commit_stays_unknown(tmp_path, monkeypatch):
_, _, shas = servers_fixture(tmp_path)
- first, _, _ = inventory.releases_cache.get(github(shas))
- inventory.releases_cache._next = 0
- slow = threading.Event()
-
- def waiting(path, body=None):
- slow.wait(5)
- raise urllib.error.URLError("down")
-
- # The expired releases come back at once while GitHub is asked again.
- again, _, failed = inventory.releases_cache.get(waiting)
- assert again is first and failed is False
- slow.set()
- inventory.releases_cache.wait_for_refresh()
- kept, _, failed = inventory.releases_cache.get(waiting)
- assert kept is first and failed is True
-
+ monkeypatch.setenv("SERVERS_GITHUB_TOKEN", "token")
+ fetch = Github(repo_data(shas), heads={"Core": "head"}, commits={"Lib": None})
+ found = inventory.fetch_releases(fetch)
+ assert found["Core"][0].branch == {"name": "main", "status": "ahead", "ahead": 3,
+ "url": "https://github.com/TF-Minecraft/Core/compare/v1.1.0...main"}
+ assert found["Lib"][0].branch is None
+ [compared] = [q for q in fetch.queries if "compare(headRef" in q["query"]]
+ assert compared["variables"] == {"org": "TF-Minecraft", "r0": "Core", "t0": "refs/tags/v1.1.0", "b0": "main"}
-def test_github_is_read_by_one_fetch_at_a_time(tmp_path):
- _, _, shas = servers_fixture(tmp_path)
- inventory.releases_cache.get(github(shas))
- inventory.releases_cache._next = 0
- entered, release_it = threading.Event(), threading.Event()
- fetches = []
- def slow(path, body=None):
- if path.startswith("/orgs/"):
- fetches.append(path)
- entered.set()
- release_it.wait(5)
- return github(shas)(path)
-
- def unused(path, body=None):
- fetches.append("second")
- return github(shas)(path)
-
- inventory.releases_cache.get(slow) # starts the background refresh
- assert entered.wait(5)
- # Another page load and a waiting caller share the refresh in flight.
- inventory.releases_cache.get(unused)
- waited = []
- waiter = threading.Thread(target=lambda: waited.append(inventory.releases_cache.get(unused, wait=True)))
- waiter.start()
- waiter.join(0.1)
- assert waiter.is_alive()
- release_it.set()
- waiter.join(5)
- [(releases, _, failed)] = waited
- assert releases is not None and failed is False and len(fetches) == 1
-
-
-def test_any_fetch_error_is_recorded_for_a_waiting_caller(tmp_path):
+def test_latest_queries_are_split_into_bounded_chunks(tmp_path, monkeypatch):
_, _, shas = servers_fixture(tmp_path)
- inventory.releases_cache.get(github(shas))
- inventory.releases_cache._next = 0
-
- def cut_short(path, body=None):
- raise http.client.IncompleteRead(b"")
-
- releases, _, failed = inventory.releases_cache.get(cut_short, wait=True)
- assert releases is not None and failed is True
+ monkeypatch.setenv("SERVERS_GITHUB_TOKEN", "token")
+ monkeypatch.setattr(inventory, "GRAPHQL_CHUNK", 1)
+ fetch = github(shas)
+ found = inventory.fetch_releases(fetch)
+ assert set(found) == {"Core", "Lib"} and found.repos == {"Core", "Lib", "Docs"}
+ assert len([q for q in fetch.queries if "latestRelease" in q["query"]]) == 3
-def test_a_fetch_running_when_the_cache_is_cleared_is_ignored(tmp_path):
- _, _, shas = servers_fixture(tmp_path)
- entered, release_it = threading.Event(), threading.Event()
+def test_partial_latest_failure_keeps_successful_repo_and_local_plugin_visible(tmp_path, monkeypatch):
+ main, _, shas = servers_fixture(tmp_path)
+ monkeypatch.setenv("SERVERS_GITHUB_TOKEN", "token")
+ monkeypatch.setenv("SERVER_MAIN_DIR", str(main))
+ result = inventory.overview(Github(repo_data(shas), failed={"Core"}))
+ assert result["github"]["status"] == "partial"
+ plugins = {p["repo"]: p for p in result["plugins"]}
+ assert plugins["Lib"]["latest"]["version"] == "2.0.0"
+ assert plugins["Core"]["latest"] is None and plugins["Core"]["error"] == "github_unavailable"
+ assert plugins["Core"]["servers"]["main"]["state"] == "unverified"
+ assert plugins["Core"]["servers"]["main"]["version"] == "1.0.0"
- def slow(path, body=None):
- entered.set()
- release_it.wait(5)
- return github(shas)(path)
- waiter = threading.Thread(target=lambda: inventory.releases_cache.get(slow))
- waiter.start()
- assert entered.wait(5)
- inventory.releases_cache.clear()
- release_it.set()
- waiter.join(5)
- assert inventory.releases_cache._state() == (None, None, False)
+def test_global_failure_does_not_reuse_previous_releases_and_keeps_local_inventory(tmp_path, monkeypatch):
+ main, _, shas = servers_fixture(tmp_path)
+ monkeypatch.setenv("SERVER_MAIN_DIR", str(main))
+ inventory.overview(github(shas))
+ failed = inventory.overview(github({}, fail=True))
+ assert failed["github"] == {"status": "unavailable", "checked_at": None}
+ assert failed["plugins"] == []
+ assert {p["name"] for p in failed["others"]} == {"Core", "Lib", "Essentials"}
+ assert all(p["unverified"] for p in failed["others"])
+ assert failed["servers"][0]["paper"] == "1.21.10-130"
-def test_a_graphql_error_in_releases_fails_the_check_but_not_in_comparisons(tmp_path, monkeypatch):
- _, _, shas = servers_fixture(tmp_path)
+def test_bad_graphql_answer_fails_fresh_check(monkeypatch):
monkeypatch.setenv("SERVERS_GITHUB_TOKEN", "token")
- fetch = graphql(shas)
-
- def missing_repository(path, body=None):
- answer = fetch(path, body)
- if "...releases" in body["query"]:
- answer["data"]["r0"] = None
- answer["errors"] = [{"type": "NOT_FOUND", "path": ["r0"]}]
- return answer
+ found = inventory.fetch_releases(lambda path, body=None: {"errors": [{"message": "nope"}]})
+ assert found.error == "github_unavailable" and not found and found.checked_at is None
- releases, _, failed = inventory.releases_cache.get(missing_repository)
- assert releases is None and failed is True
- def compare_error(path, body=None):
- answer = fetch(path, body)
- if "compare" in body["query"]:
- answer["errors"] = [{"type": "NOT_FOUND", "path": ["r1", "ref"]}]
+def test_exact_old_release_tag_verifies_hash_without_history(tmp_path, monkeypatch):
+ main = tmp_path / "main"
+ sha = make_jar(main / "plugins", "renamed.jar", "Core", "1.0.0")
+ rows = [release("v2.0.0", "2026-10-02T00:00:00Z", "core-2.0.0.jar", "new"),
+ release("v1.0.0", "2026-10-01T00:00:00Z", "core-1.0.0.jar", sha)]
+ monkeypatch.setenv("SERVERS_GITHUB_TOKEN", "token")
+ fetch = Github({"Core": rows})
+ found, servers = inventory.check_servers({"main": (main, True)}, fetch)
+ [plugin], others = inventory.compare(found, servers)
+ assert plugin["servers"]["main"]["state"] == "older_release" and not others
+ assert plugin["latest"]["version"] == "2.0.0"
+ assert any("release(tagName:$t" in q["query"] for q in fetch.queries)
+ assert not any("releases(first:30" in q["query"] for q in fetch.queries)
+ assert {v for q in fetch.queries for k, v in q["variables"].items() if k.startswith("t")} >= {"v1.0.0", "1.0.0"}
+
+
+@pytest.mark.parametrize("authenticated", [False, True])
+def test_history_paginates_past_thirty_and_recognises_renamed_old_jar(tmp_path, monkeypatch, authenticated):
+ if authenticated:
+ monkeypatch.setenv("SERVERS_GITHUB_TOKEN", "token")
+ root = tmp_path / "main"
+ sha = make_jar(root / "plugins", "old-engine-custom.jar", "Core", "legacy")
+ rows = [release(f"v2.0.{i}", "2026-10-15T00:00:00Z", f"core-2.0.{i}.jar", str(i)) for i in range(31)]
+ rows.append(release("v0.9.0", "2025-01-01T00:00:00Z", "ancient-0.9.0.jar", sha))
+ fetch = Github({"Core": rows})
+ found, servers = inventory.check_servers({"main": (root, True)}, fetch)
+ [plugin], others = inventory.compare(found, servers)
+ assert plugin["servers"]["main"]["state"] == "older_release"
+ assert plugin["servers"]["main"]["release"] == "0.9.0" and not others
+ if authenticated:
+ pages = [q["variables"]["after"] for q in fetch.queries if "releases(first:30" in q["query"]]
+ assert pages == [None, "30"]
+ else:
+ assert any("per_page=30&page=2" in p for p, _ in fetch.calls)
+
+
+def test_asset_connection_paginates_before_resolving_old_hash(tmp_path, monkeypatch):
+ monkeypatch.setenv("SERVERS_GITHUB_TOKEN", "token")
+ root = tmp_path / "main"
+ sha = make_jar(root / "plugins", "renamed.jar", "Core", "1.0.0")
+ old = release("v1.0.0", "2026-10-01T00:00:00Z", "core-1.0.0.jar", sha)
+ old["assets"] = [{"name": f"notes-{n}.txt", "digest": "sha256:notes"} for n in range(25)] + old["assets"]
+ fetch = Github({"Core": [release("v2.0.0", "2026-10-02T00:00:00Z", "core-2.0.0.jar", "new"), old]})
+ found, servers = inventory.check_servers({"main": (root, True)}, fetch)
+ [plugin], _ = inventory.compare(found, servers)
+ assert plugin["servers"]["main"]["state"] == "older_release"
+ assert any("releaseAssets(first:20,after:" in q["query"] for q in fetch.queries)
+
+
+@pytest.mark.parametrize("authenticated", [False, True])
+def test_incomplete_history_stays_unverified_instead_of_claiming_a_test_build(tmp_path, monkeypatch, authenticated):
+ if authenticated:
+ monkeypatch.setenv("SERVERS_GITHUB_TOKEN", "token")
+ root = tmp_path / "dev"
+ make_jar(root / "plugins", "core-branch.jar", "Core", "main-SNAPSHOT")
+ fetch = Github({"Core": [release("v2.0.0", "2026-10-02T00:00:00Z", "core-2.0.0.jar", "new")]}, history_failed={"Core"})
+ found, servers = inventory.check_servers({"dev": (root, False)}, fetch)
+ [plugin], _ = inventory.compare(found, servers)
+ assert plugin["servers"]["dev"]["state"] == "unverified"
+ assert "Core" not in found.complete
+
+
+def test_same_size_same_mtime_jar_bytes_are_read_again(tmp_path):
+ plugins = tmp_path / "plugins"
+ first = make_jar(plugins, "core.jar", "Core", "1.0.0", b"AAAA")
+ [original] = inventory.read_jars(plugins)
+ path = plugins / "core.jar"
+ stamp = path.stat()
+ second = make_jar(plugins, "core.jar", "Core", "1.0.0", b"BBBB")
+ os.utime(path, ns=(stamp.st_atime_ns, stamp.st_mtime_ns))
+ [changed] = inventory.read_jars(plugins)
+ assert path.stat().st_size == stamp.st_size
+ assert path.stat().st_mtime_ns == stamp.st_mtime_ns
+ assert original.sha256 == first and changed.sha256 == second and first != second
+
+
+def test_two_checks_have_independent_results_and_third_returns_busy_with_local_files(tmp_path, monkeypatch):
+ root = tmp_path / "dev"
+ make_jar(root / "plugins", "core.jar", "Core", "1.0.0")
+ entered = [threading.Event(), threading.Event()]
+ release_work = threading.Event()
+ results = [None, None]
+ calls = []
+ workers = []
+ def fetch(index):
+ def answer(path, body=None):
+ calls.append((index, path))
+ if path.startswith("/orgs/"):
+ entered[index].set()
+ assert release_work.wait(5), "test did not release GitHub worker"
+ return Github({"Core": [release(f"v{index + 1}.0.0", "2026-10-01T00:00:00Z", f"core-{index + 1}.0.0.jar", str(index))]})(path, body)
return answer
+ def run(index):
+ results[index] = inventory.fetch_releases(fetch(index))
+ try:
+ for i in range(2):
+ worker = threading.Thread(target=run, args=(i,))
+ workers.append(worker)
+ worker.start()
+ assert entered[i].wait(3)
+ busy, local = inventory.check_servers({"dev": (root, False)}, lambda *_: pytest.fail("busy check fetched GitHub"))
+ assert busy.error == "busy" and busy.checked_at is None
+ assert local["dev"]["jars"][0].name == "Core"
+ assert {i for i, path in calls if path.startswith("/orgs/")} == {0, 1}
+ finally:
+ release_work.set()
+ for worker in workers:
+ worker.join(5)
+ assert not worker.is_alive()
+ assert [r["Core"][0].version for r in results] == ["1.0.0", "2.0.0"]
+def test_startup_comes_from_the_newest_log_that_has_one(tmp_path):
+ logs = tmp_path / "logs"
+ logs.mkdir()
+ # After midnight, latest.log holds no startup; the rotated logs are read newest first.
+ (logs / "latest.log").write_text("[00:01:00] [Server thread/INFO]: nothing\n")
+ with gzip.open(logs / "2026-10-10-2.log.gz", "wt") as handle:
+ handle.write(startup_log(("Core", "0.9"), paper="old"))
+ with gzip.open(logs / "2026-10-10-10.log.gz", "wt") as handle:
+ handle.write(startup_log(("Core", "1.1"), done=False))
+ startup = inventory.read_startup(logs)
+ assert startup == {"paper": "1.21.10-130", "done": False, "log": "2026-10-10-10.log.gz",
+ "enabled": {"core": "1.1"}, "failed": {}}
+ assert inventory.read_startup(tmp_path / "nowhere") is None
- releases = inventory.fetch_releases(compare_error)
- assert releases["Core"][0].branch["status"] == "ahead" and releases["Lib"][0].branch is None
-
-
-def test_a_caller_can_wait_for_fresh_releases(tmp_path, monkeypatch):
- _, _, shas = servers_fixture(tmp_path)
- inventory.releases_cache.get(github(shas))
- inventory.releases_cache._next = 0
- releases, _, failed = inventory.releases_cache.get(github(shas, fail=True), wait=True)
- assert releases is not None and failed is True
def test_unreadable_jar_keeps_its_hash_without_a_version(tmp_path):
@@ -439,3 +515,166 @@ def test_plugin_text_quoting_an_enable_failure_is_not_a_failure(tmp_path):
"[06:10:40] [Server thread/INFO]: Done (40.0s)! For help, type \"help\"\n")
startup = inventory.read_startup(tmp_path)
assert startup["enabled"] == {"core": "1.1"} and startup["failed"] == {}
+
+
+def test_partial_graphql_error_cannot_disguise_failed_latest_as_no_releases(monkeypatch):
+ monkeypatch.setenv("SERVERS_GITHUB_TOKEN", "token")
+ base = Github({"Core": [release("v1.0.0", "2026-10-01T00:00:00Z", "core-1.0.0.jar", "known")]})
+ def failed_latest(path, body=None):
+ result = base(path, body)
+ if "latestRelease" in body["query"]:
+ result["data"]["r0"]["latestRelease"] = None
+ result["errors"] = [{"message": "temporary failure", "path": ["r0", "latestRelease"]}]
+ return result
+ found = inventory.fetch_releases(failed_latest)
+ assert found.errors == {"Core": "github_unavailable"} and not found
+
+
+def test_failed_branch_compare_keeps_latest_release_without_claiming_identity(monkeypatch):
+ monkeypatch.setenv("SERVERS_GITHUB_TOKEN", "token")
+ base = Github({"Core": [release("v1.0.0", "2026-10-01T00:00:00Z", "core-1.0.0.jar", "known")]},
+ heads={"Core": "changed"})
+ def failed_compare(path, body=None):
+ if "compare(headRef" in body["query"]:
+ return {"data": {"r0": {"ref": None}}, "errors": [{"path": ["r0", "ref"], "message": "unknown tag"}]}
+ return base(path, body)
+ found = inventory.fetch_releases(failed_compare)
+ assert found["Core"][0].version == "1.0.0" and found["Core"][0].branch is None
+
+
+@pytest.mark.parametrize("authenticated", [False, True])
+def test_failure_after_full_first_history_page_cannot_prove_unreleased_bytes(tmp_path, monkeypatch, authenticated):
+ if authenticated:
+ monkeypatch.setenv("SERVERS_GITHUB_TOKEN", "token")
+ root = tmp_path / "dev"
+ make_jar(root / "plugins", "core-branch.jar", "Core", "branch-SNAPSHOT")
+ base = Github({"Core": [release(f"v2.0.{i}", "2026-10-01T00:00:00Z", f"core-2.0.{i}.jar", str(i)) for i in range(31)]})
+ def partial_history(path, body=None):
+ if (body and "releases(first:30" in body["query"] and body["variables"]["after"] == "30") or "page=2" in path:
+ raise urllib.error.URLError("history page two down")
+ return base(path, body)
+ found, local = inventory.check_servers({"dev": (root, False)}, partial_history)
+ [plugin], _ = inventory.compare(found, local)
+ assert plugin["servers"]["dev"]["state"] == "unverified"
+ assert "Core" not in found.complete
+
+
+def test_latest_asset_connection_paginates_beyond_first_twenty_assets(monkeypatch):
+ monkeypatch.setenv("SERVERS_GITHUB_TOKEN", "token")
+ row = release("v1.0.0", "2026-10-01T00:00:00Z", "core-1.0.0.jar", "known")
+ row["assets"] = [{"name": f"notes-{n}.txt", "digest": "sha256:notes"} for n in range(25)] + row["assets"]
+ fetch = Github({"Core": [row]})
+ found = inventory.fetch_releases(fetch)
+ assert found["Core"][0].sha256 == "known" and found["Core"][0].branch["status"] == "identical"
+ assert any("releaseAssets(first:20,after:" in q["query"] for q in fetch.queries)
+
+
+def test_local_inventory_and_github_reads_overlap(tmp_path, monkeypatch):
+ root = tmp_path / "main"
+ make_jar(root / "plugins", "core.jar", "Core", "1.0.0")
+ local_started, remote_started = threading.Event(), threading.Event()
+ original = inventory._local_server
+ def local(*args):
+ local_started.set()
+ assert remote_started.wait(3), "GitHub work waited for local inventory"
+ return original(*args)
+ def remote(path, body=None):
+ remote_started.set()
+ assert local_started.wait(3), "local inventory waited for GitHub"
+ return []
+ monkeypatch.setattr(inventory, "_local_server", local)
+ found, servers = inventory.check_servers({"main": (root, True)}, remote)
+ assert found.error is None and servers["main"]["jars"][0].name == "Core"
+
+
+@pytest.mark.parametrize("authenticated", [False, True])
+@pytest.mark.parametrize("releases_only", [False, True])
+def test_exhausted_history_with_missing_jar_digest_keeps_unmatched_bytes_unverified(tmp_path, monkeypatch, authenticated, releases_only):
+ if authenticated:
+ monkeypatch.setenv("SERVERS_GITHUB_TOKEN", "token")
+ key = "main" if releases_only else "dev"
+ root = tmp_path / key
+ make_jar(root / "plugins", "core-custom.jar", "Core", "legacy")
+ old = release("v1.0.0", "2026-09-01T00:00:00Z", "core-1.0.0.jar", "unknown")
+ old["assets"][-1]["digest"] = None
+ fetch = Github({"Core": [release("v2.0.0", "2026-10-01T00:00:00Z", "core-2.0.0.jar", "new"), old]})
+ found, local = inventory.check_servers({key: (root, releases_only)}, fetch)
+ [plugin], others = inventory.compare(found, local)
+ assert plugin["servers"][key]["state"] == "unverified" and not others
+ assert plugin["servers"][key]["release"] is None
+ assert "Core" not in found.complete
+
+
+@pytest.mark.parametrize("authenticated", [False, True])
+def test_positive_historical_hash_wins_even_when_another_release_has_no_jar_digest(tmp_path, monkeypatch, authenticated):
+ if authenticated:
+ monkeypatch.setenv("SERVERS_GITHUB_TOKEN", "token")
+ root = tmp_path / "main"
+ sha = make_jar(root / "plugins", "renamed-engine.jar", "Core", "legacy")
+ unknown = release("v1.5.0", "2026-09-15T00:00:00Z", "core-1.5.0.jar", "unknown")
+ unknown["assets"][-1]["digest"] = None
+ rows = [release("v2.0.0", "2026-10-01T00:00:00Z", "core-2.0.0.jar", "new"), unknown,
+ release("v1.0.0", "2026-09-01T00:00:00Z", "core-1.0.0.jar", sha)]
+ found, local = inventory.check_servers({"main": (root, True)}, Github({"Core": rows}))
+ [plugin], others = inventory.compare(found, local)
+ copy = plugin["servers"]["main"]
+ assert copy["state"] == "older_release" and copy["release"] == "1.0.0" and not others
+ assert plugin["latest"]["version"] == "2.0.0"
+
+
+@pytest.mark.parametrize("authenticated", [False, True])
+def test_second_jar_asset_in_a_historical_release_can_prove_the_installed_release(tmp_path, monkeypatch, authenticated):
+ if authenticated:
+ monkeypatch.setenv("SERVERS_GITHUB_TOKEN", "token")
+ root = tmp_path / "main"
+ sha = make_jar(root / "plugins", "renamed-engine.jar", "Core", "legacy")
+ old = release("v1.0.0", "2026-09-01T00:00:00Z", "core-api-1.0.0.jar", "f" * 64)
+ old["assets"].append({"name": "core-1.0.0.jar", "digest": f"sha256:{sha}"})
+ rows = [release("v2.0.0", "2026-10-01T00:00:00Z", "core-2.0.0.jar", "new"), old]
+ found, local = inventory.check_servers({"main": (root, True)}, Github({"Core": rows}))
+ [plugin], others = inventory.compare(found, local)
+ assert plugin["servers"]["main"]["state"] == "older_release" and not others
+ assert plugin["servers"]["main"]["release"] == "1.0.0"
+ assert sha in {r.sha256 for r in found["Core"]}
+
+
+def test_http_body_trickle_cannot_extend_the_check_deadline(monkeypatch):
+ import http.server
+ import time
+ from contextlib import contextmanager
+
+ class Handler(http.server.BaseHTTPRequestHandler):
+ def log_message(self, *_):
+ pass
+
+ def do_GET(self):
+ self.send_response(200)
+ self.end_headers()
+ try:
+ for byte in b'{"value":"a deliberately slow JSON body"}':
+ self.wfile.write(bytes([byte]))
+ self.wfile.flush()
+ time.sleep(.02)
+ except (BrokenPipeError, ConnectionResetError):
+ pass
+
+ server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ original = inventory.urllib.request.urlopen
+
+ @contextmanager
+ def local(_request, timeout):
+ with original(f"http://127.0.0.1:{server.server_port}/", timeout=timeout) as response:
+ yield response
+
+ monkeypatch.setattr(inventory.urllib.request, "urlopen", local)
+ start = time.monotonic()
+ try:
+ with pytest.raises((TimeoutError, OSError)):
+ inventory._github("/graphql", timeout=.1)
+ assert time.monotonic() - start < .4
+ finally:
+ server.shutdown()
+ server.server_close()
+ thread.join(2)
diff --git a/frontend/app/components/admin/ServersOverview.test.tsx b/frontend/app/components/admin/ServersOverview.test.tsx
index 2243eae5..4fd82518 100644
--- a/frontend/app/components/admin/ServersOverview.test.tsx
+++ b/frontend/app/components/admin/ServersOverview.test.tsx
@@ -1,8 +1,9 @@
/** @vitest-environment jsdom */
-import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
+import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import ServersOverview from "./ServersOverview";
+import * as ServerControls from "./ServerControls";
import { AccountApiError } from "../../../lib/account/api";
import {
getServers,
@@ -18,6 +19,7 @@ vi.mock("../../../lib/admin/servers", async (importOriginal) => ({
afterEach(() => {
cleanup();
+ vi.restoreAllMocks();
vi.mocked(getServers).mockReset();
});
@@ -33,7 +35,7 @@ const copy = (over: Partial
+ {unverified} plugin{unverified === 1 ? "" : "s"} unverified +
+ ) : null}+ Checking servers and releases… +
++ Check failed. Showing the previous results from {formatLocal(data.checked_at)}. Try checking again. +
+ ) : null}GitHub didn’t answer, so releases can’t be compared. Try again in a minute.
- ) : data.github.stale ? ( -- GitHub didn’t answer; releases are from {formatLocal(data.github.checked_at)}. + ) : data.github.status === "partial" ? ( +
+ Some GitHub checks failed. Plugins marked Unverified need another check. +
+ ) : data.github.status === "busy" ? ( ++ Another release check is running. Try checking again shortly. +
+ ) : unverifiedOthers ? ( ++ Some installed files couldn’t be verified. Check plugins marked Unverified in Other plugins.
) : null} @@ -319,6 +370,9 @@ export default function ServersOverview() { {other.differs ? ( Differs ) : null} + {other.unverified ? ( + Unverified + ) : null} {readable.map((s) => { const found = other.servers[s.key]; @@ -356,17 +410,24 @@ export default function ServersOverview() { >{plugin.repo}
-