Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions backend/src/servers/control.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import json
import os
import socket
from pathlib import Path

from src.servers import inventory

Expand Down Expand Up @@ -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
22 changes: 14 additions & 8 deletions backend/src/servers/host.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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):
Expand All @@ -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:
Expand Down Expand Up @@ -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":
Expand Down
Loading
Loading