Compare commits

..
2 Commits
30 changed files with 1666 additions and 293 deletions
+35 -22
View File
@@ -1,7 +1,7 @@
# Electric Cherry # Electric Cherry
Electric Cherry manages T5 and T6 Plutonium Zombies servers through its own host Electric Cherry manages T5 and T6 Plutonium Zombies servers through its own host
agents. Docker and Plutainer run the games; EC handles hosts, game libraries, agents. Docker and Plutainer run the games; EC handles hosts, game files,
collections, configuration, campaigns, maintenance and operation history. collections, configuration, campaigns, maintenance and operation history.
Komodo is not required for native hosts. Komodo is not required for native hosts.
@@ -29,17 +29,19 @@ The default Compose data mount preserves the previous
`EC_DATA_DIR` to your preferred persistent directory. Back up that directory, `EC_DATA_DIR` to your preferred persistent directory. Back up that directory,
including SQLite metadata and published game-file storage. including SQLite metadata and published game-file storage.
1. **Library → Game Files**: select T5 or T6 and upload the complete local game 1. **Library → Game Files**: upload the complete local folder under T5 or T6,
folder, including its DLC files. Name the library and version, then publish it. including its DLC files. There are no library names or version labels to enter.
Each upload becomes the latest files for that game after verification.
Uploads work before any host is enrolled. Uploads work before any host is enrolled.
2. **Hosts → Add Host**: configure a Debian 12/13 amd64 machine and run its 2. **Hosts → Add Host**: configure a Debian 12/13 amd64 machine and run its
generated enrollment command. Automatic agent updates are enabled by default. generated enrollment command. The host automatically prepares its Plutainer
3. Prepare a Plutainer runtime from the host page and download the published runtime and the latest T5 and T6 files, when available. The host page
game-file version to that host from **Game Files**. shows setup progress. Automatic agent updates are enabled by default.
4. Add the exact Plutonium server keys in **Keys**, or supply missing keys when 3. Add the exact Plutonium server keys in **Keys**, or supply missing keys when
launching. launching.
5. Launch a server or collection with an explicit host, game-file version and 4. Launch a server or collection on a host. The latest files for each game and
runtime image. EC reserves host-specific ports and verifies startup. the default runtime are selected automatically. EC reserves host-specific
ports and verifies startup.
See [Native host management](docs/native-hosts.md) for installation, credentials, See [Native host management](docs/native-hosts.md) for installation, credentials,
storage, failure recovery, API contracts and the live-host acceptance checklist. storage, failure recovery, API contracts and the live-host acceptance checklist.
@@ -71,20 +73,31 @@ and safely retires existing servers as they become eligible.
Collections save target maps and lobby counts. Fill missing servers adds only the Collections save target maps and lobby counts. Fill missing servers adds only the
shortfall; Match collection exactly also queues safe removal of excess servers. shortfall; Match collection exactly also queues safe removal of excess servers.
Launch previews select a host, pinned base-game versions and an independent Launch previews use the latest verified files for each game and select an
runtime image. Every new launch uses the latest promoted configuration, scripts independent runtime image. If newer game files become available before launch,
and plugins when execution starts. Saved Plutonium keys are reused by exact refresh the preview to confirm the updated files. Every new launch uses the
server ID and retained after removal. latest promoted configuration, scripts and plugins when execution starts. Saved
Plutonium keys are reused by exact server ID and retained after removal.
## Libraries and campaigns ## Libraries and campaigns
Game-file versions contain the uploaded T5/T6 folder, with its directory layout Game Files has one upload slot each for T5 and T6. Uploads preserve the folder's
and included DLC preserved. There is no separate DLC declaration. Versions record directory layout and included DLC; there is no separate DLC declaration.
the game, label, file manifest, checksums and size. Uploads use bounded chunks, EC records immutable upload IDs, file manifests, checksums and sizes automatically.
support pause/resume and verify checksums before publication. Actual game files Uploads use bounded chunks, support pause/resume and verify checksums before
use dedicated filesystem storage, never SQLite blobs. Host downloads resume, publication. Actual game files
report progress and verify checksums before becoming usable. use dedicated filesystem storage, never SQLite blobs. The last upload to finish
Multiple instances on one host share read-only base files. verification becomes that game's latest files, even if it started earlier.
Active hosts always prepare the latest T5 and T6 files when available, including
hosts enrolled later. Host downloads transfer up to four files concurrently,
resume interrupted transfers,
report progress and verify checksums before becoming usable. Failed preparation
retries automatically with backoff; the host page reports any blocking condition.
Set `PLUTAINER_IMAGE` to change the default runtime prepared on hosts. Existing
servers retain their existing files and runtime without a forced restart.
Previous uploads, verified host copies and server references are preserved.
Existing named libraries appear together under their game, with no renaming or
reupload required. Multiple instances on one host share read-only base files.
Scripts, plugins, configuration baselines and overlays retain their versioning, Scripts, plugins, configuration baselines and overlays retain their versioning,
beta, promotion and rollback workflows. Campaigns stage bytes remotely without beta, promotion and rollback workflows. Campaigns stage bytes remotely without
@@ -95,7 +108,7 @@ operation history.
T5 Zombies uses `t5sp` and `dedicated_sp.cfg`; T6 uses `t6zm` and T5 Zombies uses `t5sp` and `dedicated_sp.cfg`; T6 uses `t6zm` and
`dedicated_zm.cfg`. Map selection generates `electric_cherry_map.cfg`, loaded `dedicated_zm.cfg`. Map selection generates `electric_cherry_map.cfg`, loaded
after the main config. T6 variants retain their distinct catalog selections. after the main config. T6 variants retain their distinct catalog selections.
Game-file libraries and Plutonium runtime revisions are tracked independently. Game-file uploads and Plutonium runtime revisions are tracked independently.
Game telemetry uses its own `EC_TELEMETRY_TOKEN`; configure reporters to send it Game telemetry uses its own `EC_TELEMETRY_TOKEN`; configure reporters to send it
in an authorization header. Anonymous reports are rejected before they can in an authorization header. Anonymous reports are rejected before they can
@@ -141,7 +154,7 @@ exercising enrollment. Production agent transport requires HTTPS.
`src/lib/server/hosts` owns enrollment, readiness and command transport; `src/lib/server/hosts` owns enrollment, readiness and command transport;
`src/lib/server/backends` provides instance identity and remote file access; `src/lib/server/backends` provides instance identity and remote file access;
`src/lib/server/provisioning/native.ts` connects existing lifecycle and campaign `src/lib/server/provisioning/native.ts` connects existing lifecycle and campaign
queues to agents; `src/lib/server/game-files` owns immutable libraries and queues to agents; `src/lib/server/game-files` owns immutable game-file uploads and
streaming distribution; `agent/` contains the service, installer and agent tests. streaming distribution; `agent/` contains the service, installer and agent tests.
Automated tests use simulated Docker/game hosts. Real Debian installer and Automated tests use simulated Docker/game hosts. Real Debian installer and
+138 -43
View File
@@ -7,6 +7,7 @@ unknown and is reported for reconciliation, never automatically executed twice.
import argparse import argparse
import base64 import base64
from concurrent.futures import ThreadPoolExecutor, as_completed
import contextlib import contextlib
import hashlib import hashlib
import fcntl import fcntl
@@ -29,9 +30,10 @@ import urllib.parse
import urllib.request import urllib.request
import uuid import uuid
VERSION = "1.2.0" VERSION = "1.2.1"
LABEL = "io.electric-cherry" LABEL = "io.electric-cherry"
MAX_FILE = 32 * 1024 * 1024 MAX_FILE = 32 * 1024 * 1024
LIBRARY_DOWNLOAD_WORKERS = 4
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$") ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$")
AGENT_CONFIG = Path("/etc/electric-cherry/agent.json") AGENT_CONFIG = Path("/etc/electric-cherry/agent.json")
AGENT_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-agent.py") AGENT_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-agent.py")
@@ -788,13 +790,46 @@ class Agent:
pass pass
libraries, caches = [], [] libraries, caches = [], []
for marker in (self.root / "libraries").glob("*/*/manifest.json"): for marker in (self.root / "libraries").glob("*/*/manifest.json"):
# A crash between writing a manifest and its atomic promotion must
# never expose the staging directory as a ready library.
if marker.parent.name.startswith("."):
continue
try: try:
value = json.loads(marker.read_text()) value = json.loads(marker.read_text())
libraries.append({"id": value["libraryId"], "state": "ready"}) libraries.append({"id": value["libraryId"], "state": "ready"})
caches.append({"libraryVersionId": value["libraryId"], "status": "ready", "bytesDownloaded": value["size"], "bytesTotal": value["size"], "manifestSha256": value.get("manifestSha256")}) caches.append({"libraryVersionId": value["libraryId"], "status": "ready", "bytesDownloaded": value["size"], "bytesTotal": value["size"], "manifestSha256": value.get("manifestSha256")})
except (OSError, ValueError, KeyError): except (OSError, ValueError, KeyError):
continue continue
progress = list(self.library_progress.values()) ready = {item["id"] for item in libraries}
progress = {key: value.copy() for key, value in list(self.library_progress.items()) if key not in ready}
for marker in (self.root / "libraries").glob("*/.*.partial/source.json"):
try:
value = json.loads(marker.read_text())
library_id = identifier(value["libraryId"])
if library_id in ready or library_id in progress:
continue
expected = self.library_dir(value["game"], library_id).with_name("." + library_id + ".partial")
if marker.parent != expected or marker.is_symlink() or marker.parent.is_symlink():
continue
files = value["files"]
canonical = json.dumps([{ "path": item["path"], "size": item["size"], "sha256": item["sha256"] } for item in files], separators=(",", ":"), ensure_ascii=False)
if digest(canonical.encode("utf-8")) != value["manifestSha256"]:
continue
retained, size, paths = 0, 0, set()
for item in files:
if item["path"] in paths or type(item["size"]) is not int or item["size"] < 0:
raise AgentError("Invalid partial library manifest")
paths.add(item["path"])
target = scoped(marker.parent / "content", item["path"])
size += item["size"]
if target.exists() and stat_module.S_ISREG(target.stat().st_mode):
retained += min(target.stat().st_size, item["size"])
# These are resumable bytes, not verified content. Reporting
# them survives restarts without requiring space for two copies.
progress[library_id] = {"libraryVersionId": library_id, "status": "missing", "bytesDownloaded": retained, "bytesTotal": size, "manifestSha256": value["manifestSha256"]}
except (AgentError, OSError, ValueError, KeyError, TypeError):
continue
progress = list(progress.values())
libraries.extend({"id": item["libraryVersionId"], "state": item["status"]} for item in progress) libraries.extend({"id": item["libraryVersionId"], "state": item["status"]} for item in progress)
caches.extend(progress) caches.extend(progress)
images = [] images = []
@@ -1195,6 +1230,65 @@ class Agent:
raise AgentError("Artifact redirects are not allowed") raise AgentError("Artifact redirects are not allowed")
return urllib.request.build_opener(NoRedirect()).open(req, timeout=60) return urllib.request.build_opener(NoRedirect()).open(req, timeout=60)
def download_library_file(self, item, target, files_url, update_progress, aborted):
def check_active():
if aborted.is_set():
raise AgentError("Library download stopped after a transfer failed")
check_active()
target.parent.mkdir(parents=True, exist_ok=True)
offset = target.stat().st_size if target.exists() else 0
if offset > item["size"]:
target.unlink()
offset = 0
checksum = hashlib.sha256()
# Hash the saved prefix once so resumed transfers still verify the whole
# file. New bytes are hashed as they arrive, avoiding a second disk read.
if offset:
with target.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
check_active()
checksum.update(chunk)
if offset == item["size"] and target.exists():
if checksum.hexdigest() == item["sha256"]:
update_progress(item["path"], offset)
return
target.unlink()
offset = 0
checksum = hashlib.sha256()
update_progress(item["path"], offset)
check_active()
url = files_url.rstrip("/") + "/" + urllib.parse.quote(item["path"], safe="/")
with self.artifact_open("GET", url, extra_headers={"Range": f"bytes={offset}-"} if offset else {}) as response:
if response.status == 206:
expected = f"bytes {offset}-{item['size'] - 1}/{item['size']}"
if response.headers.get("Content-Range") != expected:
raise AgentError("Invalid resumed download response")
elif response.status != 200:
raise AgentError("Invalid library download response")
elif offset:
# A server may ignore Range and send the whole file instead.
offset = 0
checksum = hashlib.sha256()
update_progress(item["path"], 0)
with target.open("ab" if offset else "wb") as output:
received = offset
while True:
check_active()
chunk = response.read(1024 * 1024)
if not chunk:
break
received += len(chunk)
if received > item["size"]:
raise AgentError("Library download exceeded its manifest size")
output.write(chunk)
checksum.update(chunk)
update_progress(item["path"], received)
output.flush()
os.fsync(output.fileno())
if target.stat().st_size != item["size"] or checksum.hexdigest() != item["sha256"]:
raise AgentError("Downloaded library file failed integrity verification")
def library_download(self, payload): def library_download(self, payload):
library_id = identifier(payload.get("libraryVersionId", payload.get("libraryId"))) library_id = identifier(payload.get("libraryVersionId", payload.get("libraryId")))
with self.artifact_open("GET", payload["manifestUrl"]) as response: with self.artifact_open("GET", payload["manifestUrl"]) as response:
@@ -1221,55 +1315,46 @@ class Agent:
source_marker = staging / "source.json" source_marker = staging / "source.json"
if source_marker.exists() and json.loads(source_marker.read_text()).get("manifestSha256") != manifest_sha: if source_marker.exists() and json.loads(source_marker.read_text()).get("manifestSha256") != manifest_sha:
raise Rejected("Partial library download belongs to a different manifest") raise Rejected("Partial library download belongs to a different manifest")
atomic_json(source_marker, {"libraryId": library_id, "manifestSha256": manifest_sha})
size = sum(item["size"] for item in files) size = sum(item["size"] for item in files)
downloaded = 0 atomic_json(source_marker, {"libraryId": library_id, "game": manifest["game"], "size": size, "files": files, "manifestSha256": manifest_sha})
paths = set() targets = {}
progress = {"libraryVersionId": library_id, "status": "downloading", "bytesDownloaded": 0, "bytesTotal": size, "manifestSha256": manifest_sha} progress = {"libraryVersionId": library_id, "status": "downloading", "bytesDownloaded": 0, "bytesTotal": size, "manifestSha256": manifest_sha}
self.library_progress[library_id] = progress self.library_progress[library_id] = progress
try: try:
if shutil.disk_usage(staging).free < max(0, size - sum(path.stat().st_size for path in staging.rglob("*") if path.is_file())): if shutil.disk_usage(staging).free < max(0, size - sum(path.stat().st_size for path in staging.rglob("*") if path.is_file())):
raise AgentError("Insufficient library storage") raise AgentError("Insufficient library storage")
# Validate every path before starting concurrent writes. In addition
# to duplicates, a manifest cannot treat a file as a parent folder.
for item in files: for item in files:
if item["path"] in paths or not isinstance(item["size"], int) or item["size"] < 0 or not re.fullmatch("[0-9a-f]{64}", item["sha256"]): if item["path"] in targets or not isinstance(item["size"], int) or item["size"] < 0 or not re.fullmatch("[0-9a-f]{64}", item["sha256"]):
raise AgentError("Invalid library file manifest") raise AgentError("Invalid library file manifest")
paths.add(item["path"]) targets[item["path"]] = scoped(staging / "content", item["path"])
target = scoped(staging / "content", item["path"]) for path in targets:
target.parent.mkdir(parents=True, exist_ok=True) if any(str(parent) in targets for parent in PurePosixPath(path).parents):
offset = target.stat().st_size if target.exists() else 0 raise AgentError("Library file paths overlap")
if offset > item["size"]: progress_lock = threading.Lock()
target.unlink() offsets = {}
offset = 0 aborted = threading.Event()
if offset == item["size"] and target.exists() and file_digest(target) != item["sha256"]:
target.unlink() def update_progress(path, offset):
offset = 0 with progress_lock:
if offset < item["size"] or not target.exists(): progress["bytesDownloaded"] += offset - offsets.get(path, 0)
url = payload["filesBaseUrl"].rstrip("/") + "/" + urllib.parse.quote(item["path"], safe="/") offsets[path] = offset
with self.artifact_open("GET", url, extra_headers={"Range": f"bytes={offset}-"} if offset else {}) as response:
if offset and response.status == 206: def transfer(item):
content_range = response.headers.get("Content-Range", "") try:
if not content_range.startswith(f"bytes {offset}-"): self.download_library_file(item, targets[item["path"]], payload["filesBaseUrl"], update_progress, aborted)
raise AgentError("Invalid resumed download response") except Exception:
elif offset: aborted.set()
offset = 0 raise
with target.open("ab" if offset else "wb") as output:
received = offset # Bound sockets, memory and disk pressure while allowing other files
while True: # to progress during network round trips and per-file fsyncs.
chunk = response.read(1024 * 1024) with ThreadPoolExecutor(max_workers=min(LIBRARY_DOWNLOAD_WORKERS, len(files))) as pool:
if not chunk: futures = [pool.submit(transfer, item) for item in files]
break for future in as_completed(futures):
received += len(chunk) future.result()
if received > item["size"]: progress["status"] = "verifying"
raise AgentError("Library download exceeded its manifest size")
output.write(chunk)
progress["bytesDownloaded"] = downloaded + received
output.flush()
os.fsync(output.fileno())
progress["status"] = "verifying"
if target.stat().st_size != item["size"] or file_digest(target) != item["sha256"]:
raise AgentError("Downloaded library file failed integrity verification")
downloaded += item["size"]
progress.update(status="downloading", bytesDownloaded=downloaded)
saved = {"libraryId": library_id, "game": manifest["game"], "size": size, "files": files, "manifestSha256": manifest_sha} saved = {"libraryId": library_id, "game": manifest["game"], "size": size, "files": files, "manifestSha256": manifest_sha}
atomic_json(staging / "manifest.json", saved) atomic_json(staging / "manifest.json", saved)
for path in (staging / "content").rglob("*"): for path in (staging / "content").rglob("*"):
@@ -1446,6 +1531,16 @@ class Agent:
if not self.config.get("pendingCredential") or self.recover_credential(): if not self.config.get("pendingCredential") or self.recover_credential():
return self.journal.update(operation["id"], "completed", {"rotated": True, "reconciled": True}) return self.journal.update(operation["id"], "completed", {"rotated": True, "reconciled": True})
return row return row
if kind == "runtime.pull":
# Docker resumes already downloaded layers. Pull again under
# the same operation ID: an existing tag can still refer to an
# older image and cannot prove an interrupted pull completed.
self.journal.update(operation["id"], "running")
try:
result = self.dispatch(operation)
return self.journal.update(operation["id"], "completed", {**result, "reconciled": True})
except Exception:
return self.journal.update(operation["id"], "failed", error="Interrupted runtime download could not be resumed")
if kind == "library.import": if kind == "library.import":
try: try:
with self.artifact_open("GET", payload["completeUrl"]) as response: with self.artifact_open("GET", payload["completeUrl"]) as response:
+221 -2
View File
@@ -4,6 +4,7 @@ import io
import json import json
from pathlib import Path from pathlib import Path
import tempfile import tempfile
import threading
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
@@ -107,6 +108,40 @@ class AgentTests(unittest.TestCase):
self.assertEqual(result["state"], "unknown") self.assertEqual(result["state"], "unknown")
self.assertEqual(self.docker.calls, []) self.assertEqual(self.docker.calls, [])
def test_agent_restart_resumes_runtime_pull_even_when_an_older_tag_is_cached(self):
image = "ghcr.io/ayymoss/plutainer:latest"
operation = self.operation("runtime.pull", {"image": image})
self.agent.journal.accept(operation)
self.agent.journal.update(operation["id"], "running")
aliases = self.agent.root / "agent/runtime-images.json"
module.atomic_json(aliases, {image: "sha256:older-image"})
self.agent.journal.close()
self.agent = Agent(self.config, self.docker)
original = self.docker.call
def call(*args, **kwargs):
result = original(*args, **kwargs)
return "sha256:new-image" if args[:2] == ("image", "inspect") else result
self.docker.call = call
result = self.agent.execute(operation)
self.assertEqual(result["state"], "completed")
self.assertEqual(json.loads(result["result"]), {"image": image, "ready": True, "reconciled": True})
self.assertEqual(json.loads(aliases.read_text())[image], "sha256:new-image")
self.agent.execute(operation)
self.assertEqual(sum(args[:2] == ("image", "pull") for args in self.docker.calls), 1)
def test_interrupted_runtime_pull_failure_is_reported_for_automatic_retry(self):
operation = self.operation("runtime.pull", {"image": "ghcr.io/ayymoss/plutainer:latest"})
self.agent.journal.accept(operation)
self.agent.journal.update(operation["id"], "running")
self.agent.journal.close()
self.agent = Agent(self.config, self.docker)
with patch.object(self.docker, "call", side_effect=AgentError("Registry unavailable")):
result = self.agent.execute(operation)
self.assertEqual(result["state"], "failed")
self.assertIn("runtime download", result["error"])
def test_occupied_and_unknown_wait_then_revalidate_same_operation(self): def test_occupied_and_unknown_wait_then_revalidate_same_operation(self):
self.create() self.create()
operation = self.operation("instance.restart") operation = self.operation("instance.restart")
@@ -198,8 +233,192 @@ class AgentTests(unittest.TestCase):
module.scoped(app, path) module.scoped(app, path)
def manifest(self, content=b"complete-game-file"): def manifest(self, content=b"complete-game-file"):
files = [{"path": "zone/test.ff", "size": len(content), "sha256": module.digest(content)}] return self.manifest_files({"zone/test.ff": content})
return {"id": "downloaded", "game": "t6", "files": files, "sizeBytes": len(content), "manifestSha256": module.digest(json.dumps(files, separators=(",", ":")).encode())}
def manifest_files(self, contents):
files = [{"path": path, "size": len(content), "sha256": module.digest(content)} for path, content in contents.items()]
return {"id": "downloaded", "game": "t6", "files": files, "sizeBytes": sum(item["size"] for item in files), "manifestSha256": module.digest(json.dumps(files, separators=(",", ":")).encode())}
def test_downloads_use_bounded_parallel_transfers_and_aggregate_progress(self):
contents = {f"zone/file-{index}.ff": bytes([index]) * 31 for index in range(8)}
manifest = self.manifest_files(contents)
barrier = threading.Barrier(4)
lock = threading.Lock()
active = maximum = 0
snapshots = []
agent = self.agent
class ConcurrentResponse(Response):
def __exit__(self, *args):
nonlocal active
with lock:
active -= 1
snapshots.append(agent.library_progress["downloaded"]["bytesDownloaded"])
return super().__exit__(*args)
def fetch(method, path, data=None, extra_headers=None):
nonlocal active, maximum
if path == "/manifest":
return Response(json.dumps(manifest).encode())
with lock:
active += 1
maximum = max(maximum, active)
# A serial downloader cannot reach this barrier. Two batches also
# prove that an unlimited worker pool cannot open all eight files.
barrier.wait(timeout=5)
return ConcurrentResponse(contents[path.removeprefix("/files/")])
self.agent.artifact_open = fetch
result = self.agent.library_download({"libraryVersionId": "downloaded", "manifestUrl": "/manifest", "filesBaseUrl": "/files"})
self.assertEqual(maximum, 4)
self.assertEqual(snapshots, sorted(snapshots))
self.assertEqual(max(snapshots), manifest["sizeBytes"])
self.assertEqual(result["size"], manifest["sizeBytes"])
for path, content in contents.items():
self.assertEqual((self.agent.library_dir("t6", "downloaded") / "content" / path).read_bytes(), content)
def test_fresh_download_hashes_stream_without_rereading_whole_file(self):
content = b"game-file" * 200000
manifest = self.manifest(content)
self.agent.artifact_open = lambda method, path, **kwargs: Response(json.dumps(manifest).encode() if path == "/manifest" else content)
with patch.object(module, "file_digest", side_effect=AssertionError("download reread the file")):
result = self.agent.library_download({"libraryVersionId": "downloaded", "manifestUrl": "/manifest", "filesBaseUrl": "/files"})
self.assertEqual(result["state"], "ready")
def test_ignored_range_restarts_file_and_checksum(self):
content = b"complete-game-file"
manifest = self.manifest(content)
staging = self.agent.root / "libraries/t6/.downloaded.partial/content/zone"
staging.mkdir(parents=True)
(staging / "test.ff").write_bytes(b"bad-prefix")
def fetch(method, path, data=None, extra_headers=None):
if path == "/manifest":
return Response(json.dumps(manifest).encode())
self.assertEqual(extra_headers, {"Range": "bytes=10-"})
return Response(content)
self.agent.artifact_open = fetch
result = self.agent.library_download({"libraryVersionId": "downloaded", "manifestUrl": "/manifest", "filesBaseUrl": "/files"})
self.assertEqual(result["state"], "ready")
self.assertEqual((self.agent.library_dir("t6", "downloaded") / "content/zone/test.ff").read_bytes(), content)
def test_resumed_download_verifies_saved_prefix(self):
content = b"complete-game-file"
manifest = self.manifest(content)
staging = self.agent.root / "libraries/t6/.downloaded.partial/content/zone"
staging.mkdir(parents=True)
(staging / "test.ff").write_bytes(b"corrupt!")
self.agent.artifact_open = lambda method, path, **kwargs: Response(json.dumps(manifest).encode()) if path == "/manifest" else Response(content[8:], 206, {"Content-Range": f"bytes 8-{len(content)-1}/{len(content)}"})
with self.assertRaisesRegex(AgentError, "integrity"):
self.agent.library_download({"libraryVersionId": "downloaded", "manifestUrl": "/manifest", "filesBaseUrl": "/files"})
self.assertFalse(self.agent.library_dir("t6", "downloaded").exists())
def test_parallel_failure_retains_partial_files_for_verified_resume(self):
contents = {"zone/first.ff": b"first-game-file", "zone/second.ff": b"second-game-file"}
manifest = self.manifest_files(contents)
payload = {"libraryVersionId": "downloaded", "manifestUrl": "/manifest", "filesBaseUrl": "/files"}
def failed_fetch(method, path, data=None, extra_headers=None):
if path == "/manifest":
return Response(json.dumps(manifest).encode())
return Response(contents[path.removeprefix("/files/")][:5])
self.agent.artifact_open = failed_fetch
with self.assertRaises(AgentError):
self.agent.library_download(payload)
self.assertFalse(self.agent.library_dir("t6", "downloaded").exists())
self.assertEqual(self.agent.library_progress["downloaded"]["status"], "failed")
resumed = []
def resume_fetch(method, path, data=None, extra_headers=None):
if path == "/manifest":
return Response(json.dumps(manifest).encode())
content = contents[path.removeprefix("/files/")]
if extra_headers:
self.assertEqual(extra_headers, {"Range": "bytes=5-"})
resumed.append(path)
return Response(content[5:], 206, {"Content-Range": f"bytes 5-{len(content)-1}/{len(content)}"})
return Response(content)
self.agent.artifact_open = resume_fetch
self.assertEqual(self.agent.library_download(payload)["state"], "ready")
self.assertTrue(resumed)
self.assertEqual(self.agent.verify_library(self.agent.library_dir("t6", "downloaded"))["size"], manifest["sizeBytes"])
def test_overlapping_paths_rejected_before_concurrent_downloads(self):
manifest = self.manifest_files({"zone": b"file", "zone/child.ff": b"child"})
calls = []
def fetch(method, path, data=None, extra_headers=None):
calls.append(path)
return Response(json.dumps(manifest).encode())
self.agent.artifact_open = fetch
with self.assertRaisesRegex(AgentError, "overlap"):
self.agent.library_download({"libraryVersionId": "downloaded", "manifestUrl": "/manifest", "filesBaseUrl": "/files"})
self.assertEqual(calls, ["/manifest"])
def test_failed_transfer_restart_reports_retained_bytes_and_resumes_with_only_remaining_space(self):
content = b"complete-game-file"
manifest = self.manifest(content)
payload = {"libraryVersionId": "downloaded", "manifestUrl": "/manifest", "filesBaseUrl": "/files"}
self.agent.artifact_open = lambda method, path, **kwargs: Response(json.dumps(manifest).encode() if path == "/manifest" else content[:8])
with self.assertRaises(AgentError):
self.agent.library_download(payload)
self.agent.journal.close()
self.agent = Agent(self.config, self.docker)
cache = next(item for item in self.agent.inventory()["libraryCaches"] if item["libraryVersionId"] == "downloaded")
self.assertEqual(cache, {"libraryVersionId": "downloaded", "status": "missing", "bytesDownloaded": 8, "bytesTotal": len(content), "manifestSha256": manifest["manifestSha256"]})
def fetch(method, path, data=None, extra_headers=None):
if path == "/manifest":
return Response(json.dumps(manifest).encode())
self.assertEqual(extra_headers, {"Range": "bytes=8-"})
return Response(content[8:], 206, {"Content-Range": f"bytes 8-{len(content)-1}/{len(content)}"})
self.agent.artifact_open = fetch
usage = module.shutil.disk_usage(self.agent.root)
with patch.object(module.shutil, "disk_usage", return_value=type(usage)(usage.total, usage.used, len(content) - 8)):
self.assertEqual(self.agent.library_download(payload)["state"], "ready")
def test_unpromoted_manifest_is_never_reported_as_ready_and_progress_is_not_duplicated(self):
content = b"complete-game-file"
manifest = self.manifest(content)
staging = self.agent.root / "libraries/t6/.downloaded.partial"
(staging / "content/zone").mkdir(parents=True)
# Oversized retained files can be replaced during resume. Their progress
# must still stay within the published file size.
(staging / "content/zone/test.ff").write_bytes(content + b"extra")
saved = {"libraryId": "downloaded", "game": "t6", "size": len(content), "files": manifest["files"], "manifestSha256": manifest["manifestSha256"]}
module.atomic_json(staging / "source.json", saved)
module.atomic_json(staging / "manifest.json", saved)
inventory = self.agent.inventory()
caches = [item for item in inventory["libraryCaches"] if item["libraryVersionId"] == "downloaded"]
self.assertEqual(len(caches), 1)
self.assertEqual(caches[0]["status"], "missing")
self.assertEqual(caches[0]["bytesDownloaded"], len(content))
self.assertNotIn({"id": "downloaded", "state": "ready"}, inventory["libraries"])
self.agent.library_progress["downloaded"] = {**caches[0], "status": "downloading", "bytesDownloaded": 3}
caches = [item for item in self.agent.inventory()["libraryCaches"] if item["libraryVersionId"] == "downloaded"]
self.assertEqual(len(caches), 1)
self.assertEqual(caches[0]["status"], "downloading")
self.assertEqual(caches[0]["bytesDownloaded"], 3)
def test_partial_inventory_rejects_changed_manifest_and_symlinked_content(self):
content = b"complete-game-file"
manifest = self.manifest(content)
staging = self.agent.root / "libraries/t6/.downloaded.partial"
(staging / "content/zone").mkdir(parents=True)
source = {"libraryId": "downloaded", "game": "t6", "files": manifest["files"], "manifestSha256": "0" * 64}
module.atomic_json(staging / "source.json", source)
(staging / "content/zone/test.ff").write_bytes(content)
self.assertFalse(any(item["libraryVersionId"] == "downloaded" for item in self.agent.inventory()["libraryCaches"]))
source["manifestSha256"] = manifest["manifestSha256"]
module.atomic_json(staging / "source.json", source)
(staging / "content/zone/test.ff").unlink()
(staging / "content/zone/test.ff").symlink_to(self.library / "content/game.ff")
self.assertFalse(any(item["libraryVersionId"] == "downloaded" for item in self.agent.inventory()["libraryCaches"]))
def test_resume_verifies_and_atomically_publishes_library(self): def test_resume_verifies_and_atomically_publishes_library(self):
content = b"complete-game-file" content = b"complete-game-file"
+63 -21
View File
@@ -18,23 +18,47 @@ the agent owns local execution and its durable operation journal.
installation, verifies the downloaded agent and updater SHA-256 over HTTPS, installation, verifies the downloaded agent and updater SHA-256 over HTTPS,
enrolls it and enables `electric-cherry-agent.service` and its automatic update enrolls it and enables `electric-cherry-agent.service` and its automatic update
timer. It needs no Git, Node.js or local build. timer. It needs no Git, Node.js or local build.
4. In **Hosts**, prepare a Plutainer runtime image. In **Game Files**, select T5 or 4. In **Game Files**, upload your complete local folder under T5 or T6. Include
T6 and upload your local game folder. Name the library and version and publish the DLC files in that folder; there is no separate DLC declaration, library
it after the upload finishes. Include the DLC files in that folder; there is name or version label. The upload becomes the latest files for that game after
no separate DLC declaration. Uploading also works before enrolling any host. verification. Uploading also works before enrolling any host.
5. Cache the published version on each destination host. Wait for verification. 5. Hosts automatically prepare the configured Plutainer runtime and the latest
Hosts report connection and readiness separately; deployment also checks the verified T5 and T6 files whenever available. **Automatic host setup** reports
particular library, image, capacity and ports you selected. A connected host progress and errors. Wait for verification before launching. Hosts report
missing content shows **Needs runtime** or **Needs game files**; these require connection and readiness separately; deployment also checks the particular
setup rather than waiting for an automatic preparation step. **Ready** means game files, image, capacity and ports required for the launch.
it has both a runtime image and a verified library available. 6. Launch a server or collection. Select the host; the latest game files and
6. Launch a server or collection. Select the host, immutable game-file version(s) default runtime are selected automatically. An advanced runtime override is
and runtime image. EC selects lobby IDs and reserves UDP/TCP ports atomically. available. If the latest files change after previewing, refresh the preview
before launching.
EC selects lobby IDs and reserves UDP/TCP ports atomically.
Saved Plutonium server keys remain associated with their exact server names. Saved Plutonium server keys remain associated with their exact server names.
A fresh destination host downloads centrally stored game files without manual A fresh destination host downloads centrally stored game files without manual
copying or per-container setup. EC does not supply the game content itself. copying or per-container setup. EC does not supply the game content itself.
The controller reconciles automatic setup on agent heartbeats and operation
polls. It first pulls `PLUTAINER_IMAGE` (default
`ghcr.io/ayymoss/plutainer:latest`), then prepares the latest verified upload for
T5 and for T6 when available, one setup operation at a time. Uploading new files
or enrolling another host needs no manual propagation. Setup waits for an active, connected,
healthy host with sufficient storage. Failed operations retry automatically with
backoff from one minute to fifteen minutes; their errors remain visible in the
host's setup panel and operation history. Disconnected hosts resume when they
reconnect. Draining and disabled hosts do not receive new automatic setup work.
An automatic download that has not been delivered to the agent can be replaced
when a newer upload becomes available. A delivered operation finishes safely
before the host prepares the latest files. Failed or incomplete uploads never
replace the latest verified files.
Verified previous uploads remain cached and running servers keep their existing
files and runtime; uploading new files does not restart servers. Automatic setup
ensures that the configured image is present; it does not refresh an already
cached mutable image tag. Advanced controls on the
host and game-file pages can explicitly pull a custom runtime, prepare an older
upload or retry preparation immediately.
The default host root is `/var/lib/electric-cherry`; installer `--data-dir` changes The default host root is `/var/lib/electric-cherry`; installer `--data-dir` changes
it. Use a dedicated, root-owned directory. For a separate disk, use a child of its it. Use a dedicated, root-owned directory. For a separate disk, use a child of its
mount point (for example `/mnt/games/electric-cherry`), with no nested mounts, so mount point (for example `/mnt/games/electric-cherry`), with no nested mounts, so
@@ -144,8 +168,13 @@ API outline:
- `POST /api/agent/operations/:id`: authenticated acceptance, progress or result. - `POST /api/agent/operations/:id`: authenticated acceptance, progress or result.
- `POST /api/agent/decommission/:id`: cleanup worker reports file removal and - `POST /api/agent/decommission/:id`: cleanup worker reports file removal and
agent uninstall using its own operation-scoped receipt credential. agent uninstall using its own operation-scoped receipt credential.
- `POST /api/game-files`: administrator creates a folder upload with game, name, - `GET /api/game-files`: returns `{libraries, hosts}`. `libraries` always contains
version, optional description and `files: [{path, size, lastModified}]`. two game slots, T5 and T6, including empty slots. Each slot has a nullable
`latestVersionId` identifying its latest verified upload, plus upload history.
- `POST /api/game-files`: administrator creates a folder upload with
`{game: "t5" | "t6", files: [{path, size, lastModified}]}`. Upload IDs and
internal revision metadata are generated automatically; no library name,
version label or description is required.
- `GET /api/game-files/:id/upload`: durable per-file offsets and publication status. - `GET /api/game-files/:id/upload`: durable per-file offsets and publication status.
- `PUT /api/game-files/:id/upload/files/:path`: administrator uploads a raw chunk - `PUT /api/game-files/:id/upload/files/:path`: administrator uploads a raw chunk
with `X-Upload-Offset`, `X-Upload-Length` and `X-Content-SHA256` headers. with `X-Upload-Offset`, `X-Upload-Length` and `X-Content-SHA256` headers.
@@ -227,7 +256,13 @@ retries the completion receipt from memory. A power loss in that final window
can leave the controller awaiting confirmation even though removal succeeded; can leave the controller awaiting confirmation even though removal succeeded;
inspect the machine if that status persists. The controller does not guess. inspect the machine if that status persists. The controller does not guess.
## Libraries and ports ## Game files and ports
Game Files always shows T5 and T6, each with its own upload button and latest
verified files. Successful uploads become latest in publication completion
order, not by their start date or an entered label. A newer upload still being
transferred or verified leaves the current files available. Previous uploads
appear in history, and interrupted uploads can be resumed separately.
Choose the actual game folder, not its parent, a ZIP archive or the Plutonium Choose the actual game folder, not its parent, a ZIP archive or the Plutonium
launcher data folder. The selected folder's name is removed once; nested paths launcher data folder. The selected folder's name is removed once; nested paths
@@ -246,19 +281,26 @@ unchanged folder; relative paths, sizes and modification times must match. Keep
the page open while transferring. Once publication starts, the controller hashes the page open while transferring. Once publication starts, the controller hashes
every complete file and publishes the directory atomically; closing the browser every complete file and publishes the directory atomically; closing the browser
does not stop that verification. An interrupted controller can retry publication. does not stop that verification. An interrupted controller can retry publication.
Published versions cannot be updated; Verified uploads are immutable; changing files means uploading another folder
changing files requires a new version. Hosts download with HTTP Range resumption, for the same game. Hosts download up to four files concurrently with HTTP Range
report progress, verify all checksums and atomically promote the complete cache. resumption, report progress, verify all checksums
and atomically promote the complete cache. Checksums are calculated while bytes
arrive, avoiding another complete read of newly downloaded files. Resumed files
include their saved prefix in verification. Progress reports are rate-limited so
controller round trips do not stall every transferred chunk.
Corrupt or incomplete content never becomes Ready. Cache inventory is reconciled Corrupt or incomplete content never becomes Ready. Cache inventory is reconciled
on heartbeats, including vanished caches. Older immutable versions remain usable on heartbeats, including vanished caches. Previous immutable uploads remain usable
when their exact manifest is verified. when their exact manifest is verified.
The controller and reverse proxy must accept 8 MiB upload requests and folder The controller and reverse proxy must accept 8 MiB upload requests and folder
manifests up to 32 MiB. Compose sets `BODY_SIZE_LIMIT=Infinity`; application routes manifests up to 32 MiB. Compose sets `BODY_SIZE_LIMIT=Infinity`; application routes
still enforce these limits, declared file sizes and checksums. Set a proxy body still enforce these limits, declared file sizes and checksums. Set a proxy body
limit of at least 32 MiB (for example `client_max_body_size 32m` in nginx). limit of at least 32 MiB (for example `client_max_body_size 32m` in nginx).
Existing published versions and cached host libraries remain usable after the Existing uploads, host caches and server references remain usable. Previously
upload workflow replaces source-host imports. named libraries are grouped under T5 or T6, and the most recently published
upload for each game becomes its latest files. Their immutable IDs and stored
content are preserved; no reupload is needed. Legacy source-host imports retain
their existing IDs and can complete through their original workflow.
Reservations include host ID, local bind address, protocol and port. Wildcard Reservations include host ID, local bind address, protocol and port. Wildcard
binds conflict with concrete addresses on the same host. UDP and TCP reservations binds conflict with concrete addresses on the same host. UDP and TCP reservations
+5 -1
View File
@@ -1,4 +1,8 @@
export type GameLibraryGame = 't5' | 't6'; export type GameLibraryGame = 't5' | 't6';
export const GAME_FILE_GAMES = [
{ id: 't5', name: 'Black Ops I (T5)' },
{ id: 't6', name: 'Black Ops II (T6)' }
] as const;
export type GameLibraryState = 'importing' | 'ready' | 'failed'; export type GameLibraryState = 'importing' | 'ready' | 'failed';
export type LibraryCacheState = 'missing' | 'downloading' | 'verifying' | 'ready' | 'failed' | 'outdated'; export type LibraryCacheState = 'missing' | 'downloading' | 'verifying' | 'ready' | 'failed' | 'outdated';
@@ -18,7 +22,7 @@ export interface GameLibraryVersion {
} }
export interface GameLibrary { export interface GameLibrary {
id: string; name: string; game: GameLibraryGame; description: string; id: string; name: string; game: GameLibraryGame; description: string;
createdAt: string; versions: GameLibraryVersion[]; createdAt: string; latestVersionId: string | null; versions: GameLibraryVersion[];
} }
export function formatGameBytes(bytes: number): string { export function formatGameBytes(bytes: number): string {
+10
View File
@@ -74,6 +74,7 @@ export interface Host {
readiness: HostReadiness; readinessReasons: string[]; inventory: HostInventory; readiness: HostReadiness; readinessReasons: string[]; inventory: HostInventory;
enrolled: boolean; credentialActive: boolean; enrolled: boolean; credentialActive: boolean;
decommission: HostDecommission | null; decommission: HostDecommission | null;
preparation?: HostPreparation;
} }
export interface HostOperation { export interface HostOperation {
id: string; hostId: string; kind: string; instanceId: string | null; generation: number | null; id: string; hostId: string; kind: string; instanceId: string | null; generation: number | null;
@@ -81,6 +82,15 @@ export interface HostOperation {
createdAt: string; updatedAt: string; createdAt: string; updatedAt: string;
} }
export interface HostPreparationItem {
kind: 'runtime' | 'library'; label: string;
state: 'waiting' | 'preparing' | 'ready' | 'blocked'; detail: string;
operationId?: string; bytesDownloaded?: number; bytesTotal?: number;
}
export interface HostPreparation {
state: HostPreparationItem['state']; items: HostPreparationItem[];
}
export interface NativeDeploymentSelection { export interface NativeDeploymentSelection {
hostId: string; libraryIds: Partial<Record<'t5' | 't6', string>>; image: string; hostId: string; libraryIds: Partial<Record<'t5' | 't6', string>>; image: string;
bindAddress?: string; publicAddress?: string; publicPort?: number; bindAddress?: string; publicAddress?: string; publicPort?: number;
+42 -33
View File
@@ -7,6 +7,7 @@ import { getHost, getHostOperation, listHosts, queueHostOperation } from '../hos
import { ensureGameFileSchema } from './schema.js'; import { ensureGameFileSchema } from './schema.js';
import { manifestChecksum, receiveGameFile, validateGamePath, validateManifest, verifyGameFile } from './storage.js'; import { manifestChecksum, receiveGameFile, validateGamePath, validateManifest, verifyGameFile } from './storage.js';
import { MAX_UPLOAD_CHUNK_BYTES, validateUploadPlan, getUploadFileProgress, receiveUploadChunk, hashUploadedFiles, withUploadLock } from './uploads.js'; import { MAX_UPLOAD_CHUNK_BYTES, validateUploadPlan, getUploadFileProgress, receiveUploadChunk, hashUploadedFiles, withUploadLock } from './uploads.js';
import { GAME_FILE_GAMES } from '../../game-files.js';
import type { GameFileEntry, GameLibrary, GameLibraryGame, GameLibraryVersion, HostLibraryCache, LibraryCacheState } from '../../game-files.js'; import type { GameFileEntry, GameLibrary, GameLibraryGame, GameLibraryVersion, HostLibraryCache, LibraryCacheState } from '../../game-files.js';
function database() { const handle = db(); ensureGameFileSchema(handle); return handle; } function database() { const handle = db(); ensureGameFileSchema(handle); return handle; }
@@ -35,15 +36,29 @@ export function getLibraryFile(versionId: string, path: string) {
.get(versionId, path) as (GameFileEntry & { state: GameLibraryVersion['state']; sourceHostId: string; storageLocation: string }) | undefined ?? null; .get(versionId, path) as (GameFileEntry & { state: GameLibraryVersion['state']; sourceHostId: string; storageLocation: string }) | undefined ?? null;
} }
export function newestReadyLibrary(game: string): GameLibraryVersion | null { export function newestReadyLibrary(game: string): GameLibraryVersion | null {
const row = database().prepare("SELECT v.*, l.game FROM game_file_versions v JOIN game_file_libraries l ON l.id=v.library_id WHERE l.game=? AND v.state='ready' ORDER BY v.created_at DESC, v.rowid DESC LIMIT 1").get(game) as VersionRow | undefined; const row = database().prepare("SELECT v.*, l.game FROM game_file_versions v JOIN game_file_libraries l ON l.id=v.library_id WHERE l.game=? AND v.state='ready' ORDER BY v.published_at DESC, v.created_at DESC, v.rowid DESC LIMIT 1").get(game) as VersionRow | undefined;
return row ? versionFromRow(row) : null; return row ? versionFromRow(row) : null;
} }
/** Hosts follow the latest successful publication for each game, including legacy libraries. */
export function latestPublishedLibraries() {
const latest = database().prepare(`SELECT v.id, l.game, v.version, v.size_bytes AS sizeBytes, v.manifest_sha256 AS manifestSha256, v.published_at AS publishedAt
FROM game_file_versions v JOIN game_file_libraries l ON l.id=v.library_id
WHERE v.state='ready' AND l.game=?
ORDER BY v.published_at DESC,v.created_at DESC,v.rowid DESC LIMIT 1`);
return GAME_FILE_GAMES.flatMap(game => {
const version = latest.get(game.id) as { id: string; game: GameLibraryGame; version: string; sizeBytes: number; manifestSha256: string; publishedAt: string | null } | undefined;
return version ? [{ ...version, name: game.name }] : [];
});
}
export function listLibraries(): GameLibrary[] { export function listLibraries(): GameLibrary[] {
reconcileLibraryOperations(); reconcileLibraryOperations();
return (database().prepare('SELECT * FROM game_file_libraries ORDER BY name').all() as Array<{ id: string; name: string; game: GameLibraryGame; description: string; created_at: string }>).map(row => ({ const latest = new Map(latestPublishedLibraries().map(version => [version.game, version.id]));
id: row.id, name: row.name, game: row.game, description: row.description, createdAt: row.created_at, return GAME_FILE_GAMES.map(game => {
versions: (database().prepare('SELECT v.*, l.game FROM game_file_versions v JOIN game_file_libraries l ON l.id=v.library_id WHERE l.id=? ORDER BY v.created_at DESC, v.rowid DESC').all(row.id) as VersionRow[]).map(versionFromRow) const versions = (database().prepare(`SELECT v.*, l.game FROM game_file_versions v JOIN game_file_libraries l ON l.id=v.library_id
})); WHERE l.game=? ORDER BY v.created_at DESC,v.rowid DESC`).all(game.id) as VersionRow[]).map(versionFromRow);
const firstLibrary = database().prepare('SELECT created_at FROM game_file_libraries WHERE game=? ORDER BY created_at,rowid LIMIT 1').get(game.id) as { created_at: string } | undefined;
return { id: game.id, name: game.name, game: game.id, description: '', createdAt: firstLibrary?.created_at ?? '', latestVersionId: latest.get(game.id) ?? null, versions };
});
} }
export function getHostLibraryCache(hostId: string, versionId: string): HostLibraryCache | null { export function getHostLibraryCache(hostId: string, versionId: string): HostLibraryCache | null {
const row = database().prepare('SELECT * FROM host_library_caches WHERE host_id=? AND version_id=?').get(hostId, versionId) as CacheRow | undefined; const row = database().prepare('SELECT * FROM host_library_caches WHERE host_id=? AND version_id=?').get(hostId, versionId) as CacheRow | undefined;
@@ -62,27 +77,29 @@ function textField(input: unknown, label: string, max: number, required = true):
return input.trim(); return input.trim();
} }
/** Reuse a game's original storage parent without rewriting immutable historical versions. */
function libraryForGame(game: GameLibraryGame, now: string): string {
const handle = database();
const existing = handle.prepare('SELECT id FROM game_file_libraries WHERE game=? ORDER BY created_at,rowid LIMIT 1').get(game) as { id: string } | undefined;
if (existing) return existing.id;
const id = randomUUID();
// The legacy name column is globally unique, so its private value must not collide with an old user label.
handle.prepare('INSERT INTO game_file_libraries(id,name,game,description,created_at) VALUES(?,?,?,?,?)').run(id, `game-files:${game}:${id}`, game, '', now);
return id;
}
/** Register a folder on the controller; no host or Docker volume is needed. */ /** Register a folder on the controller; no host or Docker volume is needed. */
export function createLibraryUpload(input: Record<string, unknown>): GameLibraryVersion { export function createLibraryUpload(input: Record<string, unknown>): GameLibraryVersion {
const game = input.game; const game = input.game;
if (game !== 't5' && game !== 't6') throw new Error('Choose T5 or T6 game files.'); if (game !== 't5' && game !== 't6') throw new Error('Choose T5 or T6 game files.');
const name = textField(input.name, 'library name', 160);
const description = textField(input.description ?? '', 'description', 4000, false);
const version = textField(input.version, 'version label', 120);
const files = validateUploadPlan(input.files); const files = validateUploadPlan(input.files);
const handle = database(); const handle = database();
const id = randomUUID(); const now = new Date().toISOString(); const id = randomUUID(); const now = new Date().toISOString();
handle.transaction(() => { handle.transaction(() => {
let library = handle.prepare('SELECT id,game FROM game_file_libraries WHERE name=?').get(name) as { id: string; game: string } | undefined; const libraryId = libraryForGame(game, now);
if (library && library.game !== game) throw new Error('This library name is already used for another game.');
if (!library) {
library = { id: randomUUID(), game };
handle.prepare('INSERT INTO game_file_libraries(id,name,game,description,created_at) VALUES(?,?,?,?,?)').run(library.id, name, game, description, now);
}
if (handle.prepare('SELECT 1 FROM game_file_versions WHERE library_id=? AND version=?').get(library.id, version)) throw new Error('This version label already exists. Choose a new version.');
// Keep legacy non-null columns for existing databases and published versions. // Keep legacy non-null columns for existing databases and published versions.
handle.prepare(`INSERT INTO game_file_versions(id,library_id,version,description,dlc,state,size_bytes,created_at,storage_location,source_host_id,source_volume) handle.prepare(`INSERT INTO game_file_versions(id,library_id,version,description,dlc,state,size_bytes,created_at,storage_location,source_host_id,source_volume)
VALUES(?,?,?,?,'[]','importing',?,?,?,'','')`).run(id, library.id, version, description, files.reduce((sum, file) => sum + file.size, 0), now, join(gameFilesRoot(), game, id)); VALUES(?,?,?,?,'[]','importing',?,?,?,'','')`).run(id, libraryId, id, '', files.reduce((sum, file) => sum + file.size, 0), now, join(gameFilesRoot(), game, id));
const insert = handle.prepare('INSERT INTO game_file_upload_entries(version_id,path,size,last_modified) VALUES(?,?,?,?)'); const insert = handle.prepare('INSERT INTO game_file_upload_entries(version_id,path,size,last_modified) VALUES(?,?,?,?)');
for (const file of files) insert.run(id, file.path, file.size, file.lastModified); for (const file of files) insert.run(id, file.path, file.size, file.lastModified);
})(); })();
@@ -173,22 +190,13 @@ export function createLibraryImport(input: Record<string, unknown>): GameLibrary
const hostId = textField(input.hostId, 'source host', 128); const hostId = textField(input.hostId, 'source host', 128);
const host = getHost(hostId); const host = getHost(hostId);
if (!host || !host.connected || !host.credentialActive || host.mode === 'disabled') throw new Error('Choose an online, enrolled source host.'); if (!host || !host.connected || !host.credentialActive || host.mode === 'disabled') throw new Error('Choose an online, enrolled source host.');
const name = textField(input.name, 'library name', 160);
const description = textField(input.description ?? '', 'description', 4000, false);
const version = textField(input.version, 'version label', 120);
const sourceVolume = game === 't5' ? 't5_game_files' : 't6_game_files'; const sourceVolume = game === 't5' ? 't5_game_files' : 't6_game_files';
const handle = database(); const handle = database();
let library = handle.prepare('SELECT id,game FROM game_file_libraries WHERE name=?').get(name) as { id: string; game: string } | undefined;
if (library && library.game !== game) throw new Error('This library name is already used for another game.');
const id = randomUUID(); const now = new Date().toISOString(); const id = randomUUID(); const now = new Date().toISOString();
handle.transaction(() => { handle.transaction(() => {
if (!library) { const libraryId = libraryForGame(game, now);
library = { id: randomUUID(), game };
handle.prepare('INSERT INTO game_file_libraries(id,name,game,description,created_at) VALUES(?,?,?,?,?)').run(library.id, name, game, description, now);
}
if (handle.prepare('SELECT 1 FROM game_file_versions WHERE library_id=? AND version=?').get(library.id, version)) throw new Error('This version label already exists. Choose a new version.');
handle.prepare(`INSERT INTO game_file_versions(id,library_id,version,description,dlc,state,created_at,storage_location,source_host_id,source_volume) handle.prepare(`INSERT INTO game_file_versions(id,library_id,version,description,dlc,state,created_at,storage_location,source_host_id,source_volume)
VALUES(?,?,?,?,?,'importing',?,?,?,?)`).run(id, library.id, version, description, '[]', now, join(gameFilesRoot(), game, id), hostId, sourceVolume); VALUES(?,?,?,?,?,'importing',?,?,?,?)`).run(id, libraryId, id, '', '[]', now, join(gameFilesRoot(), game, id), hostId, sourceVolume);
})(); })();
try { try {
const operation = queueHostOperation(hostId, 'library.import', { const operation = queueHostOperation(hostId, 'library.import', {
@@ -310,23 +318,25 @@ export function updateHostLibraryCaches(hostId: string, input: unknown, options:
})(); })();
} }
export function queueLibraryDownload(hostId: string, versionId: string) { export function queueLibraryDownload(hostId: string, versionId: string, options: { automatic?: boolean } = {}) {
const version = getLibraryVersion(versionId); const version = getLibraryVersion(versionId);
if (!version || version.state !== 'ready') throw new Error('Choose a published library version.'); if (!version || version.state !== 'ready') throw new Error('Choose a published library version.');
const host = getHost(hostId); const host = getHost(hostId);
if (!host || !host.connected || !host.credentialActive || host.mode === 'disabled') throw new Error('Choose an online, enrolled host.'); if (!host || !host.connected || !host.credentialActive || host.mode === 'disabled') throw new Error('Choose an online, enrolled host.');
if (Number(host.inventory.storageAvailable ?? 0) < version.sizeBytes + host.minimumFreeBytes) throw new Error('This host has insufficient storage for this library and its minimum free-space reserve.');
const prior = getHostLibraryCache(hostId, versionId); const prior = getHostLibraryCache(hostId, versionId);
if (prior?.operationId) { if (prior?.operationId) {
const operation = getHostOperation(prior.operationId); const operation = getHostOperation(prior.operationId);
if (operation && ['pending', 'accepted', 'running', 'unknown'].includes(operation.state)) return operation; if (operation && ['pending', 'accepted', 'running', 'unknown'].includes(operation.state)) return operation;
} }
const retainedBytes = prior?.manifestSha256 === version.manifestSha256 ? prior.bytesDownloaded : 0;
if (Number(host.inventory.storageAvailable ?? 0) < version.sizeBytes - retainedBytes + host.minimumFreeBytes) throw new Error('This host has insufficient storage for this library and its minimum free-space reserve.');
const operation = queueHostOperation(hostId, 'library.download', { const operation = queueHostOperation(hostId, 'library.download', {
libraryVersionId: versionId, game: version.game, manifestUrl: `/api/agent/libraries/${versionId}/manifest`, libraryVersionId: versionId, game: version.game, manifestUrl: `/api/agent/libraries/${versionId}/manifest`,
filesBaseUrl: `/api/agent/libraries/${versionId}/files`, statusUrl: `/api/agent/libraries/${versionId}/status`, filesBaseUrl: `/api/agent/libraries/${versionId}/files`, statusUrl: `/api/agent/libraries/${versionId}/status`,
manifestSha256: version.manifestSha256, sizeBytes: version.sizeBytes manifestSha256: version.manifestSha256, sizeBytes: version.sizeBytes,
...(options.automatic ? { automatic: true } : {})
}); });
updateHostLibraryCaches(hostId, [{ libraryVersionId: versionId, status: 'downloading', bytesDownloaded: 0 }]); updateHostLibraryCaches(hostId, [{ libraryVersionId: versionId, status: 'downloading', bytesDownloaded: retainedBytes, manifestSha256: prior?.manifestSha256 }]);
database().prepare('UPDATE host_library_caches SET operation_id=? WHERE host_id=? AND version_id=?').run(operation.id, hostId, versionId); database().prepare('UPDATE host_library_caches SET operation_id=? WHERE host_id=? AND version_id=?').run(operation.id, hostId, versionId);
return operation; return operation;
} }
@@ -349,8 +359,7 @@ export function libraryOverview() {
return { libraries: listLibraries().map(library => ({ ...library, versions: library.versions.map(({ files: _files, ...version }) => ({ return { libraries: listLibraries().map(library => ({ ...library, versions: library.versions.map(({ files: _files, ...version }) => ({
...version, caches: hosts.map(host => { ...version, caches: hosts.map(host => {
const cached = getHostLibraryCache(host.id, version.id); const cached = getHostLibraryCache(host.id, version.id);
const latest = library.versions.find(item => item.state === 'ready'); if (cached) return { ...cached, status: cached.status === 'ready' && library.latestVersionId !== version.id ? 'outdated' as const : cached.status };
if (cached) return { ...cached, status: cached.status === 'ready' && latest?.id !== version.id ? 'outdated' as const : cached.status };
return { return {
hostId: host.id, libraryVersionId: version.id, status: 'missing' as const, bytesDownloaded: 0, hostId: host.id, libraryVersionId: version.id, status: 'missing' as const, bytesDownloaded: 0,
bytesTotal: version.sizeBytes, manifestSha256: null, error: null, updatedAt: version.createdAt, operationId: null bytesTotal: version.sizeBytes, manifestSha256: null, error: null, updatedAt: version.createdAt, operationId: null
+2 -2
View File
@@ -33,8 +33,8 @@ function hostFromRow(row: HostRow): Host {
const infrastructureReady = reasons.length === 0; const infrastructureReady = reasons.length === 0;
const runtimeReady = !!inventory.runtimeImages?.length; const runtimeReady = !!inventory.runtimeImages?.length;
const libraryReady = !!inventory.libraries?.some(library => library.state === 'ready'); const libraryReady = !!inventory.libraries?.some(library => library.state === 'ready');
if (!runtimeReady) reasons.push('Prepare a Plutainer runtime image.'); if (!runtimeReady) reasons.push('The Plutainer runtime image is not ready yet.');
if (!libraryReady) reasons.push('Download and verify a game-file library.'); if (!libraryReady) reasons.push('No verified game-file library is available yet.');
if (row.mode === 'draining') reasons.push('Host is draining; new deployments are blocked.'); if (row.mode === 'draining') reasons.push('Host is draining; new deployments are blocked.');
if (row.mode === 'disabled') reasons.push('Host is disabled.'); if (row.mode === 'disabled') reasons.push('Host is disabled.');
const decommission = presentDecommission(decommissionRow(row.id)); const decommission = presentDecommission(decommissionRow(row.id));
+143
View File
@@ -0,0 +1,143 @@
import { db } from '../db/index.js';
import { config } from '../config.js';
import { AGENT_PROTOCOL_VERSION, getHost, queueHostOperation } from './index.js';
import { getHostLibraryCache, latestPublishedLibraries, queueLibraryDownload } from '../game-files/index.js';
import type { Host, HostOperationState, HostPreparation, HostPreparationItem } from '../../hosts.js';
const activeStates = ['pending', 'accepted', 'running', 'unknown'];
const supersededDownload = 'Superseded by newer game files before delivery to the host.';
type PreparationOperation = { id: string; state: HostOperationState; updated_at: string; error: string | null };
type Target = { item: HostPreparationItem; image?: string; versionId?: string; requiredBytes: number; operation?: PreparationOperation };
function latestOperation(hostId: string, kind: string, field: string, value: string): PreparationOperation | undefined {
return db().prepare(`SELECT id,state,updated_at,error FROM ec_host_operations
WHERE host_id=? AND kind=? AND json_extract(payload,?)=? ORDER BY created_at DESC,rowid DESC LIMIT 1`)
.get(hostId, kind, `$.${field}`, value) as PreparationOperation | undefined;
}
function retryAt(hostId: string, target: Target): number {
const operation = target.operation;
if (!operation || operation.state !== 'failed') return 0;
const kind = target.image ? 'runtime.pull' : 'library.download';
const field = target.image ? '$.image' : '$.libraryVersionId';
const value = target.image ?? target.versionId!;
// Count consecutive failures, including across controller restarts. Unknown
// operations are never retried under a new ID: their journal must reconcile.
const { count } = db().prepare(`SELECT COUNT(*) AS count FROM ec_host_operations
WHERE host_id=? AND kind=? AND json_extract(payload,?)=? AND state='failed'
AND rowid>COALESCE((SELECT MAX(rowid) FROM ec_host_operations
WHERE host_id=? AND kind=? AND json_extract(payload,?)=? AND state='completed'),0)`)
.get(hostId, kind, field, value, hostId, kind, field, value) as { count: number };
return Date.parse(operation.updated_at) + Math.min(15 * 60_000, 60_000 * 2 ** Math.min(4, Math.max(0, count - 1)));
}
function hostBlocker(host: Host): { state: 'waiting' | 'blocked'; detail: string } | null {
if (host.decommission) return { state: 'waiting', detail: 'Automatic preparation is paused during host deletion.' };
if (host.mode !== 'active') return { state: 'waiting', detail: `Automatic preparation is paused while this host is ${host.mode}.` };
if (!host.enrolled) return { state: 'waiting', detail: 'Automatic preparation starts after agent installation.' };
if (!host.connected || !host.credentialActive) return { state: 'waiting', detail: 'Waiting for the host to reconnect.' };
if (host.inventory.protocolVersion !== AGENT_PROTOCOL_VERSION) return { state: 'blocked', detail: 'A compatible host agent is required.' };
if (!host.inventory.dockerRunning) return { state: 'blocked', detail: 'Waiting for Docker to become available on the host.' };
if (!host.inventory.directoriesWritable) return { state: 'blocked', detail: 'The host cannot write its managed directories.' };
return null;
}
function targets(host: Host): Target[] {
const image = config.komodo.defaultImage;
const runtimeReady = host.inventory.runtimeImages?.includes(image);
const result: Target[] = [{ image, requiredBytes: host.minimumFreeBytes,
operation: latestOperation(host.id, 'runtime.pull', 'image', image),
item: { kind: 'runtime', label: image, state: runtimeReady ? 'ready' : 'waiting', detail: runtimeReady ? 'Runtime is cached.' : 'Runtime will be downloaded automatically.' } }];
for (const version of latestPublishedLibraries()) {
const cache = getHostLibraryCache(host.id, version.id);
const matches = cache?.manifestSha256 === version.manifestSha256;
const ready = matches && ['ready', 'outdated'].includes(cache!.status) && cache!.bytesDownloaded === version.sizeBytes;
result.push({ versionId: version.id, requiredBytes: version.sizeBytes - (matches ? cache!.bytesDownloaded : 0) + host.minimumFreeBytes,
operation: latestOperation(host.id, 'library.download', 'libraryVersionId', version.id),
item: { kind: 'library', label: version.name, state: ready ? 'ready' : 'waiting',
detail: ready ? 'The latest game files are downloaded and verified.' : 'The latest game files will be downloaded and verified automatically.',
bytesDownloaded: cache?.bytesDownloaded ?? 0, bytesTotal: version.sizeBytes } });
}
const blocker = hostBlocker(host);
for (const target of result) {
if (target.item.state === 'ready') continue;
const operation = target.operation;
if (operation) target.item.operationId = operation.id;
if (blocker) { Object.assign(target.item, blocker); continue; }
if (operation && activeStates.includes(operation.state)) {
target.item.state = 'preparing';
target.item.detail = operation.state === 'unknown' ? 'Waiting for the agent to reconcile interrupted preparation.'
: target.image ? 'Downloading the runtime automatically.' : 'Downloading and verifying game files automatically.';
} else if (operation && Date.parse(host.lastHeartbeat!) <= Date.parse(operation.updated_at)) {
target.item.state = 'preparing'; target.item.detail = 'Waiting for a fresh host inventory after preparation.';
} else if (retryAt(host.id, target) > Date.now()) {
target.item.state = 'blocked';
target.item.detail = `${operation?.error ?? 'Preparation failed.'} Automatic retry after ${new Date(retryAt(host.id, target)).toISOString()}.`;
} else if (Number(host.inventory.storageAvailable ?? 0) < target.requiredBytes) {
target.item.state = 'blocked'; target.item.detail = 'Insufficient free storage, including the host reserve. Preparation resumes automatically when space is available.';
}
}
return result;
}
export function hostPreparationStatus(host: Host): HostPreparation {
const items = targets(host).map(target => target.item);
if (!items.some(item => item.kind === 'library')) items.push({ kind: 'library', label: 'Game files', state: 'waiting',
detail: 'Upload T5 or T6 game files; hosts download the latest available files automatically.' });
const state = items.some(item => item.state === 'blocked') ? 'blocked'
: items.some(item => item.state === 'preparing') ? 'preparing'
: items.some(item => item.state === 'waiting') ? 'waiting' : 'ready';
return { state, items };
}
export function presentHostPreparation(host: Host): Host {
const preparation = hostPreparationStatus(host);
if (['needs-runtime', 'needs-game-files'].includes(host.readiness) && preparation.state === 'preparing') {
return { ...host, readiness: 'preparing', readinessReasons: preparation.items.filter(item => item.state !== 'ready').map(item => item.detail), preparation };
}
return { ...host, preparation };
}
function supersedeUndeliveredDownloads(hostId: string): void {
const latest = new Set(latestPublishedLibraries().map(version => version.id));
const pending = db().prepare(`SELECT id,json_extract(payload,'$.libraryVersionId') AS versionId
FROM ec_host_operations WHERE host_id=? AND kind='library.download' AND state='pending'
AND last_delivered_at IS NULL AND json_extract(payload,'$.automatic')=1`).all(hostId) as { id: string; versionId: string }[];
const now = new Date().toISOString();
for (const operation of pending) {
if (latest.has(operation.versionId)) continue;
// Delivered operations may already be writing files. Only unsubmitted
// automatic work can be replaced without consulting the agent journal.
db().prepare("UPDATE ec_host_operations SET state='failed',error=?,updated_at=? WHERE id=?").run(supersededDownload, now, operation.id);
db().prepare("INSERT INTO ec_host_operation_events(operation_id,state,created_at,error) VALUES(?,'failed',?,?)").run(operation.id, now, supersededDownload);
db().prepare("UPDATE host_library_caches SET status='missing',error=NULL,updated_at=? WHERE host_id=? AND version_id=? AND operation_id=? AND status NOT IN ('ready','outdated')")
.run(now, hostId, operation.versionId, operation.id);
}
}
/** Agent heartbeats and polls drive durable preparation; no open UI or in-memory worker is needed. */
export function reconcileHostPreparation(hostId: string): HostPreparation {
return db().transaction(() => {
const host = getHost(hostId);
if (!host) throw new Error('Host was not found.');
if (!hostBlocker(host)) {
supersedeUndeliveredDownloads(hostId);
// Queue only one setup mutation at a time so each capacity check uses
// fresh disk inventory. This also respects manually queued downloads.
const active = db().prepare(`SELECT 1 FROM ec_host_operations WHERE host_id=?
AND kind IN ('runtime.pull','library.download','library.import') AND state IN ('pending','accepted','running','unknown') LIMIT 1`).get(hostId);
const finished = db().prepare(`SELECT updated_at FROM ec_host_operations WHERE host_id=?
AND kind IN ('runtime.pull','library.download','library.import') AND state IN ('completed','failed')
AND (error IS NULL OR error != ?)
ORDER BY updated_at DESC LIMIT 1`).get(hostId, supersededDownload) as { updated_at: string } | undefined;
if (!active && (!finished || Date.parse(host.lastHeartbeat!) > Date.parse(finished.updated_at))) {
// A failed or oversized library must not prevent other libraries
// from preparing. The runtime still comes first when eligible.
const target = targets(host).find(target => target.item.state === 'waiting');
if (target?.image) queueHostOperation(host.id, 'runtime.pull', { image: target.image });
else if (target?.versionId) queueLibraryDownload(host.id, target.versionId, { automatic: true });
}
}
return hostPreparationStatus(host);
}).immediate();
}
+7 -5
View File
@@ -4,7 +4,7 @@ import { db } from '../db/index.js';
import { config, relDestForKind, serverDir } from '../config.js'; import { config, relDestForKind, serverDir } from '../config.js';
import { nativeInstance, nativeRequest, type NativeInstance } from '../backends/native.js'; import { nativeInstance, nativeRequest, type NativeInstance } from '../backends/native.js';
import { getHost, getHostOperation, listHosts, nextHostPort, queueHostOperation, releaseHostPorts, requireReadyHost, reserveHostPort } from '../hosts/index.js'; import { getHost, getHostOperation, listHosts, nextHostPort, queueHostOperation, releaseHostPorts, requireReadyHost, reserveHostPort } from '../hosts/index.js';
import { getLibraryVersion, requireReadyLibrary } from '../game-files/index.js'; import { newestReadyLibrary, requireReadyLibrary } from '../game-files/index.js';
import { blueprintRecipe, captureRecipe, recipeForDeployment, summarizeRecipe, type LaunchRecipe } from '../fleet/launch-recipes.js'; import { blueprintRecipe, captureRecipe, recipeForDeployment, summarizeRecipe, type LaunchRecipe } from '../fleet/launch-recipes.js';
import { getServerKey, saveServerKey } from '../fleet/server-keys.js'; import { getServerKey, saveServerKey } from '../fleet/server-keys.js';
import { activeBetaRemovalBlocker, getServer } from '../fleet/servers.js'; import { activeBetaRemovalBlocker, getServer } from '../fleet/servers.js';
@@ -29,9 +29,11 @@ interface FilePlan { id: number; path: string; contentBase64: string | null; sha
export function nativeSelection(input: Record<string, unknown>, game: string): NativeSelection { export function nativeSelection(input: Record<string, unknown>, game: string): NativeSelection {
if (typeof input.hostId !== 'string' || !input.hostId) throw new Error('Select a ready host.'); if (typeof input.hostId !== 'string' || !input.hostId) throw new Error('Select a ready host.');
if (typeof input.libraryId !== 'string' || !input.libraryId) throw new Error('Select a published game-file version.'); const version = newestReadyLibrary(game);
const version = getLibraryVersion(input.libraryId); if (!version) throw new Error(`Upload ${game.toUpperCase()} game files before launching a server.`);
if (!version || version.game !== game || version.state !== 'ready') throw new Error('Choose a published game-file version for the selected game.'); if (input.libraryId !== undefined && input.libraryId !== '' && input.libraryId !== version.id) {
throw new Error(`The current ${game.toUpperCase()} game files changed. Refresh the launch preview.`);
}
const image = input.image === undefined || input.image === '' ? config.komodo.defaultImage : input.image; const image = input.image === undefined || input.image === '' ? config.komodo.defaultImage : input.image;
if (typeof image !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,499}$/.test(image)) throw new Error('Invalid runtime image.'); if (typeof image !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,499}$/.test(image)) throw new Error('Invalid runtime image.');
const host = requireReadyHost(input.hostId, { image, requiredBytes: 1024 ** 3 }); const host = requireReadyHost(input.hostId, { image, requiredBytes: 1024 ** 3 });
@@ -68,7 +70,7 @@ export function previewNativeCreation(input: Record<string, unknown>): LaunchPre
.run(id, expiresAt, name, port, JSON.stringify(recipe), blueprintName); .run(id, expiresAt, name, port, JSON.stringify(recipe), blueprintName);
const checks: LaunchPreview['checks'] = [ const checks: LaunchPreview['checks'] = [
{ label: 'Host readiness', state: 'pass', detail: `${getHost(selection.hostId)!.name}: connected, Docker ready, storage and runtime verified.` }, { label: 'Host readiness', state: 'pass', detail: `${getHost(selection.hostId)!.name}: connected, Docker ready, storage and runtime verified.` },
{ label: 'Game files', state: 'pass', detail: `Immutable library version ${selection.libraryId} is ready on the selected host.` }, { label: 'Game files', state: 'pass', detail: `The latest ${recipe.game.toUpperCase()} game files are verified on the selected host.` },
{ label: 'Lobby', state: name ? 'pass' : 'blocked', detail: name ?? 'All lobby IDs for this map are reserved.' }, { label: 'Lobby', state: name ? 'pass' : 'blocked', detail: name ?? 'All lobby IDs for this map are reserved.' },
{ label: 'Host port', state: port ? 'pass' : 'blocked', detail: port ? `${selection.bindAddress}:${port} / UDP and TCP` : 'No available UDP/TCP port on this host.' }, { label: 'Host port', state: port ? 'pass' : 'blocked', detail: port ? `${selection.bindAddress}:${port} / UDP and TCP` : 'No available UDP/TCP port on this host.' },
{ label: 'Configuration', state: 'pass', detail: recipe.configSource } { label: 'Configuration', state: 'pass', detail: recipe.configSource }
@@ -17,7 +17,7 @@
let mode = $state<CollectionMode>('fill'); let mode = $state<CollectionMode>('fill');
let games = $state<ZombieGame[]>([]); let games = $state<ZombieGame[]>([]);
let collectionName = $state('Launch collection'); let collectionName = $state('Launch collection');
let hostSelection = $state<NativeDeploymentSelection>({ hostId: '', libraryIds: {}, image: 'ghcr.io/ayymoss/plutainer:latest' }); let hostSelection = $state<NativeDeploymentSelection>({ hostId: '', libraryIds: {}, image: '' });
let hostReady = $state(false); let hostReady = $state(false);
let confirmRemovals = $state(false); let confirmRemovals = $state(false);
let keys = $state<Record<string, string>>({}); let keys = $state<Record<string, string>>({});
@@ -31,7 +31,7 @@
const keysReady = $derived(preview?.servers.filter(server => server.name && (server.hasSavedKey || keys[server.name]?.trim())).length ?? 0); const keysReady = $derived(preview?.servers.filter(server => server.name && (server.hasSavedKey || keys[server.name]?.trim())).length ?? 0);
onMount(() => { const timer = setInterval(() => { now = Date.now(); }, 10_000); return () => clearInterval(timer); }); onMount(() => { const timer = setInterval(() => { now = Date.now(); }, 10_000); return () => clearInterval(timer); });
let canLaunch = $derived(preview?.ready && preview.mode === mode && (preview.servers.length > 0 || preview.removals.length > 0) let canLaunch = $derived(preview?.ready && preview.mode === mode && (preview.servers.length > 0 || preview.removals.length > 0)
&& (!preview.removals.length || confirmRemovals) && preview.servers.every(server => server.name && (server.hasSavedKey || keys[server.name]?.trim()))); && (!preview.servers.length || hostReady) && (!preview.removals.length || confirmRemovals) && preview.servers.every(server => server.name && (server.hasSavedKey || keys[server.name]?.trim())));
$effect(() => { $effect(() => {
if (!dialog) return; if (!dialog) return;
@@ -41,7 +41,7 @@
async function initialize() { async function initialize() {
const current = ++session; busy = true; error = ''; games = []; hostReady = false; const current = ++session; busy = true; error = ''; games = []; hostReady = false;
hostSelection = { hostId: '', libraryIds: {}, image: 'ghcr.io/ayymoss/plutainer:latest' }; hostSelection = { hostId: '', libraryIds: {}, image: '' };
try { try {
const response = await fetch('/api/blueprints'); const body = await response.json(); const response = await fetch('/api/blueprints'); const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not load collection.'); if (!response.ok) throw new Error(body.error ?? 'Could not load collection.');
@@ -54,7 +54,7 @@
} }
async function review() { async function review() {
if (!hostReady) { error = 'Choose a ready host and cached game files for each game in this collection.'; return; } if (!hostReady) { error = 'Choose a host and wait for its latest game files and runtime to finish automatic setup.'; return; }
const current = ++session; const current = ++session;
const previous = preview; const previous = preview;
busy = true; error = ''; keyNotice = ''; confirmRemovals = false; busy = true; error = ''; keyNotice = ''; confirmRemovals = false;
@@ -102,10 +102,10 @@
<option value="exact">Match collection exactly</option> <option value="exact">Match collection exactly</option>
</select> </select>
</label> </label>
{#if games.length}<NativeHostSelection {games} bind:selection={hostSelection} bind:ready={hostReady} disabled={busy} onchange={() => { preview = null; confirmRemovals = false; }} />{/if} {#if open && games.length}<NativeHostSelection {games} bind:selection={hostSelection} bind:ready={hostReady} disabled={busy} onchange={() => { preview = null; confirmRemovals = false; }} />{/if}
<p class="muted">{mode === 'exact' ? 'Keep matching lobbies, add missing ones, and remove excess lobbies and maps outside this collection across both games.' : 'Create missing lobbies and keep all existing servers.'}</p> <p class="muted">{mode === 'exact' ? 'Keep matching lobbies, add missing ones, and remove excess lobbies and maps outside this collection across both games.' : 'Create missing lobbies and keep all existing servers.'}</p>
{#if error}<p class="error" role="alert">{error}</p>{/if} {#if error}<p class="error" role="alert">{error}</p>{/if}
{#if !preview}<p class="muted">{busy ? 'Preparing the collection…' : 'Select a host and game files, then review capacity and server keys.'}</p>{#if !busy}<Button disabled={!hostReady} onclick={review}>Preview launch</Button>{/if} {#if !preview}<p class="muted">{busy ? 'Preparing the collection…' : 'Choose a host. Its latest game files sync automatically before you review capacity and server keys.'}</p>{#if !busy}<Button disabled={!hostReady} onclick={review}>Preview launch</Button>{/if}
{:else} {:else}
{#if keyNotice}<p role="status" class="muted">{keyNotice}</p>{/if} {#if keyNotice}<p role="status" class="muted">{keyNotice}</p>{/if}
{#if expired}<p class="error" role="status">This preview expired. Refresh before launch; keys remain entered for unchanged IDs.</p>{/if} {#if expired}<p class="error" role="status">This preview expired. Refresh before launch; keys remain entered for unchanged IDs.</p>{/if}
@@ -145,7 +145,7 @@
<div class="launch-footer"><div>{#if preview.servers.length}<p role="status"><strong>{keysReady} / {preview.servers.length} keys available</strong></p>{/if}<p class="caption">Key validity is checked at startup. Preview expires at {$timeFormat.minuteTime(preview.expiresAt)}.</p>{#if keysReady < preview.servers.length}<button class="text-button" type="button" onclick={nextMissing}>Jump to next missing key</button>{/if}</div><div class="actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant={preview.removals.length ? 'danger' : 'primary'} loading={busy} disabled={busy || expired || !canLaunch}>{preview.mode === 'exact' ? `Apply: ${preview.servers.length} create, ${preview.removals.length} remove` : `Queue ${preview.servers.length} server${preview.servers.length === 1 ? '' : 's'} →`}</Button></div></div> <div class="launch-footer"><div>{#if preview.servers.length}<p role="status"><strong>{keysReady} / {preview.servers.length} keys available</strong></p>{/if}<p class="caption">Key validity is checked at startup. Preview expires at {$timeFormat.minuteTime(preview.expiresAt)}.</p>{#if keysReady < preview.servers.length}<button class="text-button" type="button" onclick={nextMissing}>Jump to next missing key</button>{/if}</div><div class="actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant={preview.removals.length ? 'danger' : 'primary'} loading={busy} disabled={busy || expired || !canLaunch}>{preview.mode === 'exact' ? `Apply: ${preview.servers.length} create, ${preview.removals.length} remove` : `Queue ${preview.servers.length} server${preview.servers.length === 1 ? '' : 's'} →`}</Button></div></div>
</form> </form>
{:else}<p class="muted">{preview.mode === 'exact' ? 'Your fleet already matches this collection.' : 'Your fleet already meets or exceeds every target in this collection.'} No servers will be created or removed.</p><div class="actions"><Button onclick={() => open = false}>Done</Button></div>{/if} {:else}<p class="muted">{preview.mode === 'exact' ? 'Your fleet already matches this collection.' : 'Your fleet already meets or exceeds every target in this collection.'} No servers will be created or removed.</p><div class="actions"><Button onclick={() => open = false}>Done</Button></div>{/if}
<details class="recipes"><summary>Setup and launch requirements</summary><p class="muted requirements">Each new server uses the selected host, immutable game-file version, and prepared runtime. Host readiness, cached files, runtime availability, and local port reservations are checked again before deployment.</p>{#each preview.entries as entry}<div class="recipe"><LaunchRecipe recipe={entry.recipe} /></div>{/each}</details> <details class="recipes"><summary>Setup and launch requirements</summary><p class="muted requirements">Each new server uses the selected host, latest game files, and prepared runtime. Host readiness, synced files, runtime availability, and local port reservations are checked again before deployment. Existing servers keep their current files.</p>{#each preview.entries as entry}<div class="recipe"><LaunchRecipe recipe={entry.recipe} /></div>{/each}</details>
{/if} {/if}
</div> </div>
</dialog> </dialog>
@@ -23,7 +23,7 @@
let maps = $derived(catalogs[game]); let maps = $derived(catalogs[game]);
let selectedBlueprint = $derived(blueprints.find(b => b.id === blueprintId)); let selectedBlueprint = $derived(blueprints.find(b => b.id === blueprintId));
let preview = $state<LaunchPreview | null>(null); let preview = $state<LaunchPreview | null>(null);
let hostSelection = $state<NativeDeploymentSelection>({ hostId: '', libraryIds: {}, image: 'ghcr.io/ayymoss/plutainer:latest' }); let hostSelection = $state<NativeDeploymentSelection>({ hostId: '', libraryIds: {}, image: '' });
let hostReady = $state(false); let hostReady = $state(false);
let plutoKey = $state(''); let plutoKey = $state('');
let busy = $state(false); let busy = $state(false);
@@ -36,6 +36,7 @@
let wasOpen = false; let wasOpen = false;
let session = 0; let session = 0;
let keyNotice = $state(''); let keyNotice = $state('');
let selectionNotice = $state('');
let now = $state(Date.now()); let now = $state(Date.now());
let heading: HTMLHeadingElement; let heading: HTMLHeadingElement;
const expired = $derived(!!preview && new Date(preview.expiresAt).getTime() <= now); const expired = $derived(!!preview && new Date(preview.expiresAt).getTime() <= now);
@@ -49,12 +50,12 @@
async function initialize() { async function initialize() {
const current = ++session; const current = ++session;
preview = null; plutoKey = ''; error = ''; loaded = false; busy = false; keyNotice = ''; preview = null; plutoKey = ''; error = ''; loaded = false; busy = false; keyNotice = ''; selectionNotice = '';
blueprintName = ''; description = ''; savedName = ''; saving = false; blueprintName = ''; description = ''; savedName = ''; saving = false;
blueprintId = initialBlueprintId; blueprintId = initialBlueprintId;
game = initialGame; game = initialGame;
mapSlug = initialMapSlug; mapSlug = initialMapSlug;
hostSelection = { hostId: '', libraryIds: {}, image: 'ghcr.io/ayymoss/plutainer:latest' }; hostReady = false; hostSelection = { hostId: '', libraryIds: {}, image: '' }; hostReady = false;
try { try {
const [mapRes, blueprintRes] = await Promise.all([fetch('/api/maps'), fetch('/api/blueprints')]); const [mapRes, blueprintRes] = await Promise.all([fetch('/api/maps'), fetch('/api/blueprints')]);
if (!mapRes.ok || !blueprintRes.ok) throw new Error('Could not load maps and collections. Try again.'); if (!mapRes.ok || !blueprintRes.ok) throw new Error('Could not load maps and collections. Try again.');
@@ -74,15 +75,20 @@
} catch (err) { if (current === session) error = (err as Error).message; } } catch (err) { if (current === session) error = (err as Error).message; }
} }
function selectionChanged() {
if (preview) selectionNotice = 'The host or game files changed. Review the launch again with the latest files.';
preview = null; plutoKey = ''; keyNotice = '';
}
async function review() { async function review() {
if (busy) return; if (busy) return;
if (!sourceDeploymentId && selectedBlueprint) { if (!sourceDeploymentId && selectedBlueprint) {
open = false; collectionOpen = true; return; open = false; collectionOpen = true; return;
} }
if (!hostReady) { error = 'Choose a ready host, cached game-file version, and prepared runtime.'; return; } if (!hostReady) { error = 'Choose a host and wait for its latest game files and runtime to finish automatic setup.'; return; }
const current = session; const current = session;
const previousName = preview?.name; const previousName = preview?.name;
busy = true; error = ''; savedName = ''; keyNotice = ''; busy = true; error = ''; savedName = ''; keyNotice = ''; selectionNotice = '';
try { try {
const res = await fetch('/api/provisioning/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, const res = await fetch('/api/provisioning/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...(sourceDeploymentId ? { deploymentId: sourceDeploymentId } : { game, mapSlug }), body: JSON.stringify({ ...(sourceDeploymentId ? { deploymentId: sourceDeploymentId } : { game, mapSlug }),
@@ -101,7 +107,7 @@
} }
async function launch() { async function launch() {
if (!preview || !preview.ready || expired || busy) return; if (!preview || !preview.ready || !hostReady || expired || busy) return;
busy = true; error = ''; busy = true; error = '';
try { try {
const res = await fetch('/api/servers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, const res = await fetch('/api/servers', { method: 'POST', headers: { 'Content-Type': 'application/json' },
@@ -135,6 +141,8 @@
<header><div><h2 bind:this={heading} tabindex="-1">{preview ? 'Review server launch' : 'Create server'}</h2></div><button class="close" disabled={busy || saving} onclick={() => open = false} aria-label="Close server creation dialog">×</button></header> <header><div><h2 bind:this={heading} tabindex="-1">{preview ? 'Review server launch' : 'Create server'}</h2></div><button class="close" disabled={busy || saving} onclick={() => open = false} aria-label="Close server creation dialog">×</button></header>
<ol class="steps" aria-label="Launch steps"><li class:active={!preview} aria-current={!preview ? 'step' : undefined}>01 <span>Choose a starting point</span></li><li class:active={!!preview} aria-current={preview ? 'step' : undefined}>02 <span>Review & launch</span></li></ol> <ol class="steps" aria-label="Launch steps"><li class:active={!preview} aria-current={!preview ? 'step' : undefined}>01 <span>Choose a starting point</span></li><li class:active={!!preview} aria-current={preview ? 'step' : undefined}>02 <span>Review & launch</span></li></ol>
{#if error}<div class="error" role="alert">{error}</div>{/if} {#if error}<div class="error" role="alert">{error}</div>{/if}
{#if selectionNotice}<p class="muted" role="status">{selectionNotice}</p>{/if}
{#if open && loaded && (sourceDeploymentId || !selectedBlueprint)}<NativeHostSelection games={[game]} bind:selection={hostSelection} bind:ready={hostReady} disabled={busy || saving} network onchange={selectionChanged} />{/if}
{#if !loaded} {#if !loaded}
<p class="muted">{error ? 'Your launch choices could not be loaded.' : 'Loading your maps and collections…'}</p> <p class="muted">{error ? 'Your launch choices could not be loaded.' : 'Loading your maps and collections…'}</p>
{#if error}<Button onclick={initialize}>Try again</Button>{/if} {#if error}<Button onclick={initialize}>Try again</Button>{/if}
@@ -150,7 +158,7 @@
{#if preview.hasSavedKey}<p class="key-ready"><span aria-hidden="true">✓</span> Saved key ready</p> {#if preview.hasSavedKey}<p class="key-ready"><span aria-hidden="true">✓</span> Saved key ready</p>
{:else}<label>Plutonium server key<input type="password" bind:value={plutoKey} disabled={busy || saving || !preview.name} maxlength="512" autocomplete="off" placeholder="Paste the key for this server ID" /></label>{/if} {:else}<label>Plutonium server key<input type="password" bind:value={plutoKey} disabled={busy || saving || !preview.name} maxlength="512" autocomplete="off" placeholder="Paste the key for this server ID" /></label>{/if}
<p class="muted">{preview.hasSavedKey ? 'The saved key for this server ID will be reused automatically. Electric Cherry does not check whether Plutonium accepts it. Update it in Keys.' : 'Add the key here. It is saved for this exact server ID when you launch, including if provisioning fails.'} <a href={`/server-keys?server=${encodeURIComponent(preview.name ?? '')}`} target="_blank" rel="noopener">Manage saved keys in a new tab</a>; your launch stays open here.</p> <p class="muted">{preview.hasSavedKey ? 'The saved key for this server ID will be reused automatically. Electric Cherry does not check whether Plutonium accepts it. Update it in Keys.' : 'Add the key here. It is saved for this exact server ID when you launch, including if provisioning fails.'} <a href={`/server-keys?server=${encodeURIComponent(preview.name ?? '')}`} target="_blank" rel="noopener">Manage saved keys in a new tab</a>; your launch stays open here.</p>
<div class="launch-actions"><Button variant="ghost" disabled={busy || saving} onclick={() => { preview = null; plutoKey = ''; error = ''; }}>Back</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || saving || expired || !preview.ready || (!preview.hasSavedKey && !plutoKey.trim())}>Launch server</Button></div> <div class="launch-actions"><Button variant="ghost" disabled={busy || saving} onclick={() => { preview = null; plutoKey = ''; error = ''; }}>Back</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || saving || expired || !hostReady || !preview.ready || (!preview.hasSavedKey && !plutoKey.trim())}>Launch server</Button></div>
{#if !preview.hasSavedKey && !plutoKey.trim()}<p class="muted">Enter this server’s key to enable launch.</p>{/if} {#if !preview.hasSavedKey && !plutoKey.trim()}<p class="muted">Enter this server’s key to enable launch.</p>{/if}
<p class="caption">Preview valid until {$timeFormat.minuteTime(preview.expiresAt)}. Lobby and port availability are checked again at launch.</p> <p class="caption">Preview valid until {$timeFormat.minuteTime(preview.expiresAt)}. Lobby and port availability are checked again at launch.</p>
</form> </form>
@@ -160,13 +168,12 @@
{/if} {/if}
</details> </details>
{:else if sourceDeploymentId} {:else if sourceDeploymentId}
<p class="muted">Choose a host for the latest promoted {game.toUpperCase()} setup, with a fresh lobby and port.</p><NativeHostSelection games={[game]} bind:selection={hostSelection} bind:ready={hostReady} disabled={busy} network /><Button disabled={busy || !hostReady} onclick={review}>Review launch</Button> <p class="muted">Choose a host for the latest promoted {game.toUpperCase()} setup, with a fresh lobby and port.</p><Button disabled={busy || !hostReady} onclick={review}>Review launch</Button>
{:else} {:else}
<form onsubmit={event => { event.preventDefault(); void review(); }}> <form onsubmit={event => { event.preventDefault(); void review(); }}>
<label>Start with<select bind:value={blueprintId} disabled={busy}><option value="">Current promoted setup</option>{#each blueprints as blueprint}<option value={blueprint.id}>{blueprint.name} · {blueprint.totalServers} server{blueprint.totalServers === 1 ? '' : 's'}</option>{/each}</select></label> <label>Start with<select bind:value={blueprintId} disabled={busy}><option value="">Current promoted setup</option>{#each blueprints as blueprint}<option value={blueprint.id}>{blueprint.name} · {blueprint.totalServers} server{blueprint.totalServers === 1 ? '' : 's'}</option>{/each}</select></label>
{#if selectedBlueprint}<section class="recipe-panel"><h3>{selectedBlueprint.totalServers} target server{selectedBlueprint.totalServers === 1 ? '' : 's'}</h3>{#each selectedBlueprint.entries as entry}<p>{entry.count}× {entry.mapName} · {entry.game.toUpperCase()}</p>{/each}<p class="muted">Review missing servers and their keys together. Every new server uses the latest promoted scripts, plugins, and configuration.</p>{#if selectedBlueprint.description}<p class="muted">{selectedBlueprint.description}</p>{/if}</section> {#if selectedBlueprint}<section class="recipe-panel"><h3>{selectedBlueprint.totalServers} target server{selectedBlueprint.totalServers === 1 ? '' : 's'}</h3>{#each selectedBlueprint.entries as entry}<p>{entry.count}× {entry.mapName} · {entry.game.toUpperCase()}</p>{/each}<p class="muted">Review missing servers and their keys together. Every new server uses the latest promoted scripts, plugins, and configuration.</p>{#if selectedBlueprint.description}<p class="muted">{selectedBlueprint.description}</p>{/if}</section>
{:else}<p class="muted">Pick a map to review its current promoted scripts, plugins, and configuration. You can save the map as a collection for future launches.</p><div class="fields"><label>Game<select bind:value={game} disabled={busy} onchange={event => mapSlug = catalogs[event.currentTarget.value as ZombieGame][0]?.slug ?? ''}><option value="t5">Black Ops I (T5)</option><option value="t6">Black Ops II (T6)</option></select></label><label>Map<select bind:value={mapSlug} disabled={busy}>{#each maps as map}<option value={map.slug}>{map.friendly}</option>{/each}</select></label></div>{/if} {:else}<p class="muted">Pick a map to review its current promoted scripts, plugins, and configuration. You can save the map as a collection for future launches.</p><div class="fields"><label>Game<select bind:value={game} disabled={busy} onchange={event => mapSlug = catalogs[event.currentTarget.value as ZombieGame][0]?.slug ?? ''}><option value="t5">Black Ops I (T5)</option><option value="t6">Black Ops II (T6)</option></select></label><label>Map<select bind:value={mapSlug} disabled={busy}>{#each maps as map}<option value={map.slug}>{map.friendly}</option>{/each}</select></label></div>{/if}
{#if !selectedBlueprint}<NativeHostSelection games={[game]} bind:selection={hostSelection} bind:ready={hostReady} disabled={busy} network />{/if}
<div class="promise"><strong>Know what you’re launching.</strong><p>Review the current promoted setup, available lobby, and launch prerequisites before creating your server.</p></div> <div class="promise"><strong>Know what you’re launching.</strong><p>Review the current promoted setup, available lobby, and launch prerequisites before creating your server.</p></div>
<div class="launch-actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || (!blueprintId && (!mapSlug || !hostReady))}>Preview launch →</Button></div> <div class="launch-actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || (!blueprintId && (!mapSlug || !hostReady))}>Preview launch →</Button></div>
</form> </form>
@@ -0,0 +1,31 @@
<script lang="ts">
import type { Host } from '$lib/hosts.js';
import { formatGameBytes } from '$lib/game-files.js';
let { host }: { host: Host } = $props();
</script>
<div class="preparation" aria-live="polite">
<p>The default runtime and the latest available T5 and T6 game files are prepared automatically on active hosts. Running servers keep their current files.</p>
{#if host.preparation}
<p class="status" class:blocked={host.preparation.state === 'blocked'}>Automatic setup: {host.preparation.state === 'ready' ? 'Ready' : host.preparation.state === 'preparing' ? 'In progress' : host.preparation.state === 'blocked' ? 'Needs attention' : 'Waiting'}</p>
<ul>
{#each host.preparation.items as item}
<li>
<div class="row"><strong>{item.label}</strong><span>{item.state === 'blocked' ? 'Needs attention' : item.state}</span></div>
<p class:blocked={item.state === 'blocked'}>{item.detail}</p>
{#if item.bytesTotal && item.bytesDownloaded !== undefined && item.state === 'preparing'}
<progress max={item.bytesTotal} value={item.bytesDownloaded} aria-label={`${item.label} download progress`}></progress>
<small>{formatGameBytes(item.bytesDownloaded)} / {formatGameBytes(item.bytesTotal)}</small>
{/if}
</li>
{/each}
</ul>
{:else}<p>Setup progress will appear after the host connects.</p>{/if}
<p><a href="/game-files">Upload and view T5 / T6 game files →</a></p>
</div>
<style>
.preparation { display:grid; gap:12px; } p { margin:0; color:var(--text-secondary); font-size:var(--text-sm); line-height:1.6; } .status { color:var(--text-primary); }
ul { list-style:none; padding:0; margin:0; display:grid; gap:16px; } li { min-width:0; } .row { display:flex; justify-content:space-between; gap:12px; font-size:var(--text-sm); } strong { overflow-wrap:anywhere; } span { text-transform:capitalize; color:var(--text-secondary); }
li p { margin-top:4px; } .blocked { color:var(--state-danger); } progress { width:100%; margin-top:8px; accent-color:var(--accent-primary); } small { display:block; color:var(--text-secondary); font-size:var(--text-xs); } a { color:var(--accent-primary-hover); }
</style>
@@ -4,39 +4,67 @@
import { hostReadinessLabel } from '$lib/hosts.js'; import { hostReadinessLabel } from '$lib/hosts.js';
import type { GameLibrary } from '$lib/game-files.js'; import type { GameLibrary } from '$lib/game-files.js';
import type { ZombieGame } from '$lib/map-aliases.js'; import type { ZombieGame } from '$lib/map-aliases.js';
import { pollVisible } from '$ui/poll.js';
import { latestGameFiles, selectLatestGameFiles } from '$ui/native-game-files.js';
import { timeFormat } from '$ui/stores/time-zone.js';
let { games, selection = $bindable(), ready = $bindable(false), disabled = false, network = false, onchange }: let { games, selection = $bindable(), ready = $bindable(false), disabled = false, network = false, onchange }:
{ games: ZombieGame[]; selection: NativeDeploymentSelection; ready?: boolean; disabled?: boolean; network?: boolean; onchange?: () => void } = $props(); { games: ZombieGame[]; selection: NativeDeploymentSelection; ready?: boolean; disabled?: boolean; network?: boolean; onchange?: () => void } = $props();
let hosts = $state<Host[]>([]); let libraries = $state<GameLibrary[]>([]); let loading = $state(true); let error = $state(''); let hosts = $state<Host[]>([]); let libraries = $state<GameLibrary[]>([]); let loading = $state(true); let error = $state('');
let defaultRuntimeImage = $state('');
let runtimeSelection: NativeDeploymentSelection | null = null;
const host = $derived(hosts.find(item => item.id === selection.hostId)); const host = $derived(hosts.find(item => item.id === selection.hostId));
const versions = $derived(libraries.flatMap(library => library.versions.filter(version => version.state === 'ready').map(version => ({ ...version, name: library.name })))); const latest = $derived(latestGameFiles(libraries));
const imageReady = $derived(!!host?.inventory.runtimeImages?.includes(selection.image)); const imageReady = $derived(!!host?.inventory.runtimeImages?.includes(selection.image));
function cacheReady(versionId: string | undefined) { return !!versionId && versions.some(version => version.id === versionId && version.caches.some(cache => cache.hostId === selection.hostId && ['ready', 'outdated'].includes(cache.status))); } function cacheReady(game: ZombieGame) { return !!latest[game]?.caches.some(cache => cache.hostId === selection.hostId && ['ready', 'outdated'].includes(cache.status)); }
$effect(() => { ready = !loading && host?.readiness === 'ready' && imageReady && games.length > 0 && games.every(game => cacheReady(selection.libraryIds[game])); }); $effect(() => { ready = !loading && !error && host?.readiness === 'ready' && imageReady && games.length > 0 && games.every(game => selection.libraryIds[game] === latest[game]?.id && cacheReady(game)); });
$effect(() => {
if (disabled || !defaultRuntimeImage || runtimeSelection === selection) return;
if (!selection.image) selection.image = defaultRuntimeImage;
runtimeSelection = selection;
});
$effect(() => {
// Keep an in-flight preview/launch request stable. Reconcile as soon as
// it finishes, so a newly uploaded folder invalidates its old preview.
if (loading || error || disabled) return;
if (selectLatestGameFiles(selection.libraryIds, latest, games)) onchange?.();
});
async function load() { async function load() {
loading = true; error = '';
try { try {
const [hostResponse, libraryResponse] = await Promise.all([fetch('/api/hosts'), fetch('/api/game-files')]); const [hostResponse, libraryResponse] = await Promise.all([fetch('/api/hosts'), fetch('/api/game-files')]);
if (!hostResponse.ok || !libraryResponse.ok) throw new Error('Could not load hosts and game libraries.'); if (!hostResponse.ok || !libraryResponse.ok) throw new Error('Could not load hosts and game files.');
const [hostBody, libraryBody] = await Promise.all([hostResponse.json(), libraryResponse.json()]); hosts = hostBody.hosts; libraries = libraryBody.libraries; const [hostBody, libraryBody] = await Promise.all([hostResponse.json(), libraryResponse.json()]); hosts = hostBody.hosts; libraries = libraryBody.libraries; error = '';
defaultRuntimeImage = hostBody.defaultRuntimeImage ?? '';
} catch (cause) { error = (cause as Error).message; } finally { loading = false; } } catch (cause) { error = (cause as Error).message; } finally { loading = false; }
} }
onMount(() => { void load(); }); onMount(() => pollVisible(load, 5000));
</script> </script>
<section class="selection" aria-label="Deployment host and game files"> <section class="selection" aria-label="Deployment host and game files">
<div class="title"><h3>Host and game files</h3><button type="button" disabled={disabled || loading} onclick={() => { onchange?.(); void load(); }}>Refresh hosts</button></div> <div class="title"><h3>Host and game files</h3><button type="button" disabled={disabled || loading} onclick={() => { onchange?.(); void load(); }}>Refresh hosts</button></div>
{#if error}<p role="alert" class="error">{error}</p>{/if} {#if error}<p role="alert" class="error">{error}</p>{/if}
{#if loading}<p role="status">Loading host readiness and libraries…</p>{/if} {#if loading}<p role="status">Loading host readiness and game files…</p>{/if}
<label>Game host<select bind:value={selection.hostId} disabled={disabled || loading} onchange={() => onchange?.()}><option value="">Choose a host</option>{#each hosts as candidate}<option value={candidate.id} disabled={candidate.readiness !== 'ready'}>{candidate.name} · {candidate.connectionState} · {hostReadinessLabel(candidate.readiness)}</option>{/each}</select></label> <label>Game host<select bind:value={selection.hostId} disabled={disabled || loading} onchange={() => onchange?.()}><option value="">Choose a host</option>{#each hosts as candidate}<option value={candidate.id} disabled={candidate.mode !== 'active' || !!candidate.decommission || !candidate.credentialActive}>{candidate.name} · {candidate.connectionState} · {candidate.preparation?.state === 'preparing' ? 'Preparing automatically' : hostReadinessLabel(candidate.readiness)}</option>{/each}</select></label>
{#if !loading && !hosts.some(candidate => candidate.readiness === 'ready')}<p>No deployment-ready hosts. <a href="/hosts" target="_blank" rel="noopener">Set up a host</a>, then refresh.</p>{/if} {#if !loading && !hosts.length}<p><a href="/hosts" target="_blank" rel="noopener">Add a host</a>. Its runtime and latest T5 and T6 files will be prepared automatically.</p>{/if}
{#if host}<p>{host.hostname} · ports {host.portMin}–{host.portMax} · {host.publicAddress || host.bindAddress}</p>{/if} {#if host}<p>{host.hostname} · ports {host.portMin}–{host.portMax} · {host.publicAddress || host.bindAddress}</p>{/if}
{#each games as game}<label>{game === 't5' ? 'Black Ops I' : 'Black Ops II'} game-file version<select bind:value={selection.libraryIds[game]} disabled={disabled || loading || !host} onchange={() => onchange?.()}><option value="">Choose an immutable library version</option>{#each versions.filter(version => version.game === game) as version}<option value={version.id} disabled={!cacheReady(version.id)}>{version.name} · {version.version} · {cacheReady(version.id) ? 'Ready on host' : 'Not cached on host'}</option>{/each}</select></label>{/each} {#if host?.preparation && host.preparation.state !== 'ready'}<p class:error={host.preparation.state === 'blocked'}>{host.preparation.items.find(item => item.state === 'blocked' || item.state === 'preparing' || item.state === 'waiting')?.detail} <a href={`/hosts/${host.id}`} target="_blank" rel="noopener">View automatic setup</a></p>{/if}
<p><a href="/game-files" target="_blank" rel="noopener">Upload a game folder or download a version to a host</a>, then refresh. Each launch stays pinned to its selected library version.</p> {#if !loading}{#each games as game}
<label>Plutainer runtime image<input required bind:value={selection.image} disabled={disabled} onchange={() => onchange?.()} /></label> <div class="game-files">
{#if host && !imageReady}<p class="error">Prepare this runtime image from <a href={`/hosts/${host.id}`} target="_blank" rel="noopener">host setup</a> before launch.</p>{/if} <strong>{game === 't5' ? 'Black Ops I (T5)' : 'Black Ops II (T6)'} files</strong>
{#if latest[game]}
<p>Latest upload · {$timeFormat.dateTime(latest[game]?.publishedAt ?? latest[game]?.createdAt)}</p>
<p role="status">{!host ? 'Choose a host to check its files.' : cacheReady(game) ? 'Latest files ready on this host.' : 'Waiting for this host to sync the latest files automatically.'}</p>
{:else}
<p>No files uploaded yet. <a href="/game-files" target="_blank" rel="noopener">Upload {game.toUpperCase()} files</a> to prepare your hosts.</p>
{/if}
</div>
{/each}{/if}
<p>Hosts keep the latest T5 and T6 files ready automatically. <a href="/game-files" target="_blank" rel="noopener">Upload game files</a> when they change. Existing servers keep their current files.</p>
<details><summary>Advanced: runtime override</summary><p>The configured default runtime is prepared automatically. Custom images must be prepared from the host page.</p><label>Plutainer runtime image<input required bind:value={selection.image} disabled={disabled} onchange={() => onchange?.()} /></label></details>
{#if host && !imageReady}<p>Waiting for runtime preparation. <a href={`/hosts/${host.id}`} target="_blank" rel="noopener">View host setup</a>.</p>{/if}
{#if network}<details><summary>Network overrides</summary><label>Local bind address<input bind:value={selection.bindAddress} placeholder={host?.bindAddress ?? 'Host default'} disabled={disabled} onchange={() => onchange?.()} /></label><label>Public / NAT address<input bind:value={selection.publicAddress} placeholder={host?.publicAddress || 'Host default'} disabled={disabled} onchange={() => onchange?.()} /></label><label>Public / NAT port<input type="number" min="1" max="65535" bind:value={selection.publicPort} placeholder="Same as local port" disabled={disabled} onchange={() => onchange?.()} /></label><p>Public mapping does not change the local Docker port reservation.</p></details>{/if} {#if network}<details><summary>Network overrides</summary><label>Local bind address<input bind:value={selection.bindAddress} placeholder={host?.bindAddress ?? 'Host default'} disabled={disabled} onchange={() => onchange?.()} /></label><label>Public / NAT address<input bind:value={selection.publicAddress} placeholder={host?.publicAddress || 'Host default'} disabled={disabled} onchange={() => onchange?.()} /></label><label>Public / NAT port<input type="number" min="1" max="65535" bind:value={selection.publicPort} placeholder="Same as local port" disabled={disabled} onchange={() => onchange?.()} /></label><p>Public mapping does not change the local Docker port reservation.</p></details>{/if}
</section> </section>
<style> <style>
.game-files { display:grid; gap:5px; } .game-files strong { font-size:13px; font-weight:600; }
.selection { display:grid; gap:14px; padding:20px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface-sunken); } .title { display:flex; gap:12px; justify-content:space-between; align-items:center; } h3 { font-size:var(--text-sm); margin:0; } label { display:grid; gap:7px; font-size:13px; } input,select { width:100%; box-sizing:border-box; padding:11px; color:var(--text-primary); background:var(--surface); border:1px solid var(--border-control); border-radius:var(--radius-sm); font:inherit; } p { color:var(--text-secondary); font-size:12px; line-height:1.6; margin:0; } a,button { color:var(--accent-primary-hover); } button { background:none; border:0; cursor:pointer; font:inherit; font-size:12px; } button:disabled { opacity:.5; cursor:default; } .error { color:var(--state-danger); } summary { cursor:pointer; font-size:13px; } details label { margin-top:14px; } details p { margin-top:12px; } .selection { display:grid; gap:14px; padding:20px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface-sunken); } .title { display:flex; gap:12px; justify-content:space-between; align-items:center; } h3 { font-size:var(--text-sm); margin:0; } label { display:grid; gap:7px; font-size:13px; } input,select { width:100%; box-sizing:border-box; padding:11px; color:var(--text-primary); background:var(--surface); border:1px solid var(--border-control); border-radius:var(--radius-sm); font:inherit; } p { color:var(--text-secondary); font-size:12px; line-height:1.6; margin:0; } a,button { color:var(--accent-primary-hover); } button { background:none; border:0; cursor:pointer; font:inherit; font-size:12px; } button:disabled { opacity:.5; cursor:default; } .error { color:var(--state-danger); } summary { cursor:pointer; font-size:13px; } details label { margin-top:14px; } details p { margin-top:12px; }
</style> </style>
+26
View File
@@ -0,0 +1,26 @@
import type { GameLibrary, GameLibraryGame, GameLibraryVersion } from '../game-files.js';
export type LatestGameFiles = Partial<Record<GameLibraryGame, GameLibraryVersion>>;
/** Use the controller's latest upload, even while that upload is still syncing to a host. */
export function latestGameFiles(libraries: GameLibrary[]): LatestGameFiles {
const latest: LatestGameFiles = {};
for (const library of libraries) {
const version = library.versions.find(item => item.id === library.latestVersionId && item.state === 'ready' && item.game === library.game);
if (version) latest[library.game] = version;
}
return latest;
}
/** Return whether callers need to discard a preview that targeted different files. */
export function selectLatestGameFiles(selection: Partial<Record<GameLibraryGame, string>>, latest: LatestGameFiles, games: GameLibraryGame[]): boolean {
let changed = false;
for (const game of games) {
const id = latest[game]?.id;
if (selection[game] === id) continue;
if (id) selection[game] = id;
else delete selection[game];
changed = true;
}
return changed;
}
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { heartbeat } from '$lib/server/hosts/index.js'; import { heartbeat } from '$lib/server/hosts/index.js';
import { authenticateAgent, hostApiError, objectBody } from '$lib/server/hosts/http.js'; import { authenticateAgent, hostApiError, objectBody } from '$lib/server/hosts/http.js';
import { reconcileHostPreparation } from '$lib/server/hosts/preparation.js';
export async function POST({ request }: { request: Request }) { export async function POST({ request }: { request: Request }) {
const authenticated = authenticateAgent(request); const authenticated = authenticateAgent(request);
try { try {
@@ -11,6 +12,7 @@ export async function POST({ request }: { request: Request }) {
const { updateHostLibraryCaches } = await import('$lib/server/game-files/index.js'); const { updateHostLibraryCaches } = await import('$lib/server/game-files/index.js');
updateHostLibraryCaches(host.id, inventory.libraryCaches, { snapshot: true }); updateHostLibraryCaches(host.id, inventory.libraryCaches, { snapshot: true });
} }
reconcileHostPreparation(host.id);
return json({ host, heartbeatSeconds: 15, serverTime: new Date().toISOString() }); return json({ host, heartbeatSeconds: 15, serverTime: new Date().toISOString() });
} catch (cause) { return hostApiError(cause); } } catch (cause) { return hostApiError(cause); }
} }
@@ -1,7 +1,9 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { pendingHostOperations } from '$lib/server/hosts/index.js'; import { pendingHostOperations } from '$lib/server/hosts/index.js';
import { authenticateAgent } from '$lib/server/hosts/http.js'; import { authenticateAgent } from '$lib/server/hosts/http.js';
import { reconcileHostPreparation } from '$lib/server/hosts/preparation.js';
export function GET({ request }: { request: Request }) { export function GET({ request }: { request: Request }) {
const host = authenticateAgent(request); const host = authenticateAgent(request);
reconcileHostPreparation(host.id);
return json({ operations: pendingHostOperations(host.id), serverTime: new Date().toISOString() }, { headers: { 'Cache-Control': 'no-store' } }); return json({ operations: pendingHostOperations(host.id), serverTime: new Date().toISOString() }, { headers: { 'Cache-Control': 'no-store' } });
} }
+3 -1
View File
@@ -1,8 +1,10 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { createEnrollment, createHost, listHosts } from '$lib/server/hosts/index.js'; import { createEnrollment, createHost, listHosts } from '$lib/server/hosts/index.js';
import { hostApiError, installerCommand, objectBody, requireManagementOrigin } from '$lib/server/hosts/http.js'; import { hostApiError, installerCommand, objectBody, requireManagementOrigin } from '$lib/server/hosts/http.js';
import { presentHostPreparation } from '$lib/server/hosts/preparation.js';
import { config } from '$lib/server/config.js';
export function GET({ request }: { request: Request }) { requireManagementOrigin(request); return json({ hosts: listHosts() }); } export function GET({ request }: { request: Request }) { requireManagementOrigin(request); return json({ hosts: listHosts().map(presentHostPreparation), defaultRuntimeImage: config.komodo.defaultImage }); }
export async function POST({ request }: { request: Request }) { export async function POST({ request }: { request: Request }) {
requireManagementOrigin(request); requireManagementOrigin(request);
try { try {
+3 -1
View File
@@ -1,12 +1,14 @@
import { error, json } from '@sveltejs/kit'; import { error, json } from '@sveltejs/kit';
import { deleteHost, getHost, hostDeletionStatus, HostDeletionError, listHostOperations, updateHost } from '$lib/server/hosts/index.js'; import { deleteHost, getHost, hostDeletionStatus, HostDeletionError, listHostOperations, updateHost } from '$lib/server/hosts/index.js';
import { hostApiError, objectBody, requireManagementOrigin } from '$lib/server/hosts/http.js'; import { hostApiError, objectBody, requireManagementOrigin } from '$lib/server/hosts/http.js';
import { presentHostPreparation } from '$lib/server/hosts/preparation.js';
import { config } from '$lib/server/config.js';
type Event = { request: Request; params: { id: string } }; type Event = { request: Request; params: { id: string } };
export function GET({ request, params }: Event) { export function GET({ request, params }: Event) {
requireManagementOrigin(request); const host = getHost(params.id); if (!host) error(404, 'Host not found.'); requireManagementOrigin(request); const host = getHost(params.id); if (!host) error(404, 'Host not found.');
// Payloads and results can contain RCON keys or staged credentials. History // Payloads and results can contain RCON keys or staged credentials. History
// exposes metadata only; agent APIs remain the only command delivery path. // exposes metadata only; agent APIs remain the only command delivery path.
return json({ host, deletion: hostDeletionStatus(host.id), operations: listHostOperations(host.id).map(({ payload: _payload, result: _result, ...operation }) => operation) }); return json({ host: presentHostPreparation(host), defaultRuntimeImage: config.komodo.defaultImage, deletion: hostDeletionStatus(host.id), operations: listHostOperations(host.id).map(({ payload: _payload, result: _result, ...operation }) => operation) });
} }
export async function PATCH({ request, params }: Event) { export async function PATCH({ request, params }: Event) {
requireManagementOrigin(request); requireManagementOrigin(request);
+4 -4
View File
@@ -18,16 +18,16 @@ export async function GET({ url }: { url: URL }) {
if (hosts.length || !komodoConfigured()) { if (hosts.length || !komodoConfigured()) {
const readyHosts = hosts.filter(host => host.readiness === 'ready'); const readyHosts = hosts.filter(host => host.readiness === 'ready');
const connected = hosts.filter(host => host.connected); const connected = hosts.filter(host => host.connected);
const versions = listLibraries().flatMap(library => library.versions).filter(version => version.state === 'ready'); const versions = listLibraries().flatMap(game => game.versions.filter(version => version.id === game.latestVersionId));
const cached = versions.filter(version => version.caches.some(cache => cache.status === 'ready' && readyHosts.some(host => host.id === cache.hostId))); const cached = versions.filter(version => version.caches.some(cache => cache.status === 'ready' && readyHosts.some(host => host.id === cache.hostId)));
const runtimes = connected.filter(host => host.inventory.runtimeImages?.length); const runtimes = connected.filter(host => host.inventory.runtimeImages?.length);
const checks: SetupCheck[] = [ const checks: SetupCheck[] = [
{ id: 'hosts', title: 'Host agents', state: readyHosts.length ? 'ready' : hosts.length ? 'attention' : 'unconfigured', detail: `${connected.length} connected, ${readyHosts.length} deployment-ready, ${hosts.length} enrolled or awaiting enrollment.`, href: '/hosts', action: hosts.length ? 'Review hosts' : 'Add host' }, { id: 'hosts', title: 'Host agents', state: readyHosts.length ? 'ready' : hosts.length ? 'attention' : 'unconfigured', detail: `${connected.length} connected, ${readyHosts.length} deployment-ready, ${hosts.length} enrolled or awaiting enrollment.`, href: '/hosts', action: hosts.length ? 'Review hosts' : 'Add host' },
{ id: 'runtime', title: 'Plutainer runtime', state: runtimes.length ? 'ready' : 'attention', detail: runtimes.length ? `${runtimes.length} connected hosts report cached runtime images.` : 'Prepare a Plutainer image on the host before creating a server.', href: '/hosts', action: 'Prepare runtime' }, { id: 'runtime', title: 'Plutainer runtime', state: runtimes.length ? 'ready' : 'attention', detail: runtimes.length ? `${runtimes.length} connected hosts report cached runtime images.` : 'Hosts prepare the configured Plutainer runtime automatically.', href: '/hosts', action: 'View host setup' },
{ id: 'game-files', title: 'Verified game-file libraries', state: cached.length ? 'ready' : 'attention', detail: `${versions.length} published versions; ${cached.length} have a verified cache on a ready host.`, href: '/game-files', action: 'Manage game files' }, { id: 'game-files', title: 'T5 / T6 game files', state: cached.length ? 'ready' : 'attention', detail: `${versions.length} games have uploaded files; ${cached.length} have their latest files verified on a ready host.`, href: '/game-files', action: 'Manage game files' },
{ id: 'rcon', title: 'Agent-local game connection', state: rconServers ? 'ready' : 'unverified', detail: rconServers ? `${rconServers} servers have fresh RCON responses.` : 'The host agent checks RCON locally after deployment. Controller access to game ports is not required.', href: servers.length ? '/' : '/hosts', action: servers.length ? 'Review fleet' : 'Review hosts' }, { id: 'rcon', title: 'Agent-local game connection', state: rconServers ? 'ready' : 'unverified', detail: rconServers ? `${rconServers} servers have fresh RCON responses.` : 'The host agent checks RCON locally after deployment. Controller access to game ports is not required.', href: servers.length ? '/' : '/hosts', action: servers.length ? 'Review fleet' : 'Review hosts' },
{ id: 'keys', title: 'Saved server keys', state: savedKeys ? 'ready' : 'attention', detail: `${savedKeys} saved ${savedKeys === 1 ? 'key' : 'keys'}. Validity is checked with Plutonium at startup.`, href: '/server-keys', action: 'Manage keys' }, { id: 'keys', title: 'Saved server keys', state: savedKeys ? 'ready' : 'attention', detail: `${savedKeys} saved ${savedKeys === 1 ? 'key' : 'keys'}. Validity is checked with Plutonium at startup.`, href: '/server-keys', action: 'Manage keys' },
{ id: 'first-server', title: 'First server ready', state: readyServers ? 'ready' : 'attention', detail: readyServers ? `${readyServers} of ${servers.length} servers are responding.` : servers.length ? 'Registered servers are awaiting fresh healthy reports. Inspect their host and diagnostics.' : 'Choose a ready host and a game library when creating your first server.', href: servers.length ? '/' : '/provisioning', action: servers.length ? 'Review fleet' : 'Create a server' } { id: 'first-server', title: 'First server ready', state: readyServers ? 'ready' : 'attention', detail: readyServers ? `${readyServers} of ${servers.length} servers are responding.` : servers.length ? 'Registered servers are awaiting fresh healthy reports. Inspect their host and diagnostics.' : 'Choose a host to launch your first server with the latest game files.', href: servers.length ? '/' : '/provisioning', action: servers.length ? 'Review fleet' : 'Create a server' }
]; ];
return json({ checkedAt: new Date().toISOString(), checks, readyServers, totalServers: servers.length, savedKeys } satisfies SystemStatus, { headers: { 'cache-control': 'no-store' } }); return json({ checkedAt: new Date().toISOString(), checks, readyServers, totalServers: servers.length, savedKeys } satisfies SystemStatus, { headers: { 'cache-control': 'no-store' } });
} }
+105 -59
View File
@@ -10,7 +10,6 @@
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
import Badge from '$ui/components/primitives/Badge.svelte'; import Badge from '$ui/components/primitives/Badge.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
type OverviewLibrary = Omit<GameLibrary, 'versions'> & { versions: Omit<GameLibraryVersion, 'files'>[] }; type OverviewLibrary = Omit<GameLibrary, 'versions'> & { versions: Omit<GameLibraryVersion, 'files'>[] };
interface UploadFile { path: string; size: number; lastModified: number; receivedBytes: number; complete: boolean } interface UploadFile { path: string; size: number; lastModified: number; receivedBytes: number; complete: boolean }
@@ -19,8 +18,7 @@
let libraries = $state<OverviewLibrary[]>([]); let hosts = $state<Host[]>([]); let libraries = $state<OverviewLibrary[]>([]); let hosts = $state<Host[]>([]);
let loaded = $state(false); let error = $state(''); let notice = $state(''); let busy = $state(false); let loaded = $state(false); let error = $state(''); let notice = $state(''); let busy = $state(false);
let showUpload = $state(false); let game = $state<'t5' | 't6'>('t5'); let showUpload = $state(false); let game = $state<'t5' | 't6'>('t5');
let name = $state(''); let version = $state(''); let description = $state(''); let targets = $state<Record<string, string>>({});
let filterGame = $state('all'); let targets = $state<Record<string, string>>({});
let folder = $state.raw<SelectedGameFolder | null>(null); let folder = $state.raw<SelectedGameFolder | null>(null);
let uploadStatus = $state.raw<UploadStatus | null>(null); let uploadStatus = $state.raw<UploadStatus | null>(null);
let uploadId = $state(''); let uploadBusy = $state(false); let uploadError = $state(''); let uploadId = $state(''); let uploadBusy = $state(false); let uploadError = $state('');
@@ -30,12 +28,21 @@
let activeRequest: XMLHttpRequest | null = null; let disposed = false; let activeRequest: XMLHttpRequest | null = null; let disposed = false;
const canManage = $derived($page.data.user?.role === 'admin'); const canManage = $derived($page.data.user?.role === 'admin');
const onlineHosts = $derived(hosts.filter(host => host.connected && host.credentialActive && host.mode !== 'disabled')); const onlineHosts = $derived(hosts.filter(host => host.connected && host.credentialActive && host.mode !== 'disabled'));
const matching = $derived(libraries.filter(library => filterGame === 'all' || library.game === filterGame));
const totalBytes = $derived(folder?.size ?? uploadStatus?.files.reduce((total, file) => total + file.size, 0) ?? 0); const totalBytes = $derived(folder?.size ?? uploadStatus?.files.reduce((total, file) => total + file.size, 0) ?? 0);
const totalFiles = $derived(folder?.files.length ?? uploadStatus?.files.length ?? 0); const totalFiles = $derived(folder?.files.length ?? uploadStatus?.files.length ?? 0);
const progressBytes = $derived(Math.min(totalBytes, acceptedBytes + sendingBytes)); const progressBytes = $derived(Math.min(totalBytes, acceptedBytes + sendingBytes));
const transferring = $derived(uploadBusy && (phase === 'preparing' || phase === 'uploading')); const transferring = $derived(uploadBusy && (phase === 'preparing' || phase === 'uploading'));
const hostName = (id: string | null) => id ? hosts.find(host => host.id === id)?.name ?? id : 'Browser upload'; const hostName = (id: string | null) => id ? hosts.find(host => host.id === id)?.name ?? id : 'Browser upload';
const gameName = (game: 't5' | 't6') => game === 't5' ? 'Black Ops I (T5)' : 'Black Ops II (T6)';
const uploadDate = (item: OverviewLibrary['versions'][number]) => new Date(item.publishedAt ?? item.createdAt).toLocaleString();
function automaticDownloadDetail(host: Host | undefined): string {
if (host?.decommission) return 'Automatic setup is paused during host deletion.';
if (!host?.enrolled) return 'Waiting for host enrollment.';
if (host.mode !== 'active') return 'Automatic setup resumes when the host is active.';
if (!host.credentialActive || !host.connected) return 'Automatic setup resumes when the host reconnects.';
if (host.readiness === 'degraded') return host.readinessReasons[0] ?? 'The host needs attention before automatic setup can continue.';
return 'Download starts automatically after runtime preparation and earlier work finish.';
}
async function responseBody(response: Response) { async function responseBody(response: Response) {
let body; let body;
@@ -53,7 +60,7 @@
busy = true; error = ''; notice = ''; busy = true; error = ''; notice = '';
try { try {
await responseBody(await fetch(`/api/game-files/${versionId}/download`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hostId: targets[versionId] }) })); await responseBody(await fetch(`/api/game-files/${versionId}/download`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hostId: targets[versionId] }) }));
notice = 'Library download queued. Progress will update below.'; await load(); notice = 'Game files queued for download. Progress will update below.'; await load();
} catch (cause) { error = (cause as Error).message; } finally { busy = false; } } catch (cause) { error = (cause as Error).message; } finally { busy = false; }
} }
function resetUpload() { function resetUpload() {
@@ -61,10 +68,9 @@
acceptedBytes = 0; sendingBytes = 0; completedFiles = 0; pauseRequested = false; acceptedBytes = 0; sendingBytes = 0; completedFiles = 0; pauseRequested = false;
if (folderInput) folderInput.value = ''; if (folderInput) folderInput.value = '';
} }
async function newUpload(library?: OverviewLibrary) { async function newUpload(library: OverviewLibrary) {
resetUpload(); showUpload = true; game = library?.game ?? 't5'; name = library?.name ?? ''; resetUpload(); showUpload = true; game = library.game;
description = library?.description ?? ''; version = ''; await tick(); document.getElementById('game-upload')?.scrollIntoView({ behavior: 'smooth' });
await tick(); document.getElementById('library-upload')?.scrollIntoView({ behavior: 'smooth' });
} }
function chooseFolder(event: Event) { function chooseFolder(event: Event) {
uploadError = ''; folder = null; uploadError = ''; folder = null;
@@ -74,14 +80,13 @@
const selected = selectGameFolder(Array.from(input.files)); const selected = selectGameFolder(Array.from(input.files));
if (uploadStatus) checkSelection(selected, uploadStatus); if (uploadStatus) checkSelection(selected, uploadStatus);
folder = selected; folder = selected;
if (!name && !uploadId) name = game === 't5' ? 'Black Ops I game files' : 'Black Ops II game files';
} catch (cause) { uploadError = (cause as Error).message; input.value = ''; } } catch (cause) { uploadError = (cause as Error).message; input.value = ''; }
} }
function checkSelection(selected: SelectedGameFolder, status: UploadStatus) { function checkSelection(selected: SelectedGameFolder, status: UploadStatus) {
const originals = new Map(status.files.map(file => [file.path, file])); const originals = new Map(status.files.map(file => [file.path, file]));
if (selected.files.length !== originals.size || selected.files.some(({ path, file }) => { if (selected.files.length !== originals.size || selected.files.some(({ path, file }) => {
const original = originals.get(path); return !original || original.size !== file.size || original.lastModified !== file.lastModified; const original = originals.get(path); return !original || original.size !== file.size || original.lastModified !== file.lastModified;
})) throw new Error('This folder does not match the original upload. Select the same unchanged folder, including its original file names, sizes, and modification dates. Upload changed game files as a new version.'); })) throw new Error('This folder does not match the original upload. Select the same unchanged folder, including its original file names, sizes, and modification dates. Start a new upload for changed game files.');
} }
function applyStatus(status: UploadStatus) { function applyStatus(status: UploadStatus) {
uploadStatus = status; uploadStatus = status;
@@ -101,12 +106,12 @@
} }
if (disposed) return; if (disposed) return;
if (status.status !== 'published') throw new Error(status.error || 'Verification did not finish. Retry this upload to continue.'); if (status.status !== 'published') throw new Error(status.error || 'Verification did not finish. Retry this upload to continue.');
phase = 'published'; folder = null; notice = `${name || 'Game files'} ${version} published. You can download this version to a host below.`; phase = 'published'; folder = null; notice = `${game.toUpperCase()} game files are ready. Active hosts will download and verify the latest files automatically.`;
if (folderInput) folderInput.value = ''; if (folderInput) folderInput.value = '';
await load(); await load();
} }
async function resumeUpload(library: OverviewLibrary, item: OverviewLibrary['versions'][number]) { async function resumeUpload(library: OverviewLibrary, item: OverviewLibrary['versions'][number]) {
resetUpload(); showUpload = true; uploadId = item.id; game = library.game; name = library.name; version = item.version; description = item.description; resetUpload(); showUpload = true; uploadId = item.id; game = library.game;
uploadBusy = true; uploadBusy = true;
try { try {
const status = await getStatus(); const status = await getStatus();
@@ -114,7 +119,7 @@
else { phase = 'paused'; uploadError = status.error ?? ''; } else { phase = 'paused'; uploadError = status.error ?? ''; }
} catch (cause) { uploadError = (cause as Error).message; phase = 'paused'; } } catch (cause) { uploadError = (cause as Error).message; phase = 'paused'; }
finally { uploadBusy = false; } finally { uploadBusy = false; }
await tick(); document.getElementById('library-upload')?.scrollIntoView({ behavior: 'smooth' }); await tick(); document.getElementById('game-upload')?.scrollIntoView({ behavior: 'smooth' });
} }
function pauseUpload() { pauseRequested = true; activeRequest?.abort(); } function pauseUpload() { pauseRequested = true; activeRequest?.abort(); }
async function sendChunk(path: string, file: File, offset: number, length: number): Promise<number> { async function sendChunk(path: string, file: File, offset: number, length: number): Promise<number> {
@@ -150,7 +155,7 @@
uploadBusy = true; pauseRequested = false; uploadError = ''; notice = ''; phase = 'preparing'; uploadBusy = true; pauseRequested = false; uploadError = ''; notice = ''; phase = 'preparing';
try { try {
if (!uploadId) { if (!uploadId) {
const result = await responseBody(await fetch('/api/game-files', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ game, name, version, description, files: folder.files.map(({ path, file }) => ({ path, size: file.size, lastModified: file.lastModified })) }) })); const result = await responseBody(await fetch('/api/game-files', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ game, files: folder.files.map(({ path, file }) => ({ path, size: file.size, lastModified: file.lastModified })) }) }));
uploadId = result.version.id; uploadId = result.version.id;
} }
const status = await getStatus(); const status = await getStatus();
@@ -192,78 +197,119 @@
<svelte:head><title>Game Files | Electric Cherry</title></svelte:head> <svelte:head><title>Game Files | Electric Cherry</title></svelte:head>
<div class="page"> <div class="page">
<SectionHeader title="Game Files" subtitle="Upload your T5 or T6 game folder, then distribute verified versions to your hosts."> <SectionHeader title="Game Files" subtitle="Upload your T5 and T6 game folders. Hosts automatically keep a verified copy of the latest files for each game." />
{#snippet actions()}{#if canManage}<Button disabled={uploadBusy} onclick={() => newUpload()}>Upload game folder</Button>{/if}{/snippet}
</SectionHeader>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if} {#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if notice}<p role="status">{notice}</p>{/if} {#if notice}<p role="status">{notice}</p>{/if}
{#if !canManage}<p class="muted">Administrators upload game files and distribute verified copies to hosts.</p>{/if} {#if !canManage}<p class="muted">Administrators upload game files. Active hosts prepare verified copies automatically.</p>{/if}
{#if canManage && showUpload} {#if canManage && showUpload}
<section class="card" id="library-upload"> <section class="card" id="game-upload">
<div class="heading"><h2>{uploadId ? phase === 'published' ? 'Version published' : 'Continue folder upload' : 'Upload a game folder'}</h2><Button size="sm" variant="ghost" disabled={uploadBusy} onclick={() => showUpload = false}>Close</Button></div> <div class="heading"><h2>{phase === 'published' ? `${game.toUpperCase()} files uploaded` : uploadId ? `Continue ${game.toUpperCase()} upload` : `Upload ${game.toUpperCase()} game folder`}</h2><Button size="sm" variant="ghost" disabled={uploadBusy} onclick={() => showUpload = false}>Close</Button></div>
<p>Select your complete T5 or T6 game folder, including its installed DLC. The files upload directly to this controller; a game host is only needed when you are ready to run servers.</p> <p>Select your complete {gameName(game)} game folder, including its installed DLC. Once verified, this upload becomes the latest files and hosts download it automatically.</p>
{#if uploadError}<p class="ui-error" role="alert">{uploadError}</p>{/if} {#if uploadError}<p class="ui-error" role="alert">{uploadError}</p>{/if}
<form onsubmit={startUpload}> <form onsubmit={startUpload}>
<fieldset disabled={uploadBusy || !!uploadId} class="fields">
<label>Game<select bind:value={game}><option value="t5">Black Ops I (T5)</option><option value="t6">Black Ops II (T6)</option></select></label>
<label>Library name<input required maxlength="160" bind:value={name} placeholder="Black Ops II game files" /></label>
<label>Version label<input required maxlength="120" bind:value={version} placeholder="2026-10-04" /></label>
<label class="wide">Description <span class="muted">(optional)</span><textarea maxlength="4000" bind:value={description} rows="2"></textarea></label>
</fieldset>
{#if phase !== 'verifying' && phase !== 'published'} {#if phase !== 'verifying' && phase !== 'published'}
<label class="folder-picker">{uploadId ? 'Select the original unchanged folder' : 'Game folder'}<input bind:this={folderInput} type="file" webkitdirectory multiple onchange={chooseFolder} disabled={uploadBusy} aria-describedby="folder-help" /></label> <label class="folder-picker">{uploadId ? 'Select the original unchanged folder' : 'Game folder'}<input bind:this={folderInput} type="file" webkitdirectory multiple onchange={chooseFolder} disabled={uploadBusy} aria-describedby="folder-help" /></label>
<p class="muted" id="folder-help">{uploadId ? 'To resume, file names, sizes, and modification dates must match the original upload.' : 'Choose the game directory itself. Its top-level folder name is removed once; all files and subfolders inside it keep their paths.'}</p> <p class="muted" id="folder-help">{uploadId ? 'To resume, file names, sizes, and modification dates must match the original upload.' : 'Choose the game directory itself. Its top-level folder name is removed once; all files and subfolders inside it keep their paths.'}</p>
{/if} {/if}
{#if folder}<div class="folder-summary"><strong>{folder.name}</strong><span>{folder.files.length.toLocaleString()} files · {formatGameBytes(folder.size)}</span><details><summary>Preview folder paths</summary><ul class="path-list">{#each folder.files.slice(0, 8) as file}<li>{file.path}</li>{/each}</ul>{#if folder.files.length > 8}<p class="muted">And {(folder.files.length - 8).toLocaleString()} more files.</p>{/if}</details></div>{/if} {#if folder}<div class="folder-summary"><strong>{folder.name}</strong><span>{folder.files.length.toLocaleString()} files · {formatGameBytes(folder.size)}</span><details><summary>Preview folder paths</summary><ul class="path-list">{#each folder.files.slice(0, 8) as file}<li>{file.path}</li>{/each}</ul>{#if folder.files.length > 8}<p class="muted">And {(folder.files.length - 8).toLocaleString()} more files.</p>{/if}</details></div>{/if}
{#if phase !== 'idle'}<div class="upload-progress" aria-live="polite"> {#if phase !== 'idle'}<div class="upload-progress" aria-live="polite">
<div class="heading"><strong>{phase === 'preparing' ? 'Preparing upload…' : phase === 'uploading' ? 'Uploading game files' : phase === 'verifying' ? 'Verifying and publishing…' : phase === 'published' ? 'Published and ready to distribute' : 'Upload paused'}</strong><span>{completedFiles.toLocaleString()} / {totalFiles.toLocaleString()} files</span></div> <div class="heading"><strong>{phase === 'preparing' ? 'Preparing upload…' : phase === 'uploading' ? 'Uploading game files' : phase === 'verifying' ? 'Verifying files…' : phase === 'published' ? 'Ready · host downloads are automatic' : 'Upload paused'}</strong><span>{completedFiles.toLocaleString()} / {totalFiles.toLocaleString()} files</span></div>
<progress max={totalBytes || Math.max(1, totalFiles)} value={totalBytes ? progressBytes : completedFiles} aria-label="Game folder upload progress"></progress> <progress max={totalBytes || Math.max(1, totalFiles)} value={totalBytes ? progressBytes : completedFiles} aria-label="Game folder upload progress"></progress>
<p>{formatGameBytes(progressBytes)} / {formatGameBytes(totalBytes)} saved or transferring</p> <p>{formatGameBytes(progressBytes)} / {formatGameBytes(totalBytes)} saved or transferring</p>
{#if currentPath}<p class="current-file">{currentPath}</p>{/if} {#if currentPath}<p class="current-file">{currentPath}</p>{/if}
{#if phase === 'verifying'}<p class="muted">The controller checks every file and publishes this version when verification finishes. Verification continues if you leave this page.</p>{:else if phase === 'paused'}<p class="muted">Saved chunks are kept on the controller. Resume this upload to check the saved progress and retry.</p>{/if} {#if phase === 'verifying'}<p class="muted">The controller checks every file before making this upload available to hosts. Verification continues if you leave this page.</p>{:else if phase === 'paused'}<p class="muted">Saved chunks are kept on the controller. Resume this upload to check the saved progress and retry.</p>{/if}
</div>{/if} </div>{/if}
<div class="upload-actions"> <div class="upload-actions">
{#if transferring}<Button onclick={pauseUpload} disabled={pauseRequested}>{pauseRequested ? 'Pausing…' : 'Pause upload'}</Button> {#if transferring}<Button onclick={pauseUpload} disabled={pauseRequested}>{pauseRequested ? 'Pausing…' : 'Pause upload'}</Button>
{:else if phase !== 'verifying' && phase !== 'published'}<Button type="submit" variant="primary" disabled={uploadBusy || !folder}>{uploadId ? 'Resume upload' : 'Upload and publish'}</Button>{/if} {:else if phase !== 'verifying' && phase !== 'published'}<Button type="submit" variant="primary" disabled={uploadBusy || !folder}>{uploadId ? 'Resume upload' : `Upload ${game.toUpperCase()} files`}</Button>{/if}
{#if phase === 'idle' || phase === 'uploading'}<span class="muted">Keep this page open while files transfer. Interrupted uploads can be resumed below.</span>{/if} {#if phase === 'idle' || phase === 'uploading'}<span class="muted">Keep this page open while files transfer. Interrupted uploads can be resumed below.</span>{/if}
</div> </div>
</form> </form>
</section> </section>
{/if} {/if}
<div class="filters"><label>Game<select bind:value={filterGame}><option value="all">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label><span class="muted">{libraries.length} libraries · Game folders and runtime images are versioned separately.</span></div> {#if !loaded}<p role="status">Loading game files…</p>{/if}
{#each matching as library (library.id)} {#each libraries as library (library.game)}
{@const latest = library.versions.find(item => item.id === library.latestVersionId)}
{@const unfinished = library.versions.filter(item => item.state !== 'ready')}
{@const history = library.versions.filter(item => item.state === 'ready' && item.id !== library.latestVersionId)}
<section class="card"> <section class="card">
<div class="heading"><div><h2>{library.name}</h2><p class="muted">{library.game.toUpperCase()} · {library.description}</p></div>{#if canManage}<Button size="sm" disabled={uploadBusy} onclick={() => newUpload(library)}>Upload new version</Button>{/if}</div> <div class="heading">
{#each library.versions as item (item.id)} <div><h2>{gameName(library.game)}</h2><p class="muted">{latest ? 'Latest verified game files' : 'Game folder not uploaded yet'}</p></div>
{@const browserUpload = !item.sourceHostId} {#if canManage}<Button size="sm" disabled={uploadBusy} onclick={() => newUpload(library)}>{latest ? `Update ${library.game.toUpperCase()} files` : `Upload ${library.game.toUpperCase()} files`}</Button>{/if}
<article class="version"> </div>
<div class="heading"><h3>{item.version}</h3><Badge variant={item.state === 'failed' ? 'danger' : item.state === 'ready' ? 'online' : 'info'}>{item.state === 'ready' ? 'Published' : item.state === 'importing' ? browserUpload ? item.id === uploadId && phase === 'verifying' ? 'Verifying' : 'Uploading' : 'Importing' : 'Failed'}</Badge></div> {#if latest}
<p>{formatGameBytes(item.sizeBytes)} · {item.fileCount.toLocaleString()} files · Created {new Date(item.createdAt).toLocaleString()}</p> <div class="heading"><p>{formatGameBytes(latest.sizeBytes)} · {latest.fileCount.toLocaleString()} files · Uploaded {uploadDate(latest)}</p><Badge variant="online">Available</Badge></div>
{#if item.error}<p class="ui-error">{item.error}</p>{/if} <p class="muted">Active hosts keep these files ready automatically, including hosts added later. Running servers keep using their existing files.</p>
<details><summary>Version details</summary><dl><dt>Immutable version ID</dt><dd>{item.id}</dd><dt>Manifest SHA256</dt><dd>{item.manifestSha256 ?? 'Available after verification'}</dd><dt>Uploaded from</dt><dd>{browserUpload ? 'Game folder in browser' : `${hostName(item.sourceHostId)} / ${item.sourceVolume}`}</dd></dl>{#if item.state === 'ready'}<a href={`/api/game-files/${item.id}`} target="_blank" rel="noreferrer">View complete file manifest</a>{/if}</details> {@render hostCopies(latest, true)}
{#if item.state === 'ready'} {@render uploadDetails(latest)}
{#if canManage}<div class="download"><label>Download to host<select bind:value={targets[item.id]}><option value="">Select host</option>{#each onlineHosts as host}<option value={host.id}>{host.name} · {formatGameBytes(Number(host.inventory.storageAvailable ?? 0))} free</option>{/each}</select></label><Button size="sm" disabled={busy || !targets[item.id]} onclick={() => download(item.id)}>Download / verify</Button></div>{#if !onlineHosts.length}<p class="muted">This version is stored and ready. <a href="/hosts">Connect a host</a> when you want to deploy game servers.</p>{/if}{/if} {:else}
<div class="table-wrap"><table><thead><tr><th>Host</th><th>Cache state</th><th>Progress</th><th>Detail</th></tr></thead><tbody> <p class="muted">Upload the complete {library.game.toUpperCase()} game folder once. Hosts will download and verify it automatically as part of setup.</p>
{#each item.caches as cache}<tr><td><a href={`/hosts/${cache.hostId}`}>{hostName(cache.hostId)}</a></td><td>{cache.status}{!hosts.find(host => host.id === cache.hostId)?.connected ? ' · offline' : ''}</td><td>{#if cache.status === 'downloading' || cache.status === 'verifying'}<progress max={cache.bytesTotal || 1} value={cache.bytesDownloaded} aria-label="Host download progress"></progress><small>{formatGameBytes(cache.bytesDownloaded)} / {formatGameBytes(cache.bytesTotal)}</small>{:else if cache.status === 'ready' || cache.status === 'outdated'}Verified{cache.status === 'outdated' ? ' · older version' : ''}{:else}—{/if}</td><td>{cache.error ?? ''}</td></tr>{:else}<tr><td colspan="4">No host copies yet.</td></tr>{/each} {/if}
</tbody></table></div> {#if unfinished.length}
{:else if browserUpload}<p class="muted">Resume with the original unchanged folder. Published versions can be downloaded to any enrolled host.</p>{#if canManage}<div><Button size="sm" disabled={uploadBusy} onclick={() => resumeUpload(library, item)}>Resume upload</Button></div>{/if} <div class="uploads">
{:else if item.state === 'importing'}<p class="muted">The existing host import continues independently. It is reconciled when the host reconnects.</p>{/if} <h3>Unfinished uploads</h3>
</article> {#each unfinished as item (item.id)}
{/each} {@const browserUpload = !item.sourceHostId}
<article class="upload-entry">
<div class="heading"><p>Started {new Date(item.createdAt).toLocaleString()} · {formatGameBytes(item.sizeBytes)} · {item.fileCount.toLocaleString()} files</p><Badge variant={item.state === 'failed' ? 'danger' : 'info'}>{item.state === 'failed' ? 'Failed' : browserUpload ? item.id === uploadId && phase === 'verifying' ? 'Verifying' : 'Uploading' : 'Importing'}</Badge></div>
{#if item.error}<p class="ui-error">{item.error}</p>{/if}
{#if browserUpload}
<p class="muted">Resume with the original unchanged folder. The latest files stay available while this upload completes.</p>
{#if canManage}<div><Button size="sm" disabled={uploadBusy} onclick={() => resumeUpload(library, item)}>Resume upload</Button></div>{/if}
{:else if item.state === 'importing'}<p class="muted">The existing host import continues independently. It is reconciled when the host reconnects.</p>{/if}
</article>
{/each}
</div>
{/if}
{#if history.length}
<details class="history">
<summary>Previous uploads ({history.length})</summary>
<p class="muted">Previous files remain available for servers that already use them.</p>
{#each history as item (item.id)}
<article class="upload-entry">
<h3>Uploaded {uploadDate(item)}</h3>
<p>{formatGameBytes(item.sizeBytes)} · {item.fileCount.toLocaleString()} files</p>
{@render uploadDetails(item)}
<details><summary>Host copies</summary>{@render hostCopies(item, false)}</details>
</article>
{/each}
</details>
{/if}
</section> </section>
{:else}{#if loaded}<EmptyState title="No game libraries yet" description="Upload a complete T5 or T6 game folder to get started. You can add a host after the files are published." />{:else}<p role="status">Loading game libraries…</p>{/if}{/each} {/each}
</div> </div>
{#snippet uploadDetails(item: OverviewLibrary['versions'][number])}
<details><summary>Upload details</summary><dl><dt>Upload ID</dt><dd>{item.id}</dd><dt>Manifest SHA256</dt><dd>{item.manifestSha256 ?? 'Available after verification'}</dd><dt>Uploaded from</dt><dd>{!item.sourceHostId ? 'Game folder in browser' : `${hostName(item.sourceHostId)} / ${item.sourceVolume}`}</dd></dl><a href={`/api/game-files/${item.id}`} target="_blank" rel="noreferrer">View complete file manifest</a></details>
{/snippet}
{#snippet hostCopies(item: OverviewLibrary['versions'][number], latest: boolean)}
{#if !hosts.length}<p class="muted">Files are ready. <a href="/hosts">Add a host</a> to prepare them automatically.</p>{/if}
<div class="table-wrap"><table><thead><tr><th>Host</th><th>File status</th><th>Progress</th><th>Detail</th></tr></thead><tbody>
{#each item.caches as cache}
{@const cacheHost = hosts.find(host => host.id === cache.hostId)}
{@const awaitingAutomaticDownload = latest && cache.status === 'missing'}
<tr>
<td><a href={`/hosts/${cache.hostId}`}>{hostName(cache.hostId)}</a></td>
<td>{awaitingAutomaticDownload ? 'Waiting' : cache.status}{!cacheHost?.connected ? ' · offline' : ''}</td>
<td>{#if cache.status === 'downloading' || cache.status === 'verifying'}<progress max={cache.bytesTotal || 1} value={cache.bytesDownloaded} aria-label="Host download progress"></progress><small>{formatGameBytes(cache.bytesDownloaded)} / {formatGameBytes(cache.bytesTotal)}</small>{:else if cache.status === 'ready' || cache.status === 'outdated'}Verified{cache.status === 'outdated' ? ' · previous upload' : ''}{:else}—{/if}</td>
<td>{cache.error ?? (awaitingAutomaticDownload ? automaticDownloadDetail(cacheHost) : '')}{#if awaitingAutomaticDownload || cache.status === 'failed'} <a href={`/hosts/${cache.hostId}`}>View host setup</a>{/if}</td>
</tr>
{:else}<tr><td colspan="4">No host copies yet.</td></tr>{/each}
</tbody></table></div>
{#if canManage}<details><summary>Advanced: download or verify these files</summary><p class="muted">Retry a download or prepare a previous upload on a host. The latest files are downloaded automatically.</p><div class="download"><label>Host<select bind:value={targets[item.id]}><option value="">Select host</option>{#each onlineHosts as host}<option value={host.id}>{host.name} · {formatGameBytes(Number(host.inventory.storageAvailable ?? 0))} free</option>{/each}</select></label><Button size="sm" disabled={busy || !targets[item.id]} onclick={() => download(item.id)}>Download / verify</Button></div></details>{/if}
{/snippet}
<style> <style>
.page, .card, .version, form { display:grid; gap:20px; } .page, .card, .uploads, .upload-entry, form { display:grid; gap:20px; }
.card { padding:24px; min-width:0; border:1px solid var(--border-subtle); background:var(--surface); border-radius:var(--radius-md); } .card { padding:24px; min-width:0; border:1px solid var(--border-subtle); background:var(--surface); border-radius:var(--radius-md); }
.version { padding-top:20px; border-top:1px solid var(--border-subtle); } .uploads, .upload-entry { padding-top:20px; border-top:1px solid var(--border-subtle); }
.heading, .filters, .download, .upload-actions { display:flex; gap:16px; align-items:center; justify-content:space-between; flex-wrap:wrap; } .history>p, .history>.upload-entry { margin-top:16px; }
.heading, .download, .upload-actions { display:flex; gap:16px; align-items:center; justify-content:space-between; flex-wrap:wrap; }
.download, .upload-actions { justify-content:flex-start; align-items:end; } .download, .upload-actions { justify-content:flex-start; align-items:end; }
.fields { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px; border:0; padding:0; margin:0; min-width:0; } label { display:grid; gap:6px; font-size:var(--text-sm); }
.wide { grid-column:1/-1; } label { display:grid; gap:6px; font-size:var(--text-sm); } input, select { width:100%; min-width:0; box-sizing:border-box; padding:10px; background:var(--surface); color:inherit; border:1px solid var(--border-strong); border-radius:var(--radius-sm); }
input, select, textarea { width:100%; min-width:0; box-sizing:border-box; padding:10px; background:var(--surface); color:inherit; border:1px solid var(--border-strong); border-radius:var(--radius-sm); } input:disabled, select:disabled { opacity:.7; } input[type='file'] { padding:16px; cursor:pointer; }
input:disabled, select:disabled, textarea:disabled { opacity:.7; } input[type='file'] { padding:16px; cursor:pointer; }
.folder-summary,.upload-progress { padding:20px; display:grid; gap:12px; background:var(--surface-sunken); border:1px solid var(--border-subtle); border-radius:var(--radius-md); min-width:0; } .folder-summary,.upload-progress { padding:20px; display:grid; gap:12px; background:var(--surface-sunken); border:1px solid var(--border-subtle); border-radius:var(--radius-md); min-width:0; }
.folder-summary>span,.upload-progress span,.upload-actions>span { color:var(--text-secondary); font-size:var(--text-sm); } .folder-summary>span,.upload-progress span,.upload-actions>span { color:var(--text-secondary); font-size:var(--text-sm); }
.path-list { padding-left:20px; font-family:var(--font-mono); font-size:var(--text-xs); overflow-wrap:anywhere; } .path-list { padding-left:20px; font-family:var(--font-mono); font-size:var(--text-xs); overflow-wrap:anywhere; }
@@ -273,5 +319,5 @@
small { display:block; } a { color:var(--accent-primary-hover); } summary { cursor:pointer; } small { display:block; } a { color:var(--accent-primary-hover); } summary { cursor:pointer; }
dl { display:grid; grid-template-columns:auto 1fr; gap:6px 16px; } dd { margin:0; overflow-wrap:anywhere; } dl { display:grid; grid-template-columns:auto 1fr; gap:6px 16px; } dd { margin:0; overflow-wrap:anywhere; }
.table-wrap { overflow-x:auto; } table { width:100%; border-collapse:collapse; text-align:left; } th,td { padding:10px; border-bottom:1px solid var(--border-subtle); } .table-wrap { overflow-x:auto; } table { width:100%; border-collapse:collapse; text-align:left; } th,td { padding:10px; border-bottom:1px solid var(--border-subtle); }
@media(max-width:640px) { .fields { grid-template-columns:1fr; } .card,.folder-summary,.upload-progress { padding:16px; } dl { grid-template-columns:1fr; } .heading>div { min-width:0; } } @media(max-width:640px) { .card,.folder-summary,.upload-progress { padding:16px; } dl { grid-template-columns:1fr; } .heading>div { min-width:0; } }
</style> </style>
+4 -4
View File
@@ -37,11 +37,11 @@
<svelte:head><title>Hosts · Electric Cherry</title></svelte:head> <svelte:head><title>Hosts · Electric Cherry</title></svelte:head>
<section class="page"> <section class="page">
<SectionHeader title="Hosts" subtitle="Connect game-server hosts and prepare them for deployment." /> <SectionHeader title="Hosts" subtitle="Connect game-server hosts. Runtime and game files are prepared automatically." />
{#if error}<p role="alert" class="ui-error">{error}</p>{/if} {#if error}<p role="alert" class="ui-error">{error}</p>{/if}
{#if notice}<p role="status">{notice}</p>{/if} {#if notice}<p role="status">{notice}</p>{/if}
{#if canManage}<section class="panel"> {#if canManage}<section class="panel">
<h2>Add host</h2><p>Run the installation command on a Debian 12 or 13 amd64 machine. Each host connects to this controller over HTTPS.</p> <h2>Add host</h2><p>Run the installation command on a Debian 12 or 13 amd64 machine. Each host connects to this controller over HTTPS, then prepares its runtime and published game files automatically.</p>
<form onsubmit={add}><fieldset disabled={busy}> <form onsubmit={add}><fieldset disabled={busy}>
<label>Friendly name<input required maxlength="200" bind:value={name} placeholder="Game host 1" /></label> <label>Friendly name<input required maxlength="200" bind:value={name} placeholder="Game host 1" /></label>
<label>First game port<input type="number" required min="1" max="65535" bind:value={portMin} /></label> <label>First game port<input type="number" required min="1" max="65535" bind:value={portMin} /></label>
@@ -49,9 +49,9 @@
<Button type="submit" disabled={busy}>{busy ? 'Creating…' : 'Add host'}</Button> <Button type="submit" disabled={busy}>{busy ? 'Creating…' : 'Add host'}</Button>
</fieldset></form> </fieldset></form>
{#if enrollment}<div class="enrollment" role="status"><h3>Install the agent</h3><p>This single-use command expires {new Date(enrollment.expiresAt).toLocaleString()}. Run it with administrator access on your game host.</p><pre>{enrollment.command}</pre><a href={`/hosts/${enrollment.hostId}`}>Open host setup →</a></div>{/if} {#if enrollment}<div class="enrollment" role="status"><h3>Install the agent</h3><p>This single-use command expires {new Date(enrollment.expiresAt).toLocaleString()}. Run it with administrator access on your game host.</p><pre>{enrollment.command}</pre><a href={`/hosts/${enrollment.hostId}`}>Open host setup →</a></div>{/if}
</section>{:else}<p>Administrators manage host setup, runtime preparation, and credentials.</p>{/if} </section>{:else}<p>Administrators enroll hosts and manage their settings and credentials.</p>{/if}
{#if loading}<p role="status">Loading hosts…</p>{:else if !hosts.length}<p>No hosts connected yet.{canManage ? ' Add a host to begin.' : ''}</p>{:else} {#if loading}<p role="status">Loading hosts…</p>{:else if !hosts.length}<p>No hosts connected yet.{canManage ? ' Add a host to begin.' : ''}</p>{:else}
<div class="host-grid">{#each hosts as host (host.id)}<article class="panel host"><div class="row"><h2><a href={`/hosts/${host.id}`}>{host.name}</a></h2><span class:ready={host.readiness === 'ready'} class="state">{hostReadinessLabel(host.readiness)}</span></div><p>{host.hostname || 'Awaiting enrollment'} · {host.connectionState}</p><p>{host.inventory.os ?? 'OS pending'} · {host.inventory.architecture ?? 'Architecture pending'}</p><p>Game ports {host.portMin}–{host.portMax}</p>{#if host.enrolled}<p>Agent {host.inventory.agentVersion ?? 'unknown'} · Auto updates: {hostAgentUpdateLabel(host.inventory)}</p>{/if}{#if host.decommission}<p class="reason">{hostDeletionMessage(host)}</p>{#if host.decommission.error}<p class="ui-error">{host.decommission.error}</p>{/if}{:else if host.readinessReasons.length}<p class="reason">{host.readinessReasons[0]}</p>{/if}<div class="host-actions"><Button size="sm" href={`/hosts/${host.id}`}>{host.decommission ? 'View cleanup' : 'Manage host'}</Button>{#if host.readiness === 'needs-game-files'}<Button size="sm" href="/game-files">Choose game files</Button>{/if}{#if canManage && (!host.decommission || host.decommission.retryable)}<Button size="sm" variant="danger" onclick={() => { deletingHost = host; deleteOpen = true; }}>{host.decommission ? 'Retry cleanup' : 'Delete host'}</Button>{/if}</div></article>{/each}</div> <div class="host-grid">{#each hosts as host (host.id)}<article class="panel host"><div class="row"><h2><a href={`/hosts/${host.id}`}>{host.name}</a></h2><span class:ready={host.readiness === 'ready'} class="state">{hostReadinessLabel(host.readiness)}</span></div><p>{host.hostname || 'Awaiting enrollment'} · {host.connectionState}</p><p>{host.inventory.os ?? 'OS pending'} · {host.inventory.architecture ?? 'Architecture pending'}</p><p>Game ports {host.portMin}–{host.portMax}</p>{#if host.enrolled}<p>Agent {host.inventory.agentVersion ?? 'unknown'} · Auto updates: {hostAgentUpdateLabel(host.inventory)}</p>{/if}{#if host.decommission}<p class="reason">{hostDeletionMessage(host)}</p>{#if host.decommission.error}<p class="ui-error">{host.decommission.error}</p>{/if}{:else}{#if host.preparation}<p>Automatic setup: {host.preparation.state === 'preparing' ? 'In progress' : host.preparation.state === 'blocked' ? 'Needs attention' : host.preparation.state === 'ready' ? 'Ready' : 'Waiting'}</p>{#if host.preparation.state === 'blocked'}<p class="ui-error">{host.preparation.items.find(item => item.state === 'blocked')?.detail}</p>{/if}{/if}{#if host.readinessReasons.length}<p class="reason">{host.readinessReasons[0]}</p>{/if}{/if}<div class="host-actions"><Button size="sm" href={`/hosts/${host.id}`}>{host.decommission ? 'View cleanup' : 'Manage host'}</Button>{#if canManage && (!host.decommission || host.decommission.retryable)}<Button size="sm" variant="danger" onclick={() => { deletingHost = host; deleteOpen = true; }}>{host.decommission ? 'Retry cleanup' : 'Delete host'}</Button>{/if}</div></article>{/each}</div>
{/if} {/if}
</section> </section>
<DeleteHostDialog bind:open={deleteOpen} host={deletingHost} ondeleted={deleted} /> <DeleteHostDialog bind:open={deleteOpen} host={deletingHost} ondeleted={deleted} />
+7 -5
View File
@@ -7,6 +7,7 @@
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
import DeleteHostDialog from '$ui/components/DeleteHostDialog.svelte'; import DeleteHostDialog from '$ui/components/DeleteHostDialog.svelte';
import HostPreparation from '$ui/components/HostPreparation.svelte';
let host = $state<Host | null>(null); let operations = $state<HostOperation[]>([]); let host = $state<Host | null>(null); let operations = $state<HostOperation[]>([]);
let loading = $state(true); let busy = $state(false); let error = $state(''); let notice = $state(''); let loading = $state(true); let busy = $state(false); let error = $state(''); let notice = $state('');
let deleteOpen = $state(false); let deleteOpen = $state(false);
@@ -19,7 +20,7 @@
async function load(initialize = false) { async function load(initialize = false) {
try { try {
const response = await fetch(endpoint); const body = await response.json(); if (response.status === 404 && host?.decommission) { await goto('/hosts'); return; } if (!response.ok) throw new Error(body.error ?? body.message ?? 'Could not load host.'); host = body.host; operations = body.operations; const response = await fetch(endpoint); const body = await response.json(); if (response.status === 404 && host?.decommission) { await goto('/hosts'); return; } if (!response.ok) throw new Error(body.error ?? body.message ?? 'Could not load host.'); host = body.host; operations = body.operations;
if (initialize && host) { name = host.name; portMin = host.portMin; portMax = host.portMax; bindAddress = host.bindAddress; publicAddress = host.publicAddress; minimumFreeGB = host.minimumFreeBytes / 1073741824; } if (initialize && host) { name = host.name; portMin = host.portMin; portMax = host.portMax; bindAddress = host.bindAddress; publicAddress = host.publicAddress; minimumFreeGB = host.minimumFreeBytes / 1073741824; runtimeImage = body.defaultRuntimeImage ?? runtimeImage; }
} catch (cause) { error = (cause as Error).message; } finally { loading = false; } } catch (cause) { error = (cause as Error).message; } finally { loading = false; }
} }
async function action(path: string, method: string, body?: unknown, message?: string) { async function action(path: string, method: string, body?: unknown, message?: string) {
@@ -40,11 +41,11 @@
{#if loading}<p role="status">Loading host…</p>{:else if host} {#if loading}<p role="status">Loading host…</p>{:else if host}
{#if host.decommission}<section class="panel" aria-live="polite"><h2>Host cleanup</h2><p>{hostDeletionMessage(host)}</p>{#if host.decommission.error}<p class="ui-error">{host.decommission.error}</p>{/if}<p>Requested {new Date(host.decommission.requestedAt).toLocaleString()}. This page closes only after the cleanup worker confirms removal.</p>{#if host.decommission.stage === 'cleaned'}<p>The agent is expected to go offline during uninstall. If final confirmation never arrives, inspect the machine; loss of contact alone does not prove completion.</p>{/if}</section>{/if} {#if host.decommission}<section class="panel" aria-live="polite"><h2>Host cleanup</h2><p>{hostDeletionMessage(host)}</p>{#if host.decommission.error}<p class="ui-error">{host.decommission.error}</p>{/if}<p>Requested {new Date(host.decommission.requestedAt).toLocaleString()}. This page closes only after the cleanup worker confirms removal.</p>{#if host.decommission.stage === 'cleaned'}<p>The agent is expected to go offline during uninstall. If final confirmation never arrives, inspect the machine; loss of contact alone does not prove completion.</p>{/if}</section>{/if}
<section class="panel"><div class="row"><h2>{host.connectionState} · {hostReadinessLabel(host.readiness)}</h2><span>Last heartbeat: {host.lastHeartbeat ? new Date(host.lastHeartbeat).toLocaleString() : 'Never'}</span></div> <section class="panel"><div class="row"><h2>{host.connectionState} · {hostReadinessLabel(host.readiness)}</h2><span>Last heartbeat: {host.lastHeartbeat ? new Date(host.lastHeartbeat).toLocaleString() : 'Never'}</span></div>
{#if host.readinessReasons.length}<ul>{#each host.readinessReasons as reason}<li>{reason}</li>{/each}</ul>{:else}<p>Connected and ready. Each deployment also checks its game library, runtime, and ports.</p>{/if} {#if host.readinessReasons.length}<ul>{#each host.readinessReasons as reason}<li>{reason}</li>{/each}</ul>{:else}<p>Connected and ready. Each deployment also checks its latest game files, runtime, and ports.</p>{/if}
{#if host.readiness === 'needs-game-files'}<p><a href="/game-files">Choose a published game-file version and download it to this host →</a></p>{/if}
{#if canManage && !cleaning}<div class="actions"><Button disabled={busy || host.mode === 'active'} onclick={() => mode('active')}>Enable</Button><Button disabled={busy || host.mode === 'draining'} onclick={() => mode('draining')}>Drain host</Button><Button disabled={busy || host.mode === 'disabled'} onclick={() => mode('disabled')}>Disable host</Button></div>{/if} {#if canManage && !cleaning}<div class="actions"><Button disabled={busy || host.mode === 'active'} onclick={() => mode('active')}>Enable</Button><Button disabled={busy || host.mode === 'draining'} onclick={() => mode('draining')}>Drain host</Button><Button disabled={busy || host.mode === 'disabled'} onclick={() => mode('disabled')}>Disable host</Button></div>{/if}
<p>Draining prevents new deployments and retires instances as they become safe to remove. Disabled hosts receive no new commands; running game containers continue.</p> <p>Draining prevents new deployments and retires instances as they become safe to remove. Disabled hosts receive no new commands; running game containers continue.</p>
</section> </section>
{#if !cleaning}<section class="panel"><h2>Automatic host setup</h2><HostPreparation {host} /></section>{/if}
<div class="grid"><section class="panel"><h2>Inventory</h2><dl> <div class="grid"><section class="panel"><h2>Inventory</h2><dl>
<dt>Hostname</dt><dd>{host.hostname || 'Pending'}</dd><dt>Agent</dt><dd>{host.inventory.agentVersion ?? 'Unknown'}</dd><dt>Platform</dt><dd>{host.inventory.os ?? 'Unknown'} · {host.inventory.architecture ?? 'Unknown'}</dd><dt>CPU</dt><dd>{host.inventory.cpu ?? 'Unknown'} ({host.inventory.cpuCount ?? '?'} cores)</dd><dt>Memory available / total</dt><dd>{bytes(host.inventory.memoryAvailable)} / {bytes(host.inventory.memoryTotal)}</dd><dt>Game storage available / total</dt><dd>{bytes(host.inventory.storageAvailable)} / {bytes(host.inventory.storageTotal)}</dd><dt>Docker</dt><dd>{host.inventory.dockerVersion ?? 'Unknown'} · {host.inventory.dockerRunning ? 'Running' : 'Unavailable'}</dd><dt>Current operation</dt><dd>{host.inventory.currentOperation ?? 'None reported'}</dd> <dt>Hostname</dt><dd>{host.hostname || 'Pending'}</dd><dt>Agent</dt><dd>{host.inventory.agentVersion ?? 'Unknown'}</dd><dt>Platform</dt><dd>{host.inventory.os ?? 'Unknown'} · {host.inventory.architecture ?? 'Unknown'}</dd><dt>CPU</dt><dd>{host.inventory.cpu ?? 'Unknown'} ({host.inventory.cpuCount ?? '?'} cores)</dd><dt>Memory available / total</dt><dd>{bytes(host.inventory.memoryAvailable)} / {bytes(host.inventory.memoryTotal)}</dd><dt>Game storage available / total</dt><dd>{bytes(host.inventory.storageAvailable)} / {bytes(host.inventory.storageTotal)}</dd><dt>Docker</dt><dd>{host.inventory.dockerVersion ?? 'Unknown'} · {host.inventory.dockerRunning ? 'Running' : 'Unavailable'}</dd><dt>Current operation</dt><dd>{host.inventory.currentOperation ?? 'None reported'}</dd>
</dl></section><section class="panel"><h2>Host configuration</h2>{#if canManage}<form onsubmit={save}><fieldset disabled={busy || cleaning}> </dl></section><section class="panel"><h2>Host configuration</h2>{#if canManage}<form onsubmit={save}><fieldset disabled={busy || cleaning}>
@@ -57,8 +58,9 @@
{#if host.enrolled}<p>{hostAgentUpdateMessage(host.inventory)}</p>{:else}<p>The installer enables automatic updates when this host first enrolls.</p>{/if} {#if host.enrolled}<p>{hostAgentUpdateMessage(host.inventory)}</p>{:else}<p>The installer enables automatic updates when this host first enrolls.</p>{/if}
{#if host.enrolled && !hostAgentUpdatesEnabled(host.inventory)}<p>{canManage ? 'Generate an installer under Agent access below, run it on this host, and wait for a fresh heartbeat. The installer preserves the existing host identity and game data.' : 'An administrator can generate an installer under Agent access and run it on this host.'}</p>{:else if hostAgentUpdatesEnabled(host.inventory)}<p>The host checks this controller about every five minutes. Updates replace agent code between operations; running game containers continue.</p>{/if} {#if host.enrolled && !hostAgentUpdatesEnabled(host.inventory)}<p>{canManage ? 'Generate an installer under Agent access below, run it on this host, and wait for a fresh heartbeat. The installer preserves the existing host identity and game data.' : 'An administrator can generate an installer under Agent access and run it on this host.'}</p>{:else if hostAgentUpdatesEnabled(host.inventory)}<p>The host checks this controller about every five minutes. Updates replace agent code between operations; running game containers continue.</p>{/if}
</section> </section>
<div class="grid"><section class="panel"><h2>Game files and runtime</h2><p><a href="/game-files">Manage game-file libraries →</a></p>{#if host.inventory.libraries?.length}<ul>{#each host.inventory.libraries as library}<li>{library.id}: {library.state}{library.progress !== undefined ? ` (${library.progress}%)` : ''}</li>{/each}</ul>{:else}<p>No cached game libraries reported.</p>{/if} <div class="grid"><section class="panel"><h2>Cached game files and runtimes</h2>{#if host.inventory.libraries?.length}<ul>{#each host.inventory.libraries as library}<li>{library.id}: {library.state}{library.progress !== undefined ? ` (${library.progress}%)` : ''}</li>{/each}</ul>{:else}<p>No cached game libraries reported yet.</p>{/if}
{#if canManage}<form onsubmit={(event) => { event.preventDefault(); void action('/operations', 'POST', { kind: 'runtime.pull', image: runtimeImage }, 'Runtime download queued.'); }}><fieldset disabled={busy || cleaning}><label>Plutainer image<input required bind:value={runtimeImage} /></label><Button type="submit" disabled={busy || !host.connected}>Prepare runtime</Button></fieldset></form>{/if}<p>Cached images: {host.inventory.runtimeImages?.join(', ') || 'None reported'}</p> <p>Cached images: {host.inventory.runtimeImages?.join(', ') || 'None reported yet'}</p>
{#if canManage}<details><summary>Advanced: custom runtime or manual retry</summary><p>The configured default runtime is prepared automatically. Use this to prepare another image or retry a pull now.</p><form onsubmit={(event) => { event.preventDefault(); void action('/operations', 'POST', { kind: 'runtime.pull', image: runtimeImage }, 'Runtime download queued.'); }}><fieldset disabled={busy || cleaning}><label>Plutainer image<input required bind:value={runtimeImage} /></label><Button type="submit" disabled={busy || !host.connected}>Pull runtime image</Button></fieldset></form></details>{/if}
</section><section class="panel"><h2>Agent access</h2><p>Credentials: {host.credentialActive ? 'Active' : 'Not active'}. Enrollment links expire after 15 minutes and work once.</p>{#if canManage}<div class="actions"><Button disabled={busy || (cleaning && !host.decommission?.retryable)} onclick={() => action('/enrollment', 'POST')}>Generate installer</Button><Button disabled={busy || cleaning || !host.credentialActive || !host.connected} onclick={() => action('/credentials', 'POST', undefined, 'Credential rotation queued.')}>Rotate credential</Button><Button disabled={busy || cleaning || !host.credentialActive} onclick={() => action('/credentials', 'DELETE', undefined, 'Host credentials revoked. Generate a new installer to enroll again.')}>Revoke credential</Button></div> </section><section class="panel"><h2>Agent access</h2><p>Credentials: {host.credentialActive ? 'Active' : 'Not active'}. Enrollment links expire after 15 minutes and work once.</p>{#if canManage}<div class="actions"><Button disabled={busy || (cleaning && !host.decommission?.retryable)} onclick={() => action('/enrollment', 'POST')}>Generate installer</Button><Button disabled={busy || cleaning || !host.credentialActive || !host.connected} onclick={() => action('/credentials', 'POST', undefined, 'Credential rotation queued.')}>Rotate credential</Button><Button disabled={busy || cleaning || !host.credentialActive} onclick={() => action('/credentials', 'DELETE', undefined, 'Host credentials revoked. Generate a new installer to enroll again.')}>Revoke credential</Button></div>
{#if enrollment}<p>Expires {new Date(enrollment.expiresAt).toLocaleString()}</p><pre>{enrollment.command}</pre>{/if}{:else}<p>Administrators manage enrollment and agent credentials.</p>{/if} {#if enrollment}<p>Expires {new Date(enrollment.expiresAt).toLocaleString()}</p><pre>{enrollment.command}</pre>{/if}{:else}<p>Administrators manage enrollment and agent credentials.</p>{/if}
</section></div> </section></div>
+138 -43
View File
@@ -7,6 +7,7 @@ unknown and is reported for reconciliation, never automatically executed twice.
import argparse import argparse
import base64 import base64
from concurrent.futures import ThreadPoolExecutor, as_completed
import contextlib import contextlib
import hashlib import hashlib
import fcntl import fcntl
@@ -29,9 +30,10 @@ import urllib.parse
import urllib.request import urllib.request
import uuid import uuid
VERSION = "1.2.0" VERSION = "1.2.1"
LABEL = "io.electric-cherry" LABEL = "io.electric-cherry"
MAX_FILE = 32 * 1024 * 1024 MAX_FILE = 32 * 1024 * 1024
LIBRARY_DOWNLOAD_WORKERS = 4
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$") ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$")
AGENT_CONFIG = Path("/etc/electric-cherry/agent.json") AGENT_CONFIG = Path("/etc/electric-cherry/agent.json")
AGENT_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-agent.py") AGENT_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-agent.py")
@@ -788,13 +790,46 @@ class Agent:
pass pass
libraries, caches = [], [] libraries, caches = [], []
for marker in (self.root / "libraries").glob("*/*/manifest.json"): for marker in (self.root / "libraries").glob("*/*/manifest.json"):
# A crash between writing a manifest and its atomic promotion must
# never expose the staging directory as a ready library.
if marker.parent.name.startswith("."):
continue
try: try:
value = json.loads(marker.read_text()) value = json.loads(marker.read_text())
libraries.append({"id": value["libraryId"], "state": "ready"}) libraries.append({"id": value["libraryId"], "state": "ready"})
caches.append({"libraryVersionId": value["libraryId"], "status": "ready", "bytesDownloaded": value["size"], "bytesTotal": value["size"], "manifestSha256": value.get("manifestSha256")}) caches.append({"libraryVersionId": value["libraryId"], "status": "ready", "bytesDownloaded": value["size"], "bytesTotal": value["size"], "manifestSha256": value.get("manifestSha256")})
except (OSError, ValueError, KeyError): except (OSError, ValueError, KeyError):
continue continue
progress = list(self.library_progress.values()) ready = {item["id"] for item in libraries}
progress = {key: value.copy() for key, value in list(self.library_progress.items()) if key not in ready}
for marker in (self.root / "libraries").glob("*/.*.partial/source.json"):
try:
value = json.loads(marker.read_text())
library_id = identifier(value["libraryId"])
if library_id in ready or library_id in progress:
continue
expected = self.library_dir(value["game"], library_id).with_name("." + library_id + ".partial")
if marker.parent != expected or marker.is_symlink() or marker.parent.is_symlink():
continue
files = value["files"]
canonical = json.dumps([{ "path": item["path"], "size": item["size"], "sha256": item["sha256"] } for item in files], separators=(",", ":"), ensure_ascii=False)
if digest(canonical.encode("utf-8")) != value["manifestSha256"]:
continue
retained, size, paths = 0, 0, set()
for item in files:
if item["path"] in paths or type(item["size"]) is not int or item["size"] < 0:
raise AgentError("Invalid partial library manifest")
paths.add(item["path"])
target = scoped(marker.parent / "content", item["path"])
size += item["size"]
if target.exists() and stat_module.S_ISREG(target.stat().st_mode):
retained += min(target.stat().st_size, item["size"])
# These are resumable bytes, not verified content. Reporting
# them survives restarts without requiring space for two copies.
progress[library_id] = {"libraryVersionId": library_id, "status": "missing", "bytesDownloaded": retained, "bytesTotal": size, "manifestSha256": value["manifestSha256"]}
except (AgentError, OSError, ValueError, KeyError, TypeError):
continue
progress = list(progress.values())
libraries.extend({"id": item["libraryVersionId"], "state": item["status"]} for item in progress) libraries.extend({"id": item["libraryVersionId"], "state": item["status"]} for item in progress)
caches.extend(progress) caches.extend(progress)
images = [] images = []
@@ -1195,6 +1230,65 @@ class Agent:
raise AgentError("Artifact redirects are not allowed") raise AgentError("Artifact redirects are not allowed")
return urllib.request.build_opener(NoRedirect()).open(req, timeout=60) return urllib.request.build_opener(NoRedirect()).open(req, timeout=60)
def download_library_file(self, item, target, files_url, update_progress, aborted):
def check_active():
if aborted.is_set():
raise AgentError("Library download stopped after a transfer failed")
check_active()
target.parent.mkdir(parents=True, exist_ok=True)
offset = target.stat().st_size if target.exists() else 0
if offset > item["size"]:
target.unlink()
offset = 0
checksum = hashlib.sha256()
# Hash the saved prefix once so resumed transfers still verify the whole
# file. New bytes are hashed as they arrive, avoiding a second disk read.
if offset:
with target.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
check_active()
checksum.update(chunk)
if offset == item["size"] and target.exists():
if checksum.hexdigest() == item["sha256"]:
update_progress(item["path"], offset)
return
target.unlink()
offset = 0
checksum = hashlib.sha256()
update_progress(item["path"], offset)
check_active()
url = files_url.rstrip("/") + "/" + urllib.parse.quote(item["path"], safe="/")
with self.artifact_open("GET", url, extra_headers={"Range": f"bytes={offset}-"} if offset else {}) as response:
if response.status == 206:
expected = f"bytes {offset}-{item['size'] - 1}/{item['size']}"
if response.headers.get("Content-Range") != expected:
raise AgentError("Invalid resumed download response")
elif response.status != 200:
raise AgentError("Invalid library download response")
elif offset:
# A server may ignore Range and send the whole file instead.
offset = 0
checksum = hashlib.sha256()
update_progress(item["path"], 0)
with target.open("ab" if offset else "wb") as output:
received = offset
while True:
check_active()
chunk = response.read(1024 * 1024)
if not chunk:
break
received += len(chunk)
if received > item["size"]:
raise AgentError("Library download exceeded its manifest size")
output.write(chunk)
checksum.update(chunk)
update_progress(item["path"], received)
output.flush()
os.fsync(output.fileno())
if target.stat().st_size != item["size"] or checksum.hexdigest() != item["sha256"]:
raise AgentError("Downloaded library file failed integrity verification")
def library_download(self, payload): def library_download(self, payload):
library_id = identifier(payload.get("libraryVersionId", payload.get("libraryId"))) library_id = identifier(payload.get("libraryVersionId", payload.get("libraryId")))
with self.artifact_open("GET", payload["manifestUrl"]) as response: with self.artifact_open("GET", payload["manifestUrl"]) as response:
@@ -1221,55 +1315,46 @@ class Agent:
source_marker = staging / "source.json" source_marker = staging / "source.json"
if source_marker.exists() and json.loads(source_marker.read_text()).get("manifestSha256") != manifest_sha: if source_marker.exists() and json.loads(source_marker.read_text()).get("manifestSha256") != manifest_sha:
raise Rejected("Partial library download belongs to a different manifest") raise Rejected("Partial library download belongs to a different manifest")
atomic_json(source_marker, {"libraryId": library_id, "manifestSha256": manifest_sha})
size = sum(item["size"] for item in files) size = sum(item["size"] for item in files)
downloaded = 0 atomic_json(source_marker, {"libraryId": library_id, "game": manifest["game"], "size": size, "files": files, "manifestSha256": manifest_sha})
paths = set() targets = {}
progress = {"libraryVersionId": library_id, "status": "downloading", "bytesDownloaded": 0, "bytesTotal": size, "manifestSha256": manifest_sha} progress = {"libraryVersionId": library_id, "status": "downloading", "bytesDownloaded": 0, "bytesTotal": size, "manifestSha256": manifest_sha}
self.library_progress[library_id] = progress self.library_progress[library_id] = progress
try: try:
if shutil.disk_usage(staging).free < max(0, size - sum(path.stat().st_size for path in staging.rglob("*") if path.is_file())): if shutil.disk_usage(staging).free < max(0, size - sum(path.stat().st_size for path in staging.rglob("*") if path.is_file())):
raise AgentError("Insufficient library storage") raise AgentError("Insufficient library storage")
# Validate every path before starting concurrent writes. In addition
# to duplicates, a manifest cannot treat a file as a parent folder.
for item in files: for item in files:
if item["path"] in paths or not isinstance(item["size"], int) or item["size"] < 0 or not re.fullmatch("[0-9a-f]{64}", item["sha256"]): if item["path"] in targets or not isinstance(item["size"], int) or item["size"] < 0 or not re.fullmatch("[0-9a-f]{64}", item["sha256"]):
raise AgentError("Invalid library file manifest") raise AgentError("Invalid library file manifest")
paths.add(item["path"]) targets[item["path"]] = scoped(staging / "content", item["path"])
target = scoped(staging / "content", item["path"]) for path in targets:
target.parent.mkdir(parents=True, exist_ok=True) if any(str(parent) in targets for parent in PurePosixPath(path).parents):
offset = target.stat().st_size if target.exists() else 0 raise AgentError("Library file paths overlap")
if offset > item["size"]: progress_lock = threading.Lock()
target.unlink() offsets = {}
offset = 0 aborted = threading.Event()
if offset == item["size"] and target.exists() and file_digest(target) != item["sha256"]:
target.unlink() def update_progress(path, offset):
offset = 0 with progress_lock:
if offset < item["size"] or not target.exists(): progress["bytesDownloaded"] += offset - offsets.get(path, 0)
url = payload["filesBaseUrl"].rstrip("/") + "/" + urllib.parse.quote(item["path"], safe="/") offsets[path] = offset
with self.artifact_open("GET", url, extra_headers={"Range": f"bytes={offset}-"} if offset else {}) as response:
if offset and response.status == 206: def transfer(item):
content_range = response.headers.get("Content-Range", "") try:
if not content_range.startswith(f"bytes {offset}-"): self.download_library_file(item, targets[item["path"]], payload["filesBaseUrl"], update_progress, aborted)
raise AgentError("Invalid resumed download response") except Exception:
elif offset: aborted.set()
offset = 0 raise
with target.open("ab" if offset else "wb") as output:
received = offset # Bound sockets, memory and disk pressure while allowing other files
while True: # to progress during network round trips and per-file fsyncs.
chunk = response.read(1024 * 1024) with ThreadPoolExecutor(max_workers=min(LIBRARY_DOWNLOAD_WORKERS, len(files))) as pool:
if not chunk: futures = [pool.submit(transfer, item) for item in files]
break for future in as_completed(futures):
received += len(chunk) future.result()
if received > item["size"]: progress["status"] = "verifying"
raise AgentError("Library download exceeded its manifest size")
output.write(chunk)
progress["bytesDownloaded"] = downloaded + received
output.flush()
os.fsync(output.fileno())
progress["status"] = "verifying"
if target.stat().st_size != item["size"] or file_digest(target) != item["sha256"]:
raise AgentError("Downloaded library file failed integrity verification")
downloaded += item["size"]
progress.update(status="downloading", bytesDownloaded=downloaded)
saved = {"libraryId": library_id, "game": manifest["game"], "size": size, "files": files, "manifestSha256": manifest_sha} saved = {"libraryId": library_id, "game": manifest["game"], "size": size, "files": files, "manifestSha256": manifest_sha}
atomic_json(staging / "manifest.json", saved) atomic_json(staging / "manifest.json", saved)
for path in (staging / "content").rglob("*"): for path in (staging / "content").rglob("*"):
@@ -1446,6 +1531,16 @@ class Agent:
if not self.config.get("pendingCredential") or self.recover_credential(): if not self.config.get("pendingCredential") or self.recover_credential():
return self.journal.update(operation["id"], "completed", {"rotated": True, "reconciled": True}) return self.journal.update(operation["id"], "completed", {"rotated": True, "reconciled": True})
return row return row
if kind == "runtime.pull":
# Docker resumes already downloaded layers. Pull again under
# the same operation ID: an existing tag can still refer to an
# older image and cannot prove an interrupted pull completed.
self.journal.update(operation["id"], "running")
try:
result = self.dispatch(operation)
return self.journal.update(operation["id"], "completed", {**result, "reconciled": True})
except Exception:
return self.journal.update(operation["id"], "failed", error="Interrupted runtime download could not be resumed")
if kind == "library.import": if kind == "library.import":
try: try:
with self.artifact_open("GET", payload["completeUrl"]) as response: with self.artifact_open("GET", payload["completeUrl"]) as response:
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"version": "1.2.0", "version": "1.2.1",
"protocolVersion": 1, "protocolVersion": 1,
"platform": "debian-amd64", "platform": "debian-amd64",
"sha256": "c0c08bc50daf09f4e6e141d4119cee68973b47c29845b2f3620e4579d9cd7daa", "sha256": "680c0c2bd6d703d3b0119ff5b253f1f6b70d1eeee98fe0e6770738eab2b6d380",
"updaterProtocolVersion": 1, "updaterProtocolVersion": 1,
"updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8" "updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8"
} }
+46 -9
View File
@@ -9,7 +9,7 @@ const state = vi.hoisted(() => ({ handle: null as Database.Database | null }));
vi.mock('../../src/lib/server/db/index.js', () => ({ db: () => state.handle })); vi.mock('../../src/lib/server/db/index.js', () => ({ db: () => state.handle }));
import { import {
browserUploadStatus, createLibraryImport, createLibraryUpload, getHostLibraryCache, browserUploadStatus, createLibraryImport, createLibraryUpload, getHostLibraryCache,
getLibraryVersion, listLibraries, publishBrowserUpload, queueLibraryDownload, getLibraryVersion, latestPublishedLibraries, listLibraries, newestReadyLibrary, publishBrowserUpload, queueLibraryDownload,
requestBrowserPublication, requireReadyLibrary, updateHostLibraryCaches, uploadBrowserFile requestBrowserPublication, requireReadyLibrary, updateHostLibraryCaches, uploadBrowserFile
} from '../../src/lib/server/game-files/index.js'; } from '../../src/lib/server/game-files/index.js';
import { createEnrollment, createHost, enrollHost, listHosts } from '../../src/lib/server/hosts/index.js'; import { createEnrollment, createHost, enrollHost, listHosts } from '../../src/lib/server/hosts/index.js';
@@ -24,8 +24,7 @@ const sha = (value: Uint8Array) => createHash('sha256').update(value).digest('he
const file = (path = 'zone/base.ff', content = bytes) => ({ path, size: content.length, lastModified: modified }); const file = (path = 'zone/base.ff', content = bytes) => ({ path, size: content.length, lastModified: modified });
const upload = (id: string, path: string, content: Buffer, offset = 0, checksum = sha(content)) => const upload = (id: string, path: string, content: Buffer, offset = 0, checksum = sha(content)) =>
uploadBrowserFile(id, path, { offset, size: content.length, sha256: checksum }, new Response(new Uint8Array(content)).body); uploadBrowserFile(id, path, { offset, size: content.length, sha256: checksum }, new Response(new Uint8Array(content)).body);
const begin = (game: 't5' | 't6' = 't6', files = [file()], version = '1') => const begin = (game: 't5' | 't6' = 't6', files = [file()]) => createLibraryUpload({ game, files });
createLibraryUpload({ game, name: `Plutonium ${game.toUpperCase()}`, version, description: 'Uploaded game folder', files });
beforeEach(async () => { beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'ec-browser-library-')); directory = await mkdtemp(join(tmpdir(), 'ec-browser-library-'));
@@ -37,6 +36,7 @@ afterEach(async () => {
state.handle?.close(); state.handle = null; state.handle?.close(); state.handle = null;
await rm(directory, { recursive: true, force: true }); await rm(directory, { recursive: true, force: true });
vi.unstubAllEnvs(); vi.unstubAllEnvs();
vi.useRealTimers();
}); });
function host() { function host() {
@@ -65,7 +65,7 @@ describe('browser folder uploads', () => {
expect(ready.manifestSha256).toBe(manifestChecksum(ready.files)); expect(ready.manifestSha256).toBe(manifestChecksum(ready.files));
expect(await readFile(join(ready.storageLocation, file().path))).toEqual(bytes); expect(await readFile(join(ready.storageLocation, file().path))).toEqual(bytes);
expect(await browserUploadStatus(initial.id)).toMatchObject({ status: 'published' }); expect(await browserUploadStatus(initial.id)).toMatchObject({ status: 'published' });
expect(listLibraries()[0].versions[0]).not.toHaveProperty('dlc'); expect(listLibraries().find(library => library.game === game)?.versions[0]).not.toHaveProperty('dlc');
expect(listHosts()).toEqual([]); expect(listHosts()).toEqual([]);
}); });
@@ -122,7 +122,7 @@ describe('browser folder uploads', () => {
const chunkSize = (await browserUploadStatus(sizeProbe.id)).chunkSize; const chunkSize = (await browserUploadStatus(sizeProbe.id)).chunkSize;
expect(chunkSize).toBeGreaterThan(0); expect(chunkSize).toBeGreaterThan(0);
const large = Buffer.alloc(chunkSize + 17, 0x41); const large = Buffer.alloc(chunkSize + 17, 0x41);
const initial = begin('t6', [file('main/archive.iwd', large)], 'large'); const initial = begin('t6', [file('main/archive.iwd', large)]);
const first = large.subarray(0, chunkSize); const first = large.subarray(0, chunkSize);
await upload(initial.id, 'main/archive.iwd', first); await upload(initial.id, 'main/archive.iwd', first);
expect((await browserUploadStatus(initial.id)).files[0]).toMatchObject({ receivedBytes: chunkSize, complete: false }); expect((await browserUploadStatus(initial.id)).files[0]).toMatchObject({ receivedBytes: chunkSize, complete: false });
@@ -146,17 +146,54 @@ describe('browser folder uploads', () => {
expect((await browserUploadStatus(initial.id)).files[0].receivedBytes).toBe(0); expect((await browserUploadStatus(initial.id)).files[0].receivedBytes).toBe(0);
}); });
it('keeps ready bytes and version metadata immutable and requires a new version label', async () => { it('keeps published files immutable while accepting repeated uploads without labels', async () => {
const initial = begin(); await upload(initial.id, file().path, bytes); const initial = begin(); await upload(initial.id, file().path, bytes);
const ready = await publishBrowserUpload(initial.id); const ready = await publishBrowserUpload(initial.id);
await expect(upload(initial.id, file().path, bytes)).rejects.toThrow(); await expect(upload(initial.id, file().path, bytes)).rejects.toThrow();
expect(() => state.handle!.prepare('UPDATE game_file_versions SET description=? WHERE id=?').run('changed', ready.id)).toThrow(/immutable/); expect(() => state.handle!.prepare('UPDATE game_file_versions SET description=? WHERE id=?').run('changed', ready.id)).toThrow(/immutable/);
expect(() => begin()).toThrow(/already exists/);
expect((await publishBrowserUpload(initial.id)).manifestSha256).toBe(ready.manifestSha256); expect((await publishBrowserUpload(initial.id)).manifestSha256).toBe(ready.manifestSha256);
expect(begin('t6', [file()], '2').id).not.toBe(ready.id); const next = begin();
expect(next.id).not.toBe(ready.id);
expect(next.version).not.toBe(ready.version);
expect(next.libraryId).toBe(ready.libraryId);
expect(await readFile(join(ready.storageLocation, file().path))).toEqual(bytes); expect(await readFile(join(ready.storageLocation, file().path))).toEqual(bytes);
}); });
it('ignores legacy names, descriptions, and labels when accepting new game files', () => {
const legacyFields = { name: 'same name', version: 'same version', description: 'a library description' };
const first = createLibraryUpload({ game: 't5', files: [file()], ...legacyFields });
const next = createLibraryUpload({ game: 't5', files: [file()], ...legacyFields });
const otherGame = createLibraryUpload({ game: 't6', files: [file()], ...legacyFields });
expect(first.description).toBe('');
expect(first.version).not.toBe(legacyFields.version);
expect(next.version).not.toBe(first.version);
expect(next.libraryId).toBe(first.libraryId);
expect(otherGame.libraryId).not.toBe(first.libraryId);
expect(listLibraries().map(library => library.name)).toEqual(['Black Ops I (T5)', 'Black Ops II (T6)']);
});
it('follows completion order and preserves the latest successful files during failed or unfinished uploads', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
const slow = begin();
vi.setSystemTime(new Date('2026-01-02T00:00:00Z'));
const fast = begin();
await upload(fast.id, file().path, bytes);
vi.setSystemTime(new Date('2026-01-03T00:00:00Z'));
await publishBrowserUpload(fast.id);
expect(newestReadyLibrary('t6')?.id).toBe(fast.id);
await upload(slow.id, file().path, bytes);
vi.setSystemTime(new Date('2026-01-04T00:00:00Z'));
await publishBrowserUpload(slow.id);
const failed = begin();
await requestBrowserPublication(failed.id);
await vi.waitFor(() => expect(getLibraryVersion(failed.id)?.state).toBe('failed'));
begin();
expect(newestReadyLibrary('t6')?.id).toBe(slow.id);
expect(latestPublishedLibraries().map(version => version.id)).toEqual([slow.id]);
expect(listLibraries().find(library => library.game === 't6')?.latestVersionId).toBe(slow.id);
});
it('recovers publication after the verified directory was renamed but the ready commit was interrupted', async () => { it('recovers publication after the verified directory was renamed but the ready commit was interrupted', async () => {
const initial = begin(); await upload(initial.id, file().path, bytes); const initial = begin(); await upload(initial.id, file().path, bytes);
const files = [{ path: file().path, size: bytes.length, sha256: sha(bytes) }]; const files = [{ path: file().path, size: bytes.length, sha256: sha(bytes) }];
@@ -220,7 +257,7 @@ describe('browser folder uploads', () => {
expect(getHostLibraryCache(target.id, ready.id)?.status).toBe('failed'); expect(getHostLibraryCache(target.id, ready.id)?.status).toBe('failed');
updateHostLibraryCaches(target.id, [{ libraryVersionId: ready.id, status: 'ready', manifestSha256: ready.manifestSha256 }]); updateHostLibraryCaches(target.id, [{ libraryVersionId: ready.id, status: 'ready', manifestSha256: ready.manifestSha256 }]);
expect(requireReadyLibrary(target.id, ready.id).id).toBe(ready.id); expect(requireReadyLibrary(target.id, ready.id).id).toBe(ready.id);
const replacement = begin('t5', [file()], '2'); await upload(replacement.id, file().path, bytes); const replacement = begin('t5'); await upload(replacement.id, file().path, bytes);
await publishBrowserUpload(replacement.id); await publishBrowserUpload(replacement.id);
expect(requireReadyLibrary(target.id, ready.id).manifestSha256).toBe(ready.manifestSha256); expect(requireReadyLibrary(target.id, ready.id).manifestSha256).toBe(ready.manifestSha256);
}); });
+82 -5
View File
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const state = vi.hoisted(() => ({ handle: null as Database.Database | null })); const state = vi.hoisted(() => ({ handle: null as Database.Database | null }));
vi.mock('../../src/lib/server/db/index.js', () => ({ db: () => state.handle })); vi.mock('../../src/lib/server/db/index.js', () => ({ db: () => state.handle }));
import { createEnrollment, createHost, enrollHost, getHostOperation, reportHostOperation, revokeCredentials } from '../../src/lib/server/hosts/index.js'; import { createEnrollment, createHost, enrollHost, getHostOperation, reportHostOperation, revokeCredentials } from '../../src/lib/server/hosts/index.js';
import { createLibraryImport, getHostLibraryCache, getLibraryVersion, importPublicationStatus, listLibraries, publishImport, queueLibraryDownload, requestImportPublication, requireReadyLibrary, setImportManifest, updateHostLibraryCaches, uploadImportFile } from '../../src/lib/server/game-files/index.js'; import { createLibraryImport, getHostLibraryCache, getLibraryVersion, importPublicationStatus, latestPublishedLibraries, libraryOverview, listLibraries, newestReadyLibrary, publishImport, queueLibraryDownload, requestImportPublication, requireReadyLibrary, setImportManifest, updateHostLibraryCaches, uploadImportFile } from '../../src/lib/server/game-files/index.js';
import { serveGameFile } from '../../src/lib/server/game-files/http.js'; import { serveGameFile } from '../../src/lib/server/game-files/http.js';
import { GET as manifestRoute } from '../../src/routes/api/agent/libraries/[id]/manifest/+server.js'; import { GET as manifestRoute } from '../../src/routes/api/agent/libraries/[id]/manifest/+server.js';
@@ -22,12 +22,87 @@ beforeEach(async () => {
credential = enrollHost(enrollment.token, { protocolVersion: 1, dockerRunning: true, directoriesWritable: true, storageAvailable: 1_000_000_000_000 }).credential; credential = enrollHost(enrollment.token, { protocolVersion: 1, dockerRunning: true, directoriesWritable: true, storageAvailable: 1_000_000_000_000 }).credential;
}); });
afterEach(async () => { state.handle?.close(); await rm(directory, { recursive: true, force: true }); vi.unstubAllEnvs(); }); afterEach(async () => { state.handle?.close(); await rm(directory, { recursive: true, force: true }); vi.unstubAllEnvs(); });
function begin(version = '1') { return createLibraryImport({ hostId, game: 't6', name: 'Black Ops II', version, description: 'Complete files', dlc: ['Origins'] }); } function begin() { return createLibraryImport({ hostId, game: 't6' }); }
async function published() { async function published() {
const version = begin(); setImportManifest(hostId, version.id, [file]); const version = begin(); setImportManifest(hostId, version.id, [file]);
await uploadImportFile(hostId, version.id, file.path, new Response(data).body); await uploadImportFile(hostId, version.id, file.path, new Response(data).body);
return publishImport(hostId, version.id); return publishImport(hostId, version.id);
} }
function legacyLibrary(id: string, game: 't5' | 't6') {
state.handle!.prepare('INSERT INTO game_file_libraries(id,name,game,description,created_at) VALUES(?,?,?,?,?)')
.run(id, `User named ${id}`, game, 'Original description', '2025-01-01T00:00:00.000Z');
}
function legacyVersion(id: string, libraryId: string, createdAt: string, publishedAt: string) {
state.handle!.prepare(`INSERT INTO game_file_versions(id,library_id,version,description,dlc,state,created_at,published_at,storage_location,source_host_id,source_volume,manifest_sha256)
VALUES(?,?,?,'Legacy version','[]','ready',?,?,?,'','','checksum')`)
.run(id, libraryId, `User label ${id}`, createdAt, publishedAt, join(directory, id));
}
describe('fixed game file slots', () => {
it('always lists exactly T5 and T6, including before any files are uploaded', () => {
expect(listLibraries()).toEqual([
{ id: 't5', game: 't5', name: 'Black Ops I (T5)', description: '', createdAt: '', latestVersionId: null, versions: [] },
{ id: 't6', game: 't6', name: 'Black Ops II (T6)', description: '', createdAt: '', latestVersionId: null, versions: [] }
]);
expect(latestPublishedLibraries()).toEqual([]);
expect(newestReadyLibrary('t5')).toBeNull();
expect(newestReadyLibrary('t6')).toBeNull();
});
it('unifies old named libraries by game without rewriting published versions or their host references', () => {
listLibraries();
legacyLibrary('first-t6-library', 't6');
legacyLibrary('other-t6-library', 't6');
legacyLibrary('t5-library', 't5');
legacyVersion('old-t6', 'first-t6-library', '2025-01-03', '2025-01-04');
legacyVersion('new-t6', 'other-t6-library', '2025-01-01', '2025-01-05');
legacyVersion('t5-files', 't5-library', '2025-01-01', '2025-01-02');
updateHostLibraryCaches(hostId, [
{ libraryVersionId: 'old-t6', status: 'ready', manifestSha256: 'checksum' },
{ libraryVersionId: 'new-t6', status: 'ready', manifestSha256: 'checksum' }
]);
const before = getLibraryVersion('old-t6');
const slots = listLibraries();
expect(slots.map(library => library.id)).toEqual(['t5', 't6']);
expect(slots[1].versions.map(version => version.id)).toEqual(['old-t6', 'new-t6']);
expect(slots.map(library => library.latestVersionId)).toEqual(['t5-files', 'new-t6']);
expect(latestPublishedLibraries().map(version => version.id)).toEqual(['t5-files', 'new-t6']);
expect(newestReadyLibrary('t6')?.id).toBe('new-t6');
const overview = libraryOverview().libraries[1];
expect(overview.versions.find(version => version.id === 'old-t6')?.caches[0].status).toBe('outdated');
expect(overview.versions.find(version => version.id === 'new-t6')?.caches[0].status).toBe('ready');
expect(getLibraryVersion('old-t6')).toEqual(before);
expect(requireReadyLibrary(hostId, 'old-t6')).toMatchObject({
libraryId: 'first-t6-library', version: 'User label old-t6', storageLocation: join(directory, 'old-t6')
});
expect(begin().libraryId).toBe('first-t6-library');
expect(listLibraries()[1].latestVersionId).toBe('new-t6');
});
it('breaks publication-time ties by creation time and then insertion order across old libraries', () => {
listLibraries();
legacyLibrary('first-library', 't6');
legacyLibrary('second-library', 't6');
legacyVersion('first', 'first-library', '2025-01-01', '2025-01-05');
legacyVersion('second', 'second-library', '2025-01-02', '2025-01-05');
legacyVersion('third', 'first-library', '2025-01-02', '2025-01-05');
legacyVersion('later-created', 'second-library', '2025-01-04', '2025-01-04');
expect(newestReadyLibrary('t6')?.id).toBe('third');
expect(latestPublishedLibraries().map(version => version.id)).toEqual(['third']);
expect(listLibraries()[1].latestVersionId).toBe('third');
});
it('ignores user-supplied library metadata when importing from hosts', () => {
const first = createLibraryImport({ hostId, game: 't6', name: 'shared', version: '1', description: 'Old label' });
const next = createLibraryImport({ hostId, game: 't6', name: 'shared', version: '1', description: 'Old label' });
expect(first.description).toBe('');
expect(first.version).not.toBe('1');
expect(next.version).not.toBe(first.version);
expect(first.libraryId).toBe(next.libraryId);
});
});
describe('game-file import lifecycle', () => { describe('game-file import lifecycle', () => {
it('publishes verified filesystem content and permanently pins manifest metadata', async () => { it('publishes verified filesystem content and permanently pins manifest metadata', async () => {
const initial = begin(); const initial = begin();
@@ -42,8 +117,10 @@ describe('game-file import lifecycle', () => {
expect((await publishImport(hostId, ready.id)).id).toBe(ready.id); expect((await publishImport(hostId, ready.id)).id).toBe(ready.id);
await expect(uploadImportFile(hostId, ready.id, file.path, new Response(data).body)).rejects.toThrow(/not writable/); await expect(uploadImportFile(hostId, ready.id, file.path, new Response(data).body)).rejects.toThrow(/not writable/);
expect(() => state.handle!.prepare('UPDATE game_file_versions SET description=? WHERE id=?').run('changed', ready.id)).toThrow(/immutable/); expect(() => state.handle!.prepare('UPDATE game_file_versions SET description=? WHERE id=?').run('changed', ready.id)).toThrow(/immutable/);
expect(() => begin()).toThrow(/already exists/); const next = begin();
expect(begin('2').id).not.toBe(ready.id); expect(next.id).not.toBe(ready.id);
expect(next.version).not.toBe(ready.version);
expect(next.libraryId).toBe(ready.libraryId);
}); });
it('restricts an import to its source host and rejects manifest mutation', () => { it('restricts an import to its source host and rejects manifest mutation', () => {
const version = begin(); const version = begin();
@@ -55,7 +132,7 @@ describe('game-file import lifecycle', () => {
it('surfaces durable agent failures while preserving historical versions', () => { it('surfaces durable agent failures while preserving historical versions', () => {
const version = begin(); const version = begin();
reportHostOperation(hostId, version.operationId!, { state: 'failed', error: 'Source volume not found' }); reportHostOperation(hostId, version.operationId!, { state: 'failed', error: 'Source volume not found' });
expect(listLibraries()[0].versions[0]).toMatchObject({ state: 'failed', error: 'Source volume not found' }); expect(listLibraries().find(library => library.game === 't6')?.versions[0]).toMatchObject({ state: 'failed', error: 'Source volume not found' });
}); });
it('returns a pollable publication status while large files are verified asynchronously', async () => { it('returns a pollable publication status while large files are verified asynchronously', async () => {
const version = begin(); setImportManifest(hostId, version.id, [file]); const version = begin(); setImportManifest(hostId, version.id, [file]);
+391
View File
@@ -0,0 +1,391 @@
import { createHash } from 'node:crypto';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { GameLibraryVersion } from '../../src/lib/game-files.js';
import type { HostInventory } from '../../src/lib/hosts.js';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
let hosts: typeof import('../../src/lib/server/hosts/index.js');
let libraries: typeof import('../../src/lib/server/game-files/index.js');
let preparation: typeof import('../../src/lib/server/hosts/preparation.js');
const runtime = 'ghcr.io/example/plutainer:provisioning-test';
const initialTime = Date.parse('2026-10-04T12:00:00Z');
const content = 'a'.repeat(100);
const inventory: HostInventory = {
hostname: 'game-host', protocolVersion: 1, dockerRunning: true, directoriesWritable: true,
storageAvailable: 100 * 1024 ** 3, runtimeImages: [], libraries: [], capabilities: ['host.decommission']
};
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture([]);
vi.stubEnv('PLUTAINER_IMAGE', runtime);
vi.stubEnv('GAME_FILES_ROOT', join(fixture.root, 'game-files'));
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(initialTime);
hosts = await import('../../src/lib/server/hosts/index.js');
libraries = await import('../../src/lib/server/game-files/index.js');
preparation = await import('../../src/lib/server/hosts/preparation.js');
});
afterEach(async () => {
vi.useRealTimers();
vi.unstubAllEnvs();
await fixture.cleanup();
});
function enroll(overrides: HostInventory = {}) {
const host = hosts.createHost({ name: 'Automatic game host' });
hosts.enrollHost(hosts.createEnrollment(host.id).token, { ...inventory, ...overrides });
return host.id;
}
function heartbeat(id: string, overrides: HostInventory = {}) {
vi.setSystemTime(Date.now() + 1);
return hosts.heartbeat(id, { ...inventory, ...overrides });
}
function operations(id: string) {
return hosts.listHostOperations(id).filter(operation => ['runtime.pull', 'library.download'].includes(operation.kind));
}
async function publish(game: 't5' | 't6' = 't5', bytes = content) {
vi.setSystemTime(Date.now() + 1);
const upload = libraries.createLibraryUpload({
game, files: [{ path: 'zone/game.ff', size: bytes.length, lastModified: 0 }]
});
await libraries.uploadBrowserFile(upload.id, 'zone/game.ff', {
offset: 0, size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex')
}, new Response(bytes).body);
return libraries.publishBrowserUpload(upload.id);
}
function verified(id: string, version: GameLibraryVersion) {
libraries.updateHostLibraryCaches(id, [{
libraryVersionId: version.id, status: 'ready', manifestSha256: version.manifestSha256
}]);
}
describe('automatic host preparation', () => {
it('prepares an enrolled host through authenticated agent polls and heartbeats without manual setup requests', async () => {
const [{ POST: enrollAgent }, { GET: pollOperations }, { POST: reportOperation }, { POST: reportHeartbeat }] = await Promise.all([
import('../../src/routes/api/agent/enroll/+server.js'),
import('../../src/routes/api/agent/operations/+server.js'),
import('../../src/routes/api/agent/operations/[id]/+server.js'),
import('../../src/routes/api/agent/heartbeat/+server.js')
]);
const host = hosts.createHost({ name: 'Agent provisioning integration' });
const enrollment = await enrollAgent({ request: new Request('https://controller.test/api/agent/enroll', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: hosts.createEnrollment(host.id).token, inventory })
}) });
expect(enrollment.status).toBe(200);
const { credential } = await enrollment.json();
const request = (path: string, body?: object) => new Request(`https://controller.test/api/agent/${path}`, {
method: body ? 'POST' : 'GET',
headers: { Authorization: `Bearer ${credential}`, 'Content-Type': 'application/json' },
...(body ? { body: JSON.stringify(body) } : {})
});
expect(operations(host.id)).toEqual([]);
const firstPoll = await pollOperations({ request: request('operations') }).json();
expect(firstPoll.operations).toHaveLength(1);
const runtimeOperation = firstPoll.operations[0];
expect(runtimeOperation).toMatchObject({ kind: 'runtime.pull', payload: { image: runtime } });
expect((await pollOperations({ request: request('operations') }).json()).operations).toEqual([runtimeOperation]);
const version = await publish();
expect((await pollOperations({ request: request('operations') }).json()).operations).toEqual([runtimeOperation]);
vi.setSystemTime(Date.now() + 1);
const completedRuntime = await reportOperation({
request: request(`operations/${runtimeOperation.id}`, { state: 'completed' }), params: { id: runtimeOperation.id }
});
expect(completedRuntime.status).toBe(200);
expect((await pollOperations({ request: request('operations') }).json()).operations).toEqual([]);
expect(operations(host.id)).toHaveLength(1);
vi.setSystemTime(Date.now() + 1);
const preparedInventory = { ...inventory, runtimeImages: [runtime], libraryCaches: [] };
const runtimeHeartbeat = await reportHeartbeat({ request: request('heartbeat', { inventory: preparedInventory }) });
expect(runtimeHeartbeat.status).toBe(200);
expect(operations(host.id)).toHaveLength(2);
const download = operations(host.id)[0];
expect(download).toMatchObject({ kind: 'library.download', payload: { libraryVersionId: version.id } });
expect((await reportHeartbeat({ request: request('heartbeat', { inventory: preparedInventory }) })).status).toBe(200);
expect((await pollOperations({ request: request('operations') }).json()).operations).toEqual([download]);
expect(operations(host.id)).toHaveLength(2);
vi.setSystemTime(Date.now() + 1);
expect((await reportOperation({
request: request(`operations/${download.id}`, { state: 'completed' }), params: { id: download.id }
})).status).toBe(200);
vi.setSystemTime(Date.now() + 1);
const finalHeartbeat = await reportHeartbeat({ request: request('heartbeat', { inventory: {
...preparedInventory,
libraries: [{ id: version.id, state: 'ready' }],
libraryCaches: [{ libraryVersionId: version.id, status: 'ready', manifestSha256: version.manifestSha256 }]
} }) });
expect(finalHeartbeat.status).toBe(200);
expect(libraries.requireReadyLibrary(host.id, version.id).id).toBe(version.id);
expect(preparation.hostPreparationStatus(hosts.getHost(host.id)!).state).toBe('ready');
expect((await pollOperations({ request: request('operations') }).json()).operations).toEqual([]);
expect(operations(host.id)).toHaveLength(2);
});
it('presents preparation without queuing work and prepares the configured runtime even when another image exists', async () => {
const version = await publish();
const id = enroll({ runtimeImages: ['another:runtime'] });
const host = hosts.getHost(id)!;
const status = preparation.hostPreparationStatus(host);
expect(status.items.map(item => item.kind)).toEqual(['runtime', 'library']);
expect(preparation.presentHostPreparation(host)).toMatchObject({ id, preparation: status });
expect(operations(id)).toEqual([]);
expect(preparation.reconcileHostPreparation(id).state).toBe('preparing');
const [operation] = operations(id);
expect(operation).toMatchObject({ kind: 'runtime.pull', payload: { image: runtime }, state: 'pending' });
expect(libraries.getHostLibraryCache(id, version.id)).toBeNull();
for (let poll = 0; poll < 4; poll++) {
heartbeat(id);
preparation.reconcileHostPreparation(id);
}
hosts.reportHostOperation(id, operation.id, { state: 'unknown' });
preparation.reconcileHostPreparation(id);
expect(operations(id).map(item => item.id)).toEqual([operation.id]);
});
it('waits for fresh inventory after runtime completion before starting a library download', async () => {
const version = await publish();
const id = enroll();
preparation.reconcileHostPreparation(id);
const runtimeOperation = operations(id)[0];
vi.setSystemTime(Date.now() + 1);
hosts.reportHostOperation(id, runtimeOperation.id, { state: 'completed', result: { image: runtime } });
preparation.reconcileHostPreparation(id);
expect(operations(id)).toHaveLength(1);
heartbeat(id, { runtimeImages: [runtime] });
preparation.reconcileHostPreparation(id);
expect(operations(id)).toHaveLength(2);
const download = operations(id)[0];
expect(download).toMatchObject({ kind: 'library.download', payload: { libraryVersionId: version.id } });
verified(id, version);
hosts.reportHostOperation(id, download.id, { state: 'completed' });
heartbeat(id, { runtimeImages: [runtime], libraries: [{ id: version.id, state: 'ready' }] });
expect(preparation.reconcileHostPreparation(id).state).toBe('ready');
expect(operations(id)).toHaveLength(2);
});
it('always targets the latest verified T5 and T6 upload while newer uploads remain unfinished', async () => {
const old = await publish();
const latest = await publish();
const t6 = await publish('t6');
libraries.createLibraryUpload({
game: 't5',
files: [{ path: 'zone/game.ff', size: content.length, lastModified: 0 }]
});
const id = enroll({ runtimeImages: [runtime] });
const expected = new Map([latest, t6].map(version => [version.id, version]));
const downloaded: string[] = [];
for (let index = 0; index < expected.size; index++) {
preparation.reconcileHostPreparation(id);
const active = operations(id).filter(operation => !['completed', 'failed'].includes(operation.state));
expect(active).toHaveLength(1);
const operation = active[0];
const versionId = String(operation.payload.libraryVersionId);
expect(expected.has(versionId)).toBe(true);
expect(downloaded).not.toContain(versionId);
downloaded.push(versionId);
verified(id, expected.get(versionId)!);
vi.setSystemTime(Date.now() + 1);
hosts.reportHostOperation(id, operation.id, { state: 'completed' });
preparation.reconcileHostPreparation(id);
expect(operations(id)).toHaveLength(index + 1);
heartbeat(id, { runtimeImages: [runtime], libraries: downloaded.map(id => ({ id, state: 'ready' })) });
}
expect(preparation.reconcileHostPreparation(id).state).toBe('ready');
expect(downloaded).toHaveLength(expected.size);
expect(downloaded).not.toContain(old.id);
});
it('picks up publication after enrollment and keeps verified historical versions intact', async () => {
const id = enroll({ runtimeImages: [runtime] });
preparation.reconcileHostPreparation(id);
expect(operations(id)).toEqual([]);
const old = await publish();
verified(id, old);
heartbeat(id, { runtimeImages: [runtime], libraries: [{ id: old.id, state: 'ready' }] });
expect(preparation.reconcileHostPreparation(id).state).toBe('ready');
const latest = await publish();
preparation.reconcileHostPreparation(id);
expect(operations(id)).toHaveLength(1);
expect(operations(id)[0].payload.libraryVersionId).toBe(latest.id);
expect(libraries.requireReadyLibrary(id, old.id).id).toBe(old.id);
expect(libraries.getHostLibraryCache(id, old.id)?.status).toBe('ready');
});
it('replaces an undelivered automatic download when newer files finish uploading', async () => {
const old = await publish();
const id = enroll({ runtimeImages: [runtime] });
preparation.reconcileHostPreparation(id);
const first = operations(id)[0];
expect(first.payload).toMatchObject({ libraryVersionId: old.id, automatic: true });
// The next heartbeat may discover a completed cache while this queued
// retry has still never been delivered. Keep that verified evidence.
verified(id, old);
const latest = await publish();
preparation.reconcileHostPreparation(id);
expect(hosts.getHostOperation(first.id)).toMatchObject({ state: 'failed', error: expect.stringContaining('Superseded') });
expect(operations(id).filter(operation => operation.state === 'pending')).toMatchObject([
{ payload: { libraryVersionId: latest.id, automatic: true } }
]);
expect(libraries.requireReadyLibrary(id, old.id).id).toBe(old.id);
});
it('finishes a delivered download before following a newer upload under a new operation ID', async () => {
const old = await publish();
const id = enroll({ runtimeImages: [runtime] });
preparation.reconcileHostPreparation(id);
const [first] = hosts.pendingHostOperations(id);
const latest = await publish();
preparation.reconcileHostPreparation(id);
expect(operations(id)).toHaveLength(1);
expect(hosts.getHostOperation(first.id)?.state).toBe('pending');
verified(id, old);
hosts.reportHostOperation(id, first.id, { state: 'completed' });
heartbeat(id, { runtimeImages: [runtime], libraries: [{ id: old.id, state: 'ready' }] });
preparation.reconcileHostPreparation(id);
expect(operations(id)[0]).toMatchObject({ state: 'pending', payload: { libraryVersionId: latest.id } });
expect(libraries.requireReadyLibrary(id, old.id).id).toBe(old.id);
});
it.each(['draining', 'disabled'] as const)('does not start setup for a %s host', mode => {
const id = enroll();
hosts.updateHost(id, { mode });
preparation.reconcileHostPreparation(id);
expect(operations(id)).toEqual([]);
});
it.each([
{ name: 'incompatible protocol', value: { protocolVersion: 2 } },
{ name: 'unavailable Docker', value: { dockerRunning: false } },
{ name: 'unwritable managed directories', value: { directoriesWritable: false } },
{ name: 'insufficient storage reserve', value: { storageAvailable: 0 } }
])('does not start setup with $name', ({ value }) => {
const id = enroll(value);
expect(preparation.reconcileHostPreparation(id).state).toBe('blocked');
expect(operations(id)).toEqual([]);
});
it('does not start setup for unenrolled, disconnected, revoked, or decommissioning hosts', () => {
const unenrolled = hosts.createHost({ name: 'Not enrolled' }).id;
preparation.reconcileHostPreparation(unenrolled);
expect(operations(unenrolled)).toEqual([]);
const offline = enroll();
vi.setSystemTime(Date.now() + 91_000);
preparation.reconcileHostPreparation(offline);
expect(operations(offline)).toEqual([]);
const revoked = enroll();
hosts.revokeCredentials(revoked);
preparation.reconcileHostPreparation(revoked);
expect(operations(revoked)).toEqual([]);
const deleting = enroll();
hosts.deleteHost(deleting);
preparation.reconcileHostPreparation(deleting);
expect(operations(deleting)).toEqual([]);
});
it('retries runtime failures with exponential delays capped at fifteen minutes', () => {
const id = enroll();
preparation.reconcileHostPreparation(id);
for (const [attempt, delay] of [60_000, 120_000, 240_000, 480_000, 900_000, 900_000].entries()) {
const operation = operations(id)[0];
vi.setSystemTime(Date.now() + 1);
hosts.reportHostOperation(id, operation.id, { state: 'failed', error: 'Registry unavailable' });
const failedAt = Date.now();
preparation.reconcileHostPreparation(id);
expect(operations(id)).toHaveLength(attempt + 1);
vi.setSystemTime(failedAt + delay - 2);
heartbeat(id);
preparation.reconcileHostPreparation(id);
expect(operations(id)).toHaveLength(attempt + 1);
vi.setSystemTime(failedAt + delay);
heartbeat(id);
preparation.reconcileHostPreparation(id);
expect(operations(id)).toHaveLength(attempt + 2);
expect(operations(id)[0]).toMatchObject({ kind: 'runtime.pull', state: 'pending' });
}
});
it('blocks a library that cannot fit while preserving the host free-space reserve', async () => {
const version = await publish();
const id = enroll({ runtimeImages: [runtime], storageAvailable: 1_000 + version.sizeBytes - 1 });
hosts.updateHost(id, { minimumFreeBytes: 1_000 });
expect(preparation.reconcileHostPreparation(id).state).toBe('blocked');
expect(operations(id)).toEqual([]);
heartbeat(id, { runtimeImages: [runtime], storageAvailable: 1_000 + version.sizeBytes });
expect(preparation.reconcileHostPreparation(id).state).toBe('preparing');
expect(operations(id)[0].payload.libraryVersionId).toBe(version.id);
});
it('returns an active download even when its in-progress bytes have consumed free space', async () => {
const version = await publish();
const id = enroll({ runtimeImages: [runtime] });
const operation = libraries.queueLibraryDownload(id, version.id);
heartbeat(id, { runtimeImages: [runtime], storageAvailable: hosts.getHost(id)!.minimumFreeBytes });
expect(libraries.queueLibraryDownload(id, version.id).id).toBe(operation.id);
preparation.reconcileHostPreparation(id);
expect(operations(id)).toHaveLength(1);
});
it('prepares T6 when the latest T5 upload cannot fit', async () => {
const oversized = await publish();
const small = await publish('t6', 'small');
const id = enroll({ runtimeImages: [runtime], storageAvailable: 1_010 });
hosts.updateHost(id, { minimumFreeBytes: 1_000 });
const status = preparation.reconcileHostPreparation(id);
expect(status.items).toEqual(expect.arrayContaining([
expect.objectContaining({ label: 'Black Ops I (T5)', state: 'blocked' }),
expect.objectContaining({ label: 'Black Ops II (T6)', state: 'preparing' })
]));
expect(operations(id)).toHaveLength(1);
expect(operations(id)[0].payload.libraryVersionId).toBe(small.id);
expect(libraries.getHostLibraryCache(id, oversized.id)).toBeNull();
});
it('does not count partial bytes from a different manifest toward download storage', async () => {
const version = await publish();
const id = enroll({ runtimeImages: [runtime] });
libraries.updateHostLibraryCaches(id, [{
libraryVersionId: version.id, status: 'failed', bytesDownloaded: 80, manifestSha256: '0'.repeat(64)
}]);
heartbeat(id, { runtimeImages: [runtime], storageAvailable: hosts.getHost(id)!.minimumFreeBytes + 20 });
expect(preparation.reconcileHostPreparation(id).state).toBe('blocked');
expect(() => libraries.queueLibraryDownload(id, version.id)).toThrow(/insufficient storage/);
expect(operations(id)).toEqual([]);
});
it('resumes a failed verified-manifest download using only its remaining storage', async () => {
const version = await publish();
const id = enroll({ runtimeImages: [runtime] });
preparation.reconcileHostPreparation(id);
const operation = operations(id)[0];
libraries.updateHostLibraryCaches(id, [{
libraryVersionId: version.id, status: 'downloading', bytesDownloaded: 80, manifestSha256: version.manifestSha256
}]);
vi.setSystemTime(Date.now() + 1);
hosts.reportHostOperation(id, operation.id, { state: 'failed', error: 'Connection reset' });
const failedAt = Date.now();
const free = hosts.getHost(id)!.minimumFreeBytes + version.sizeBytes - 80;
heartbeat(id, { runtimeImages: [runtime], storageAvailable: free });
preparation.reconcileHostPreparation(id);
expect(operations(id)).toHaveLength(1);
vi.setSystemTime(failedAt + 60_000);
heartbeat(id, { runtimeImages: [runtime], storageAvailable: free });
expect(preparation.reconcileHostPreparation(id).state).toBe('preparing');
expect(operations(id)).toHaveLength(2);
expect(operations(id)[0].payload.libraryVersionId).toBe(version.id);
expect(libraries.getHostLibraryCache(id, version.id)?.bytesDownloaded).toBe(80);
});
});
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import type { GameLibrary, GameLibraryGame, GameLibraryVersion } from '../../src/lib/game-files.js';
import { latestGameFiles, selectLatestGameFiles } from '../../src/lib/ui/native-game-files.js';
function upload(id: string, game: GameLibraryGame = 't5', state: GameLibraryVersion['state'] = 'ready'): GameLibraryVersion {
return {
id, libraryId: game, game, version: id, description: '', state,
sizeBytes: 10, fileCount: 1, manifestSha256: 'manifest', files: [],
createdAt: '2026-10-04T12:00:00Z', publishedAt: state === 'ready' ? '2026-10-04T12:05:00Z' : null,
storageLocation: '', sourceHostId: null, sourceVolume: null, error: null, operationId: null, caches: []
};
}
function slot(game: GameLibraryGame, latestVersionId: string | null, versions: GameLibraryVersion[]): GameLibrary {
return { id: game, name: game.toUpperCase(), game, description: '', createdAt: '', latestVersionId, versions };
}
describe('automatic game file selection for new launches', () => {
it('follows the controller latest upload even when older files are already cached', () => {
const old = upload('old');
old.caches = [{ hostId: 'host', libraryVersionId: old.id, status: 'ready', bytesDownloaded: 10,
bytesTotal: 10, manifestSha256: 'manifest', error: null, updatedAt: '', operationId: null }];
const current = upload('latest');
// The API can retain older uploads and unfinished uploads in its history.
const libraries = [slot('t5', current.id, [upload('unfinished', 't5', 'importing'), old, current])];
const selected = { t5: old.id };
const latest = latestGameFiles(libraries);
expect(latest.t5).toBe(current);
expect(selectLatestGameFiles(selected, latest, ['t5'])).toBe(true);
expect(selected.t5).toBe(current.id);
expect(selectLatestGameFiles(selected, latest, ['t5'])).toBe(false);
});
it('updates both games and removes a stale selection when a game has no available upload', () => {
const latest = latestGameFiles([slot('t5', null, [upload('unavailable')]), slot('t6', 'new-t6', [upload('new-t6', 't6')])]);
const selected: Partial<Record<GameLibraryGame, string>> = { t5: 'old-t5', t6: 'old-t6' };
expect(selectLatestGameFiles(selected, latest, ['t5', 't6'])).toBe(true);
expect(selected).toEqual({ t6: 'new-t6' });
});
it('never selects an unfinished or missing latest upload from history', () => {
expect(latestGameFiles([slot('t5', 'pending', [upload('old'), upload('pending', 't5', 'importing')])])).toEqual({});
expect(latestGameFiles([slot('t6', 'missing', [upload('old', 't6')])])).toEqual({});
});
it('only changes games needed by the current launch', () => {
const selected = { t5: 'old-t5', t6: 'keep-t6' };
const latest = latestGameFiles([slot('t5', 'new-t5', [upload('new-t5')])]);
expect(selectLatestGameFiles(selected, latest, ['t5'])).toBe(true);
expect(selected).toEqual({ t5: 'new-t5', t6: 'keep-t6' });
});
});
+34 -1
View File
@@ -33,7 +33,7 @@ afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); });
async function prepareLibrary(game: 't5' | 't6' = 't5') { async function prepareLibrary(game: 't5' | 't6' = 't5') {
const libs = await import('../../src/lib/server/game-files/index.js'); const libs = await import('../../src/lib/server/game-files/index.js');
const version = libs.createLibraryImport({ hostId, game, name: game, version: '1' }); const version = libs.createLibraryImport({ hostId, game });
const bytes = Buffer.from('verified base game'); const bytes = Buffer.from('verified base game');
const file = { path: 'zone/base.ff', size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') }; const file = { path: 'zone/base.ff', size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') };
libs.setImportManifest(hostId, version.id, [file]); libs.setImportManifest(hostId, version.id, [file]);
@@ -73,6 +73,39 @@ async function detail(id: string) {
} }
describe('native instance provisioning', () => { describe('native instance provisioning', () => {
it('selects the latest game files automatically and rejects stale explicit selections', async () => {
const { nativeSelection } = await import('../../src/lib/server/provisioning/native.js');
expect(() => nativeSelection({ hostId, image }, 't5')).toThrow('Upload T5 game files');
const old = await prepareLibrary('t5');
expect(nativeSelection({ hostId, image }, 't5').libraryId).toBe(old.id);
const latest = await prepareLibrary('t5');
expect(nativeSelection({ hostId, image }, 't5').libraryId).toBe(latest.id);
expect(() => nativeSelection({ hostId, image, libraryId: old.id }, 't5')).toThrow('Refresh the launch preview');
});
it('rejects a launch preview made before newer game files were published without reserving a server', async () => {
await prepareLibrary('t5');
const { previewNativeCreation, queueNativeCreation } = await import('../../src/lib/server/provisioning/native.js');
const preview = previewNativeCreation({ hostId, image, game: 't5', mapSlug: 'theater' });
await prepareLibrary('t5');
const result = queueNativeCreation({ game: 't5', gameMode: 'zombie', mapSlug: 'theater', previewId: preview.id, plutoKey: 'new-key' });
expect(result).toMatchObject({ ok: false, message: expect.stringContaining('Refresh the launch preview') });
const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare('SELECT COUNT(*) AS count FROM native_instances').get()).toEqual({ count: 0 });
expect(db().prepare('SELECT COUNT(*) AS count FROM ec_host_ports').get()).toEqual({ count: 0 });
});
it('keeps a queued server pinned to its accepted files when newer game files arrive', async () => {
const job = await queue();
const acceptedId = libraryId;
const latest = await prepareLibrary('t5');
expect(latest.id).not.toBe(acceptedId);
await advance();
expect((await operationFor(job.deploymentId))?.payload.libraryId).toBe(acceptedId);
const { nativeInstance } = await import('../../src/lib/server/backends/native.js');
expect(nativeInstance(job.name)?.library_id).toBe(acceptedId);
});
it.each(['t5', 't6'] as const)('queues and verifies a %s deployment without Komodo', async game => { it.each(['t5', 't6'] as const)('queues and verifies a %s deployment without Komodo', async game => {
const job = await queue(game); await advance(); const job = await queue(game); await advance();
const operation = await operationFor(job.deploymentId); const operation = await operationFor(job.deploymentId);