Cache Plutonium runtime files on hosts

This commit is contained in:
2026-10-07 15:17:59 -04:00
parent 7fd59d32b4
commit 062ade8655
13 changed files with 1418 additions and 45 deletions
+262 -4
View File
@@ -33,7 +33,7 @@ import urllib.parse
import urllib.request import urllib.request
import uuid import uuid
VERSION = "1.7.0" VERSION = "1.8.0"
LABEL = "io.electric-cherry" LABEL = "io.electric-cherry"
MAX_FILE = 32 * 1024 * 1024 MAX_FILE = 32 * 1024 * 1024
MANIFEST_MAX_ENTRIES = 10000 MANIFEST_MAX_ENTRIES = 10000
@@ -42,6 +42,10 @@ MANIFEST_MAX_SECONDS = 15
MANIFEST_MAX_DEPTH = 64 MANIFEST_MAX_DEPTH = 64
LIBRARY_DOWNLOAD_WORKERS = 4 LIBRARY_DOWNLOAD_WORKERS = 4
HEALTH_LOCK_TIMEOUT_SECONDS = 5 HEALTH_LOCK_TIMEOUT_SECONDS = 5
PLUTONIUM_MANIFEST_URL = "https://cdn.plutoniummod.com/updater/prod/info.json"
PLUTONIUM_MANIFEST_LIMIT = 2 * 1024 * 1024
PLUTONIUM_FILE_LIMIT = 512 * 1024 * 1024
PLUTONIUM_CACHE_LIMIT = 4 * 1024 * 1024 * 1024
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")
@@ -487,6 +491,34 @@ def managed_write(root, value, content, uid=1000, gid=1000):
os.unlink(temp_name, dir_fd=parent) os.unlink(temp_name, dir_fd=parent)
def managed_copy(root, value, source, uid=1000, gid=1000):
"""Copy a private file, using Linux reflinks when supported, never hardlinks."""
with managed_open(Path(source).parent, Path(source).name) as incoming:
with managed_parent(root, value, create=True, uid=uid, gid=gid) as (parent, name):
temp_name = ".ec-" + uuid.uuid4().hex
fd = os.open(temp_name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=parent)
try:
os.fchmod(fd, 0o644)
if os.geteuid() == 0:
os.fchown(fd, uid, gid)
try:
if not sys.platform.startswith("linux"):
raise OSError("Reflinks unavailable")
fcntl.ioctl(fd, 0x40049409, incoming.fileno()) # FICLONE
except OSError:
os.ftruncate(fd, 0)
with os.fdopen(fd, "wb", closefd=False) as outgoing:
shutil.copyfileobj(incoming, outgoing, 1024 * 1024)
outgoing.flush()
os.fsync(fd)
os.replace(temp_name, name, src_dir_fd=parent, dst_dir_fd=parent)
os.fsync(parent)
finally:
os.close(fd)
with contextlib.suppress(FileNotFoundError):
os.unlink(temp_name, dir_fd=parent)
class ReadWriteGate: class ReadWriteGate:
"""Let observations run together; global mutations wait for existing readers.""" """Let observations run together; global mutations wait for existing readers."""
def __init__(self): def __init__(self):
@@ -921,11 +953,13 @@ class Agent:
self.read_claims = set() self.read_claims = set()
self.health_probe_gate = threading.BoundedSemaphore(2) self.health_probe_gate = threading.BoundedSemaphore(2)
self.cancelled_creations = frozenset() self.cancelled_creations = frozenset()
self.defer_plutonium_preparation = False
self.observation_keep_days = 0 # Older controllers do not authorize cleanup. self.observation_keep_days = 0 # Older controllers do not authorize cleanup.
self.library_progress = {} self.library_progress = {}
self.library_transfers = {} self.library_transfers = {}
self.inventory_manifest_cache = {} self.inventory_manifest_cache = {}
self.inventory_manifest_lock = threading.Lock() self.inventory_manifest_lock = threading.Lock()
self.plutonium_lock = threading.RLock()
self.config_path = config.get("configPath", "/etc/electric-cherry/agent.json") self.config_path = config.get("configPath", "/etc/electric-cherry/agent.json")
self.executing_id = None self.executing_id = None
self.lane_lock = threading.Lock() self.lane_lock = threading.Lock()
@@ -1360,8 +1394,9 @@ class Agent:
if line.startswith("PRETTY_NAME="): if line.startswith("PRETTY_NAME="):
os_name = line.split("=", 1)[1].strip('"') os_name = line.split("=", 1)[1].strip('"')
update = self.update_inventory() update = self.update_inventory()
capabilities = ["library.remove", "instance.cancel-create", "instance.safety-override", "instance.autoscaling-remove", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest", "files.blobs"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else []) capabilities = ["library.remove", "plutonium.cache", "instance.cancel-create", "instance.safety-override", "instance.autoscaling-remove", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest", "files.blobs"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "agentLanes": self.lane_inventory(), "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, **resources, **({"cpuUsagePercent": cpu_usage} if cpu_usage is not None else {}), "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})} plutonium_cache = self.plutonium_cache_inventory()
return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "agentLanes": self.lane_inventory(), "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, **resources, **({"cpuUsagePercent": cpu_usage} if cpu_usage is not None else {}), "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), **({"plutoniumCache": plutonium_cache} if plutonium_cache else {}), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})}
def safe_to_disrupt(self, instance_id, generation): def safe_to_disrupt(self, instance_id, generation):
health = self.inspect(instance_id, generation) health = self.inspect(instance_id, generation)
@@ -1510,6 +1545,223 @@ class Agent:
raise Rejected("Initial files exceed the 64 MiB operation limit") raise Rejected("Initial files exceed the 64 MiB operation limit")
return game, library, image, port, bind, env return game, library, image, port, bind, env
@staticmethod
def plutonium_manifest(data):
"""Validate the official updater format before using any paths or URLs."""
if len(data) > PLUTONIUM_MANIFEST_LIMIT:
raise AgentError("Plutonium manifest is too large")
try:
manifest = json.loads(data)
except (ValueError, UnicodeError):
raise AgentError("Invalid Plutonium manifest") from None
if not isinstance(manifest, dict) or manifest.get("product") != "plutonium-core-prod":
raise AgentError("Invalid Plutonium production manifest")
revision = manifest.get("revision")
if type(revision) is not int or not 1 <= revision <= 2147483647:
raise AgentError("Invalid Plutonium revision")
base = manifest.get("baseUrl")
if not isinstance(base, str) or not re.fullmatch(r"https://cdn\.plutoniummod\.com/updater/prod/(?:[A-Za-z0-9_-]+/)*", base):
raise AgentError("Plutonium downloads must use the official production CDN")
entries = manifest.get("files")
if not isinstance(entries, list) or not 1 <= len(entries) <= 4096:
raise AgentError("Invalid Plutonium file list")
names, total = set(), 0
for entry in entries:
if not isinstance(entry, dict):
raise AgentError("Invalid Plutonium file entry")
name, size, checksum = entry.get("name"), entry.get("size"), entry.get("hash")
if not isinstance(name, str) or len(name) > 512 or re.search(r'[\x00-\x1f\x7f\\:*?"<>|]', name):
raise AgentError("Invalid Plutonium file path")
path = PurePosixPath(name)
if not path.parts or path.is_absolute() or str(path) != name or any(part in (".", "..") or part.endswith((".", " ")) for part in path.parts) or name.lower() == "cdn_info.json":
raise AgentError("Invalid Plutonium file path")
if name.lower() in names:
raise AgentError("Duplicate Plutonium file path")
names.add(name.lower())
if type(size) is not int or not 0 <= size <= PLUTONIUM_FILE_LIMIT:
raise AgentError("Invalid Plutonium file size")
if not isinstance(checksum, str) or not re.fullmatch(r"[a-f0-9]{40}", checksum):
raise AgentError("Invalid Plutonium file hash")
total += size
if total > PLUTONIUM_CACHE_LIMIT:
raise AgentError("Plutonium cache exceeds the size limit")
for name in names:
if any(str(parent) in names for parent in PurePosixPath(name).parents if str(parent) != "."):
raise AgentError("Conflicting Plutonium file paths")
if "bin/plutonium-bootstrapper-win32.exe" not in {entry["name"] for entry in entries}:
raise AgentError("Plutonium manifest is missing the server bootstrapper")
# Match plutonium-updater's default: launcher assets are optional.
return manifest, [entry for entry in entries if not entry["name"].startswith("launcher")]
@staticmethod
def plutonium_file_matches(root, entry):
try:
with managed_open(root, entry["name"]) as stream:
if os.fstat(stream.fileno()).st_size != entry["size"]:
return False
checksum = hashlib.sha1()
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
checksum.update(chunk)
return checksum.hexdigest() == entry["hash"]
except (OSError, AgentError):
return False
def plutonium_open(self, url):
if not isinstance(url, str) or not re.fullmatch(r"https://cdn\.plutoniummod\.com/updater/prod/[A-Za-z0-9_./-]+", url):
raise AgentError("Plutonium downloads must use the official production CDN")
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, hdrs, newurl):
raise AgentError("Plutonium download redirects are not allowed")
# Never send the controller's host credentials to the public CDN.
return urllib.request.build_opener(NoRedirect()).open(url, timeout=30)
def plutonium_snapshot(self):
root = scoped(self.root / "agent", "plutonium-cache")
pointer = json.loads(managed_read(root, "current.json") or b"null")
if not isinstance(pointer, dict) or not isinstance(pointer.get("key"), str) or not re.fullmatch(r"[a-f0-9]{64}", pointer["key"]):
raise AgentError("Plutonium cache is not prepared")
content = scoped(root, pointer["key"] + "/content")
raw = managed_read(content, "cdn_info.json")
if raw is None or digest(raw) != pointer["key"]:
raise AgentError("Plutonium cache manifest is invalid")
manifest, files = self.plutonium_manifest(raw)
if pointer.get("revision") != manifest["revision"] or pointer.get("bytes") != sum(entry["size"] for entry in files):
raise AgentError("Plutonium cache metadata is invalid")
# Heartbeat checks are cheap. Every file is hashed before it is seeded.
for entry in files:
info = scoped(content, entry["name"]).lstat()
if not stat_module.S_ISREG(info.st_mode) or info.st_size != entry["size"]:
raise AgentError("Plutonium cache files are incomplete")
return pointer, content, raw, files
def plutonium_cache_inventory(self):
with self.plutonium_lock:
try:
pointer, _, _, _ = self.plutonium_snapshot()
return {"revision": pointer["revision"], "bytes": pointer["bytes"]}
except (OSError, ValueError, AgentError):
return None
def prepare_plutonium(self, payload):
target, reserve = payload.get("revision", 0), payload.get("minimumFreeBytes", 0)
if type(target) is not int or not 0 <= target <= 2147483647 or type(reserve) is not int or reserve < 0:
raise Rejected("Invalid Plutonium preparation request")
root = scoped(self.root / "agent", "plutonium-cache")
root.mkdir(mode=0o700, exist_ok=True)
os.chmod(root, 0o700)
deadline = time.monotonic() + 1800
def active():
if self.stopping.is_set() or time.monotonic() >= deadline:
raise AgentError("Plutonium cache preparation was interrupted or timed out")
if self.defer_plutonium_preparation:
raise Held("Plutonium cache preparation is waiting for other host operations")
active()
try:
with self.plutonium_open(PLUTONIUM_MANIFEST_URL) as response:
raw = response.read(PLUTONIUM_MANIFEST_LIMIT + 1)
manifest, files = self.plutonium_manifest(raw)
if manifest["revision"] < target:
raise AgentError("Plutonium CDN has not reached the requested revision")
key = digest(raw)
previous = None
with self.plutonium_lock:
with contextlib.suppress(OSError, ValueError, AgentError):
_, previous, _, _ = self.plutonium_snapshot()
if previous is not None and previous.parent.name == key and all(self.plutonium_file_matches(previous, entry) for entry in files):
return {"revision": manifest["revision"], "bytes": sum(entry["size"] for entry in files)}
staging = scoped(root, key + ".partial/content")
staging.mkdir(parents=True, exist_ok=True)
expected = {entry["name"] for entry in files}
# SIGKILL/power loss bypasses download/copy finally blocks. Remove
# abandoned temporary files before counting space or publishing.
for directory, subdirs, filenames in os.walk(staging, followlinks=False):
for name in subdirs + filenames:
path = Path(directory) / name
if path.is_symlink() or (name in filenames and path.relative_to(staging).as_posix() not in expected):
path.unlink()
# Keep at most the current snapshot and this resumable preparation.
current_key = previous.parent.name if previous is not None else None
for directory in root.iterdir():
if re.fullmatch(r"[a-f0-9]{64}(?:\.partial)?", directory.name) and directory.name not in (current_key, key + ".partial"):
if directory.is_symlink():
directory.unlink()
else:
shutil.rmtree(directory)
missing = sum(entry["size"] for entry in files if not self.plutonium_file_matches(staging, entry))
if shutil.disk_usage(root).free < reserve + missing + len(raw):
raise AgentError("Insufficient free storage for the Plutonium cache and host reserve")
for entry in files:
active()
if self.plutonium_file_matches(staging, entry):
continue
if previous is not None and self.plutonium_file_matches(previous, entry):
managed_copy(staging, entry["name"], scoped(previous, entry["name"]), os.geteuid(), os.getegid())
continue
destination = scoped(staging, entry["name"])
destination.parent.mkdir(parents=True, exist_ok=True)
temporary = destination.with_name(".download-" + uuid.uuid4().hex)
try:
with self.plutonium_open(manifest["baseUrl"] + entry["hash"]) as incoming, temporary.open("xb") as outgoing:
size, checksum = 0, hashlib.sha1()
while True:
active()
chunk = incoming.read(min(1024 * 1024, entry["size"] - size + 1))
if not chunk:
break
size += len(chunk)
if size > entry["size"]:
raise AgentError("Plutonium download exceeds its expected size")
checksum.update(chunk)
outgoing.write(chunk)
if size != entry["size"] or checksum.hexdigest() != entry["hash"]:
raise AgentError("Plutonium download failed size or checksum verification")
outgoing.flush()
os.fsync(outgoing.fileno())
os.replace(temporary, destination)
sync_parent(destination)
finally:
with contextlib.suppress(FileNotFoundError):
temporary.unlink()
# Publish metadata last. Interrupted transfers can never claim ready.
active()
atomic_bytes(staging / "cdn_info.json", raw)
pointer = {"key": key, "revision": manifest["revision"], "bytes": sum(entry["size"] for entry in files)}
with self.plutonium_lock:
final = scoped(root, key)
if final.exists():
# Invalidate before replacing a damaged/same-revision cache.
if current_key == key:
(root / "current.json").unlink(missing_ok=True)
shutil.rmtree(final)
os.replace(staging.parent, final)
sync_parent(final)
atomic_json(root / "current.json", pointer)
if previous is not None and current_key != key:
shutil.rmtree(previous.parent)
return {"revision": pointer["revision"], "bytes": pointer["bytes"]}
except (urllib.error.URLError, TimeoutError, http.client.HTTPException):
raise AgentError("Plutonium CDN download is unavailable; the previous cache is retained") from None
def seed_plutonium(self, app, image):
if not re.fullmatch(r"ghcr\.io/ayymoss/plutainer(?::[A-Za-z0-9_.-]+)?(?:@sha256:[a-f0-9]{64})?", image):
return False
with self.plutonium_lock:
try:
_, content, raw, files = self.plutonium_snapshot()
if not all(self.plutonium_file_matches(content, entry) for entry in files):
scoped(self.root / "agent", "plutonium-cache/current.json").unlink(missing_ok=True)
raise AgentError("Plutonium cache checksum verification failed")
except (OSError, ValueError, AgentError):
return False
uid, gid = int(self.config.get("runtimeUid", 1000)), int(self.config.get("runtimeGid", 1000))
marker = "runtime/plutonium/cdn_info.json"
managed_write(app, marker, None, uid, gid)
for entry in files:
self.check_creation_cancellation()
managed_copy(app, "runtime/plutonium/" + entry["name"], scoped(content, entry["name"]), uid, gid)
managed_write(app, marker, raw, uid, gid)
return True
def create(self, instance_id, generation, payload): def create(self, instance_id, generation, payload):
self.check_creation_cancellation() self.check_creation_cancellation()
directory = self.instance_dir(instance_id, generation) directory = self.instance_dir(instance_id, generation)
@@ -1527,6 +1779,7 @@ class Agent:
app = directory / "app" app = directory / "app"
app.mkdir(parents=True, exist_ok=True) app.mkdir(parents=True, exist_ok=True)
self.make_writable(app, directory=True) self.make_writable(app, directory=True)
self.seed_plutonium(app, image)
self.write_initial_files(app, payload.get("files", [])) self.write_initial_files(app, payload.get("files", []))
atomic_json(directory / "spec.json", payload) atomic_json(directory / "spec.json", payload)
env_path = directory / "environment" env_path = directory / "environment"
@@ -2184,6 +2437,8 @@ class Agent:
return {"image": image, "ready": True} return {"image": image, "ready": True}
if kind == "host.inspect": if kind == "host.inspect":
return self.inventory() return self.inventory()
if kind == "plutonium.prepare":
return self.prepare_plutonium(payload)
if kind == "rcon.status-batch": if kind == "rcon.status-batch":
return self.rcon_status_batch(operation) return self.rcon_status_batch(operation)
if kind == "library.download": if kind == "library.download":
@@ -2290,7 +2545,7 @@ 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": if kind in ("runtime.pull", "plutonium.prepare"):
# Docker resumes already downloaded layers. Pull again under # Docker resumes already downloaded layers. Pull again under
# the same operation ID: an existing tag can still refer to an # the same operation ID: an existing tag can still refer to an
# older image and cannot prove an interrupted pull completed. # older image and cannot prove an interrupted pull completed.
@@ -2298,6 +2553,8 @@ class Agent:
try: try:
result = self.dispatch(operation) result = self.dispatch(operation)
return self.journal.update(operation["id"], "completed", {**result, "reconciled": True}) return self.journal.update(operation["id"], "completed", {**result, "reconciled": True})
except Held as err:
return self.journal.update(operation["id"], "accepted", {"blocked": "host-busy", "message": str(err)})
except Exception: except Exception:
return self.journal.update(operation["id"], "failed", error="Interrupted runtime download could not be resumed") return self.journal.update(operation["id"], "failed", error="Interrupted runtime download could not be resumed")
if kind == "library.import": if kind == "library.import":
@@ -2407,6 +2664,7 @@ class Agent:
# Only the heartbeat thread replaces this immutable snapshot; it never # Only the heartbeat thread replaces this immutable snapshot; it never
# mutates the operation journal or races Docker cleanup against create. # mutates the operation journal or races Docker cleanup against create.
self.cancelled_creations = frozenset(value for value in response.get("cancelledCreations", []) if isinstance(value, str)) self.cancelled_creations = frozenset(value for value in response.get("cancelledCreations", []) if isinstance(value, str))
self.defer_plutonium_preparation = response.get("deferPlutoniumPreparation") is True
keep_days = response.get("observationKeepDays") keep_days = response.get("observationKeepDays")
self.observation_keep_days = keep_days if valid_observation_keep_days(keep_days) else 0 self.observation_keep_days = keep_days if valid_observation_keep_days(keep_days) else 0
host = response.get("host", {}) host = response.get("host", {})
+506
View File
@@ -0,0 +1,506 @@
import base64
import copy
import hashlib
import json
from pathlib import Path
import unittest
from unittest.mock import patch
import urllib.error
import test_agent as fixtures
module = fixtures.module
AgentError = fixtures.AgentError
IMAGE = "ghcr.io/ayymoss/plutainer:latest"
BOOTSTRAPPER = "bin/plutonium-bootstrapper-win32.exe"
BASE_URL = "https://cdn.plutoniummod.com/updater/prod/files/"
class PlutoniumCacheTests(unittest.TestCase):
tearDown = fixtures.AgentTests.tearDown
operation = fixtures.AgentTests.operation
create = fixtures.AgentTests.create
def setUp(self):
fixtures.AgentTests.setUp(self)
network = patch.object(self.agent, "plutonium_open", side_effect=AssertionError("Unexpected vendor request"), create=True)
network.start()
self.addCleanup(network.stop)
def release(self, revision=5354, contents=None):
contents = contents or {
BOOTSTRAPPER: b"bootstrapper",
"bin/plutonium.dll": b"runtime",
"storage/t5/zone/plutonium.ff": b"zone-data",
"storage/iw5/images/~weapon_ak47_iw5_spc-rgb&weap~fa48e615.iwi": b"texture-data",
"launcher/plutonium.exe": b"launcher-not-needed",
}
manifest = {
"product": "plutonium-core-prod",
"revision": revision,
"baseUrl": BASE_URL,
"files": [
{"name": name, "size": len(content), "hash": hashlib.sha1(content).hexdigest()}
for name, content in contents.items()
],
}
return manifest, contents
def opener(self, manifest, contents, calls=None):
calls = calls if calls is not None else []
def open_url(url):
calls.append(url)
if url == module.PLUTONIUM_MANIFEST_URL:
return fixtures.Response(json.dumps(manifest).encode())
self.assertTrue(url.startswith(BASE_URL), url)
name = next(entry["name"] for entry in manifest["files"] if url == BASE_URL + entry["hash"])
return fixtures.Response(contents[name])
return open_url
def file_url(self, content):
return BASE_URL + hashlib.sha1(content).hexdigest()
def prepare(self, revision=5354, contents=None):
manifest, contents = self.release(revision, contents)
calls = []
with patch.object(self.agent, "plutonium_open", side_effect=self.opener(manifest, contents, calls)):
result = self.agent.prepare_plutonium({"revision": revision})
return manifest, contents, calls, result
def cache_content(self):
root = self.agent.root / "agent" / "plutonium-cache"
pointer = json.loads((root / "current.json").read_text())
return root / pointer["key"] / "content"
def app(self, name="app"):
app = Path(self.temp.name) / name
app.mkdir(exist_ok=True)
return app
def assert_runtime(self, app, manifest, contents):
runtime = app / "runtime" / "plutonium"
self.assertEqual(json.loads((runtime / "cdn_info.json").read_text()), manifest)
for name, content in contents.items():
if name.startswith("launcher"):
self.assertFalse((runtime / name).exists())
else:
self.assertEqual((runtime / name).read_bytes(), content)
def test_cold_preparation_verifies_downloads_and_reports_inventory(self):
self.assertIsNone(self.agent.plutonium_cache_inventory())
manifest, contents, calls, result = self.prepare()
expected_bytes = sum(len(content) for name, content in contents.items() if not name.startswith("launcher"))
self.assertEqual(result, {"revision": 5354, "bytes": expected_bytes})
self.assertEqual(self.agent.plutonium_cache_inventory(), result)
self.assertEqual(calls[0], module.PLUTONIUM_MANIFEST_URL)
self.assertEqual(set(calls[1:]), {self.file_url(content) for name, content in contents.items() if not name.startswith("launcher")})
self.assertEqual(json.loads((self.cache_content() / "cdn_info.json").read_text()), manifest)
self.assertEqual((self.agent.root / "agent" / "plutonium-cache").stat().st_mode & 0o077, 0)
def test_warm_preparation_only_fetches_manifest_and_dispatches(self):
manifest, contents, _, first = self.prepare()
calls = []
with patch.object(self.agent, "plutonium_open", side_effect=self.opener(manifest, contents, calls)):
result = self.agent.dispatch({"kind": "plutonium.prepare", "payload": {"revision": 5354}})
self.assertEqual(result, first)
self.assertEqual(calls, [module.PLUTONIUM_MANIFEST_URL])
def test_refresh_downloads_changed_files_and_reuses_verified_unchanged_files(self):
_, contents, _, _ = self.prepare()
changed = {**contents, "bin/plutonium.dll": b"new-runtime", "storage/t6/new.ff": b"new-zone"}
manifest, changed, calls, result = self.prepare(5355, changed)
self.assertEqual(result["revision"], 5355)
self.assertEqual(set(calls[1:]), {self.file_url(changed["bin/plutonium.dll"]), self.file_url(changed["storage/t6/new.ff"])})
app = self.app()
self.assertTrue(self.agent.seed_plutonium(app, IMAGE))
self.assert_runtime(app, manifest, changed)
def test_changed_manifest_is_refreshed_even_when_revision_number_is_unchanged(self):
_, contents, _, _ = self.prepare()
previous = self.cache_content()
manifest, changed, calls, result = self.prepare(5354, {**contents, "bin/plutonium.dll": b"corrected-runtime"})
self.assertEqual(result["revision"], 5354)
self.assertNotEqual(self.cache_content(), previous)
self.assertEqual(calls[1:], [self.file_url(changed["bin/plutonium.dll"])])
app = self.app()
self.assertTrue(self.agent.seed_plutonium(app, IMAGE))
self.assert_runtime(app, manifest, changed)
def test_failed_refresh_preserves_the_older_complete_cache(self):
manifest, contents, _, result = self.prepare()
previous = self.cache_content()
next_manifest, next_contents = self.release(5355, {**contents, "bin/plutonium.dll": b"new-runtime"})
next_contents["bin/plutonium.dll"] = b"bad-runtime"
with patch.object(self.agent, "plutonium_open", side_effect=self.opener(next_manifest, next_contents)):
with self.assertRaises(AgentError):
self.agent.prepare_plutonium({"revision": 5355})
self.assertEqual(self.cache_content(), previous)
self.assertEqual(self.agent.plutonium_cache_inventory(), result)
app = self.app()
self.assertTrue(self.agent.seed_plutonium(app, IMAGE))
self.assert_runtime(app, manifest, contents)
def test_interrupted_preparation_resumes_without_advertising_a_partial_cache(self):
manifest, contents = self.release()
open_url = self.opener(manifest, contents)
def interrupted_open(url):
if url == self.file_url(contents["bin/plutonium.dll"]):
raise urllib.error.URLError("simulated network interruption")
return open_url(url)
with patch.object(self.agent, "plutonium_open", side_effect=interrupted_open):
with self.assertRaises(AgentError):
self.agent.prepare_plutonium({})
self.assertIsNone(self.agent.plutonium_cache_inventory())
self.assertFalse((self.agent.root / "agent/plutonium-cache/current.json").exists())
_, _, calls, result = self.prepare()
self.assertNotIn(self.file_url(contents[BOOTSTRAPPER]), calls)
self.assertEqual(result["revision"], manifest["revision"])
def test_agent_restart_reconciles_preparation_instead_of_accepting_an_older_cache(self):
_, contents, _, _ = self.prepare()
operation = {"id": "prepare-new-release", "kind": "plutonium.prepare", "payload": {"revision": 5355}}
self.agent.journal.accept(operation)
self.agent.journal.update(operation["id"], "running")
self.agent.journal.close()
self.agent = fixtures.Agent(self.config, self.docker)
self.assertEqual(self.agent.journal.get(operation["id"])["state"], "unknown")
manifest, contents = self.release(5355, {**contents, "bin/plutonium.dll": b"new-runtime"})
calls = []
with patch.object(self.agent, "plutonium_open", side_effect=self.opener(manifest, contents, calls)):
result = self.agent.execute(operation)
self.assertEqual(result["state"], "completed", result)
self.assertEqual(json.loads(result["result"])["revision"], 5355)
self.assertTrue(json.loads(result["result"])["reconciled"])
self.assertEqual(self.agent.plutonium_cache_inventory()["revision"], 5355)
self.assertEqual(calls, [module.PLUTONIUM_MANIFEST_URL, self.file_url(contents["bin/plutonium.dll"])])
with patch.object(self.agent, "plutonium_open", side_effect=AssertionError("Completed operation repeated")):
self.assertEqual(self.agent.execute(operation)["result"], result["result"])
def test_heartbeat_defers_preparation_only_for_explicit_true(self):
self.assertFalse(self.agent.defer_plutonium_preparation)
for response, expected in (
({"deferPlutoniumPreparation": True}, True),
({}, False),
({"deferPlutoniumPreparation": False}, False),
({"deferPlutoniumPreparation": 1}, False),
({"deferPlutoniumPreparation": "true"}, False),
({"deferPlutoniumPreparation": None}, False),
):
self.agent.defer_plutonium_preparation = not expected
with self.subTest(response=response), patch.object(self.agent, "request", return_value=response), patch.object(self.agent, "inventory", return_value={}), patch.object(self.agent, "record_health"):
self.agent.heartbeat()
self.assertIs(self.agent.defer_plutonium_preparation, expected)
def assert_preparation_yields_and_resumes(self, *, restarted):
manifest, contents = self.release()
operation = {"id": "cooperative-prepare", "kind": "plutonium.prepare", "payload": {"revision": 5354}}
if restarted:
self.agent.journal.accept(operation)
self.agent.journal.update(operation["id"], "running")
self.agent.journal.close()
self.agent = fixtures.Agent(self.config, self.docker)
self.assertEqual(self.agent.journal.get(operation["id"])["state"], "unknown")
agent = self.agent
open_url = self.opener(manifest, contents)
class BusyResponse(fixtures.Response):
def read(self, size=-1):
chunk = super().read(size)
if chunk:
agent.defer_plutonium_preparation = True
return chunk
def become_busy_during_download(url):
if url == self.file_url(contents["bin/plutonium.dll"]):
return BusyResponse(contents["bin/plutonium.dll"])
return open_url(url)
with patch.object(self.agent, "plutonium_open", side_effect=become_busy_during_download):
result = self.agent.execute(operation)
self.assertEqual(result["state"], "accepted", result)
self.assertIn("waiting for other host operations", json.loads(result["result"])["message"])
self.assertIsNone(self.agent.executing_id)
self.assertIsNone(self.agent.plutonium_cache_inventory())
self.assertTrue(self.agent.defer_plutonium_preparation)
# A yielded preparation must let other mutations finish before retrying.
self.create()
self.assertTrue(self.agent.inspect(fixtures.INSTANCE, 1)["running"])
self.agent.defer_plutonium_preparation = False
calls = []
with patch.object(self.agent, "plutonium_open", side_effect=self.opener(manifest, contents, calls)):
result = self.agent.execute(operation)
self.assertEqual(result["state"], "completed", result)
self.assertEqual(result["id"], operation["id"])
self.assertNotIn(self.file_url(contents[BOOTSTRAPPER]), calls)
self.assertIn(self.file_url(contents["bin/plutonium.dll"]), calls)
self.assertEqual(self.agent.plutonium_cache_inventory()["revision"], 5354)
def test_busy_host_yields_mid_download_and_resumes_completed_files(self):
self.assert_preparation_yields_and_resumes(restarted=False)
def test_reconciled_preparation_yields_mid_download_and_resumes_same_operation(self):
self.assert_preparation_yields_and_resumes(restarted=True)
def test_resumption_removes_orphans_before_storage_check_and_publication(self):
manifest, contents = self.release()
raw = json.dumps(manifest).encode()
staging = self.agent.root / "agent/plutonium-cache" / (module.digest(raw) + ".partial/content")
bootstrapper = staging / BOOTSTRAPPER
bootstrapper.parent.mkdir(parents=True)
bootstrapper.write_bytes(contents[BOOTSTRAPPER])
orphans = [staging / ".download-abandoned", staging / "bin/.ec-abandoned", staging / "unlisted/extra.dat", staging / "cdn_info.json"]
for orphan in orphans:
orphan.parent.mkdir(parents=True, exist_ok=True)
orphan.write_bytes(b"orphaned bytes")
outside = Path(self.temp.name) / "outside-cache"
outside.mkdir()
(outside / "untouched").write_bytes(b"private outside bytes")
link = staging / "outside-link"
link.symlink_to(outside, target_is_directory=True)
original_disk_usage = module.shutil.disk_usage
def checked_storage(path):
for orphan in orphans:
self.assertFalse(orphan.exists(), "orphan must be removed before storage accounting")
self.assertFalse(link.is_symlink())
return original_disk_usage(path)
calls = []
with patch.object(module.shutil, "disk_usage", side_effect=checked_storage), patch.object(self.agent, "plutonium_open", side_effect=self.opener(manifest, contents, calls)):
result = self.agent.prepare_plutonium({})
content = self.cache_content()
expected = {name for name in contents if not name.startswith("launcher")}
actual = {path.relative_to(content).as_posix() for path in content.rglob("*") if path.is_file()}
self.assertEqual(actual, expected | {"cdn_info.json"})
self.assertEqual(result["bytes"], sum(len(contents[name]) for name in expected))
self.assertEqual(sum(path.stat().st_size for path in content.rglob("*") if path.is_file()), result["bytes"] + len(raw))
self.assertNotIn(self.file_url(contents[BOOTSTRAPPER]), calls)
self.assertEqual((outside / "untouched").read_bytes(), b"private outside bytes")
app = self.app()
self.assertTrue(self.agent.seed_plutonium(app, IMAGE))
self.assert_runtime(app, manifest, contents)
self.assertFalse((app / "runtime/plutonium/unlisted/extra.dat").exists())
def test_public_downloads_reject_other_origins_and_never_send_host_credentials(self):
# Call the class method to bypass the fixture's default network blocker;
# the underlying opener is mocked, so no request reaches the network.
with patch.object(module.urllib.request, "build_opener") as build:
for url in (
"http://cdn.plutoniummod.com/updater/prod/info.json",
"https://attacker.example/updater/prod/info.json",
"https://cdn.plutoniummod.com.attacker.example/updater/prod/info.json",
"https://secret@cdn.plutoniummod.com/updater/prod/info.json",
"https://cdn.plutoniummod.com/updater/dev/info.json",
):
with self.subTest(url=url), self.assertRaises(AgentError):
fixtures.Agent.plutonium_open(self.agent, url)
build.assert_not_called()
fixtures.Agent.plutonium_open(self.agent, module.PLUTONIUM_MANIFEST_URL)
build.return_value.open.assert_called_once_with(module.PLUTONIUM_MANIFEST_URL, timeout=30)
handler = build.call_args.args[0]
for redirect in (module.PLUTONIUM_MANIFEST_URL, "https://attacker.example/collect"):
with self.subTest(redirect=redirect), self.assertRaises(AgentError):
handler.redirect_request(None, None, 302, "Found", {}, redirect)
def test_rejects_revision_downgrade_and_invalid_preparation_arguments(self):
self.prepare()
manifest, contents = self.release(5353)
with patch.object(self.agent, "plutonium_open", side_effect=self.opener(manifest, contents)):
with self.assertRaises(AgentError):
self.agent.prepare_plutonium({"revision": 5354})
for payload in ({"revision": -1}, {"revision": True}, {"revision": "5354"},
{"minimumFreeBytes": -1}, {"minimumFreeBytes": True}, {"minimumFreeBytes": "10"}):
with self.subTest(payload=payload), self.assertRaises(AgentError):
self.agent.prepare_plutonium(payload)
def test_disk_reserve_failure_leaves_existing_cache_usable(self):
_, contents, _, result = self.prepare()
manifest, contents = self.release(5355, {**contents, "bin/plutonium.dll": b"new-runtime"})
calls = []
usage = type(module.shutil.disk_usage(self.agent.root))(10000, 9900, 100)
with patch.object(module.shutil, "disk_usage", return_value=usage), patch.object(self.agent, "plutonium_open", side_effect=self.opener(manifest, contents, calls)):
with self.assertRaises(AgentError):
self.agent.prepare_plutonium({"minimumFreeBytes": 1000})
self.assertEqual(calls, [module.PLUTONIUM_MANIFEST_URL])
self.assertEqual(self.agent.plutonium_cache_inventory(), result)
def test_malformed_manifest_never_publishes_a_cache(self):
good, contents = self.release()
malformed = [
None, [], {},
{**good, "product": "other-product"},
{**good, "revision": 0},
{**good, "revision": True},
{**good, "revision": "5354"},
{**good, "baseUrl": "http://cdn.plutoniummod.com/updater/prod/"},
{**good, "baseUrl": "https://attacker.example/"},
{**good, "files": []},
{**good, "files": {}},
{**good, "files": good["files"][1:]},
{**good, "files": good["files"] + [good["files"][0]]},
]
for manifest in malformed:
with self.subTest(manifest=manifest), patch.object(self.agent, "plutonium_open", side_effect=self.opener(manifest, contents)):
with self.assertRaises(AgentError):
self.agent.prepare_plutonium({})
self.assertIsNone(self.agent.plutonium_cache_inventory())
def test_manifest_rejects_unsafe_paths_sizes_and_hashes_before_downloading(self):
good, contents = self.release()
bad_entries = [
{"name": name} for name in (
".", "../escape", "/absolute", "bin/../../escape", "bin//file", "bin/./file",
"bin\\file", "C:/escape", "bin/file\x00", "cdn_info.json",
)
] + [
{"size": size} for size in (-1, True, "5", None)
] + [
{"hash": value} for value in ("", "0" * 39, "z" * 40, None)
]
for override in bad_entries:
manifest = copy.deepcopy(good)
manifest["files"][1].update(override)
calls = []
with self.subTest(override=override), patch.object(self.agent, "plutonium_open", side_effect=self.opener(manifest, contents, calls)):
with self.assertRaises(AgentError):
self.agent.prepare_plutonium({})
self.assertEqual(calls, [module.PLUTONIUM_MANIFEST_URL])
self.assertIsNone(self.agent.plutonium_cache_inventory())
def test_short_oversize_and_wrong_hash_downloads_are_not_published(self):
manifest, contents = self.release()
for body in (b"short", contents[BOOTSTRAPPER] + b"extra", b"X" * len(contents[BOOTSTRAPPER])):
with self.subTest(body=body), patch.object(self.agent, "plutonium_open", side_effect=self.opener(manifest, {**contents, BOOTSTRAPPER: body})):
with self.assertRaises(AgentError):
self.agent.prepare_plutonium({})
self.assertIsNone(self.agent.plutonium_cache_inventory())
def test_seeds_are_private_and_preserve_existing_lobby_state(self):
manifest, contents, _, _ = self.prepare()
first, second = self.app("first"), self.app("second")
private = {
"configs/server.cfg": b"secret config",
"runtime/plutonium/plugins/private.dll": b"private plugin",
"runtime/plutonium/storage/t5/logs/games_mp.log": b"lobby log",
}
for name, body in private.items():
target = first / name
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(body)
self.assertTrue(self.agent.seed_plutonium(first, IMAGE))
self.assertTrue(self.agent.seed_plutonium(second, IMAGE))
self.assert_runtime(first, manifest, contents)
self.assert_runtime(second, manifest, contents)
for name, body in private.items():
self.assertEqual((first / name).read_bytes(), body)
self.assertFalse((second / name).exists())
first_file = first / "runtime/plutonium" / BOOTSTRAPPER
second_file = second / "runtime/plutonium" / BOOTSTRAPPER
cached_file = self.cache_content() / BOOTSTRAPPER
self.assertNotEqual(first_file.stat().st_ino, second_file.stat().st_ino)
self.assertNotEqual(first_file.stat().st_ino, cached_file.stat().st_ino)
first_file.write_bytes(b"container-updated")
self.assertEqual(second_file.read_bytes(), contents[BOOTSTRAPPER])
self.assertEqual(cached_file.read_bytes(), contents[BOOTSTRAPPER])
def test_absent_or_corrupt_cache_and_unsupported_image_fall_back(self):
app = self.app()
self.assertFalse(self.agent.seed_plutonium(app, IMAGE))
self.assertFalse((app / "runtime/plutonium/cdn_info.json").exists())
self.prepare()
for image in ("example.invalid/plutainer:latest", "ghcr.io/ayymoss/plutainer-other:latest", "ghcr.io/attacker/plutainer:latest"):
with self.subTest(image=image):
self.assertFalse(self.agent.seed_plutonium(app, image))
(self.cache_content() / BOOTSTRAPPER).write_bytes(b"broken-cache")
self.assertFalse(self.agent.seed_plutonium(app, IMAGE))
self.assertFalse((app / "runtime/plutonium/cdn_info.json").exists())
self.assertIsNone(self.agent.plutonium_cache_inventory())
def test_interrupted_seed_does_not_install_revision_marker_and_can_retry(self):
manifest, contents, _, _ = self.prepare()
app = self.app()
marker = app / "runtime/plutonium/cdn_info.json"
marker.parent.mkdir(parents=True)
marker.write_text(json.dumps({"revision": 5353}))
copy_file = module.managed_copy
copied = []
def interrupted_copy(root, name, source, *args, **kwargs):
self.assertFalse(marker.exists(), "revision marker must be installed only after all files")
if copied:
raise OSError("simulated full disk")
copied.append(name)
return copy_file(root, name, source, *args, **kwargs)
with patch.object(module, "managed_copy", side_effect=interrupted_copy):
with self.assertRaises((AgentError, OSError)):
self.agent.seed_plutonium(app, IMAGE)
self.assertFalse(marker.exists())
self.assertTrue(copied)
self.assertTrue(self.agent.seed_plutonium(app, IMAGE))
self.assert_runtime(app, manifest, contents)
def test_failed_seed_prevents_container_start(self):
self.prepare()
with patch.object(module, "managed_copy", side_effect=OSError("simulated full disk")):
payload = {"game": "t5", "libraryId": self.library_id, "image": IMAGE, "port": 28960, "env": {}}
result = self.agent.execute(self.operation("instance.create", payload, "failed-seed"))
self.assertEqual(result["state"], "failed", result)
self.assertFalse(any(call[:2] == ("container", "start") for call in self.docker.calls))
self.assertFalse((self.agent.instance_dir(fixtures.INSTANCE, 1) / "app/runtime/plutonium/cdn_info.json").exists())
def test_symlink_in_cache_cannot_seed_outside_data(self):
_, contents, _, _ = self.prepare()
cached = self.cache_content() / BOOTSTRAPPER
outside = Path(self.temp.name) / "outside"
outside.write_bytes(contents[BOOTSTRAPPER])
cached.unlink()
cached.symlink_to(outside)
app = self.app()
self.assertFalse(self.agent.seed_plutonium(app, IMAGE))
self.assertFalse((app / "runtime/plutonium/cdn_info.json").exists())
def test_create_applies_initial_overlay_after_seed_and_starts_without_cache(self):
self.create()
self.assertTrue(self.agent.inspect(fixtures.INSTANCE, 1)["running"])
self.assertFalse((self.agent.instance_dir(fixtures.INSTANCE, 1) / "app/runtime/plutonium/cdn_info.json").exists())
manifest, contents, _, _ = self.prepare()
payload = self.agent.spec(fixtures.INSTANCE, 1)
payload["port"] += 1
payload["files"].append({"path": "runtime/plutonium/" + BOOTSTRAPPER, "contentBase64": base64.b64encode(b"custom-overlay").decode()})
other_id = "00000000-0000-4000-8000-000000000002"
result = self.agent.execute({**self.operation("instance.create", payload, "second-create"), "instanceId": other_id})
self.assertEqual(result["state"], "completed", result)
app = self.agent.instance_dir(other_id, 1) / "app"
self.assertEqual((app / "runtime/plutonium" / BOOTSTRAPPER).read_bytes(), b"custom-overlay")
self.assertEqual(json.loads((app / "runtime/plutonium/cdn_info.json").read_text()), manifest)
self.assertEqual((app / "configs/server.cfg").read_bytes(), b"original")
def test_rebuild_seeds_the_new_generation_from_current_cache(self):
payload = self.create()
manifest, contents, _, _ = self.prepare()
self.agent.rcon = lambda *_: {"ok": True, "players": 0}
result = self.agent.execute(self.operation("instance.rebuild", {**payload, "nextGeneration": 2}, "rebuild"))
self.assertEqual(result["state"], "completed", result)
self.assertTrue(self.agent.inspect(fixtures.INSTANCE, 2)["running"])
self.assert_runtime(self.agent.instance_dir(fixtures.INSTANCE, 2) / "app", manifest, contents)
def test_restart_preserves_the_instances_downloaded_runtime(self):
self.prepare()
self.create()
runtime_file = self.agent.instance_dir(fixtures.INSTANCE, 1) / "app/runtime/plutonium" / BOOTSTRAPPER
runtime_file.write_bytes(b"container-updated-runtime")
self.agent.rcon = lambda *_: {"ok": True, "players": 0}
with patch.object(self.agent, "seed_plutonium", side_effect=AssertionError("Restart must retain its runtime")):
result = self.agent.execute(self.operation("instance.restart", op_id="restart"))
self.assertEqual(result["state"], "completed", result)
self.assertEqual(runtime_file.read_bytes(), b"container-updated-runtime")
if __name__ == "__main__":
unittest.main()
+57 -2
View File
@@ -55,8 +55,10 @@ enrollment; inspect `systemctl status docker` and `journalctl -u docker`.
The controller reconciles automatic setup on agent heartbeats. Command polls only The controller reconciles automatic setup on agent heartbeats. Command polls only
deliver work already queued. Setup first pulls `PLUTAINER_IMAGE` (default deliver work already queued. Setup first pulls `PLUTAINER_IMAGE` (default
`ghcr.io/ayymoss/plutainer:latest`), then prepares the latest verified upload for `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 T5 and for T6 when available, one setup operation at a time. After those prerequisites,
or enrolling another host needs no manual propagation. Setup waits for an active, connected, capable agents also prepare the optional Plutonium runtime cache described below.
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 healthy host with sufficient storage. Failed operations retry automatically with
backoff from one minute to fifteen minutes; their errors remain visible in the backoff from one minute to fifteen minutes; their errors remain visible in the
host's setup panel and **Hosts → Operation history**. This shared history lists host's setup panel and **Hosts → Operation history**. This shared history lists
@@ -90,6 +92,50 @@ cached mutable image tag. Advanced controls on the
host and game-file pages can explicitly pull a custom runtime, prepare an older host and game-file pages can explicitly pull a custom runtime, prepare an older
upload or retry preparation immediately. upload or retry preparation immediately.
### Plutonium runtime cache
Agent 1.8.0 adds a host cache for the files Plutainer normally downloads from
Plutonium on a new container's first start. This is separate from the cached
Docker image and uploaded base game libraries. Automatic preparation warms it
after the image and libraries are ready and refreshes it for new production
revisions. The controller only queues this work for agents that advertise support.
Cache preparation is optional: a missing or corrupt cache does not prevent a
launch from using Plutainer's normal download path.
Cache preparation has lower priority than deployments and other host changes.
If work arrives during a download, the next heartbeat tells the agent to yield
between transfer steps. Preparation resumes under the same operation ID after
that work finishes, reusing files that have already passed verification.
The agent downloads the official production manifest and its listed vendor files,
verifies the SHA-1 hashes published in that manifest, and makes a snapshot
available only after the complete set passes verification. It keeps the cache
under `agent/plutonium-cache` within the host data directory. It does not populate
the cache from existing lobbies, so lobby configuration, keys, custom scripts,
plugins and runtime state are not collected into it. Preparation retains the
current snapshot and the resumable download, removes older snapshots, and clears
abandoned temporary files before checking available storage or publishing the
new snapshot. A failed refresh preserves the previous complete cache.
For new instances and rebuilds using `ghcr.io/ayymoss/plutainer` image references,
the agent seeds `app/runtime/plutonium` from a valid snapshot, including its
matching `cdn_info.json`, before applying the instance's launch files. Each
instance receives its own writable copy; changes made by a lobby or its updater
cannot change the host cache or another lobby. Custom runtime images keep their
existing startup behavior.
Plutainer's updater remains enabled. When a cached revision is current, it can
skip the initial vendor-file download; when production is newer, it can download
the update normally. Launch readiness still verifies the running Plutonium
revision against production. Refreshing the cache does not restart or update
running lobbies. Ordinary restarts retain the instance's existing files and do
not need to seed from the cache again.
Deleting a lobby preserves the host cache for subsequent launches. Deleting the
host removes it with the rest of the managed data directory. This is intended to
reduce repeated first-start downloads; deployment-time improvements still need
measurement on a real Debian/Docker/Plutonium host.
### Storage layout ### Storage layout
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
@@ -698,6 +744,15 @@ game hosts. Run:
loss and host reboot during update. Verify updater protocol mismatch requests loss and host reboot during update. Verify updater protocol mismatch requests
an installer rerun. an installer rerun.
- T5 and T6 deployments and a mixed collection from a fresh destination host. - T5 and T6 deployments and a mixed collection from a fresh destination host.
- Warm the Plutonium runtime cache, then create and rebuild T5 and T6 lobbies;
verify the running revision and compare first-start downloads and launch times
with an empty cache. Test missing, corrupt and interrupted cache preparation,
a newer production revision, and a custom runtime image. Confirm launch-file
overrides survive seeding, lobby writes cannot alter the cache or another lobby,
and cache refresh leaves running lobbies unchanged. Queue a deployment during
preparation and verify that downloading yields and later resumes. Interrupt
preparation by stopping the agent and verify that retry removes abandoned
temporary files while retaining completed downloads.
- Start, stop, restart, rebuild, destroy; verify RCON, map and runtime revision. - Start, stop, restart, rebuild, destroy; verify RCON, map and runtime revision.
- Read console and game logs, including Plutainer's relative game-log links. - Read console and game logs, including Plutainer's relative game-log links.
- File campaign and rollback, with a player joining between controller check and - File campaign and rollback, with a player joining between controller check and
+2 -1
View File
@@ -82,6 +82,7 @@ export interface HostInventory {
directoriesWritable?: boolean; runtimeImages?: string[]; libraries?: HostLibrary[]; directoriesWritable?: boolean; runtimeImages?: string[]; libraries?: HostLibrary[];
currentOperation?: string | null; [key: string]: unknown; currentOperation?: string | null; [key: string]: unknown;
capabilities?: string[]; capabilities?: string[];
plutoniumCache?: { revision: number; bytes: number };
agentUpdate?: HostAgentUpdate; agentUpdate?: HostAgentUpdate;
agentLanes?: HostAgentLane[]; agentLanes?: HostAgentLane[];
instanceResources?: InstanceResources[]; instanceResources?: InstanceResources[];
@@ -146,7 +147,7 @@ export interface HostOperationHistoryPage {
} }
export interface HostPreparationItem { export interface HostPreparationItem {
kind: 'runtime' | 'library'; label: string; kind: 'runtime' | 'library' | 'plutonium'; label: string;
state: 'waiting' | 'preparing' | 'ready' | 'blocked'; detail: string; state: 'waiting' | 'preparing' | 'ready' | 'blocked'; detail: string;
operationId?: string; bytesDownloaded?: number; bytesTotal?: number; operationId?: string; bytesDownloaded?: number; bytesTotal?: number;
cache?: HostLibraryCache; cache?: HostLibraryCache;
+19 -6
View File
@@ -277,6 +277,12 @@ export function validateInventory(value: unknown): HostInventory {
for (const key of ['dockerRunning','directoriesWritable']) if (inventory[key] !== undefined && typeof inventory[key] !== 'boolean') throw new Error(`Invalid inventory ${key}.`); for (const key of ['dockerRunning','directoriesWritable']) if (inventory[key] !== undefined && typeof inventory[key] !== 'boolean') throw new Error(`Invalid inventory ${key}.`);
if (inventory.runtimeImages !== undefined && (!Array.isArray(inventory.runtimeImages) || inventory.runtimeImages.some(image => typeof image !== 'string'))) throw new Error('Invalid runtime image inventory.'); if (inventory.runtimeImages !== undefined && (!Array.isArray(inventory.runtimeImages) || inventory.runtimeImages.some(image => typeof image !== 'string'))) throw new Error('Invalid runtime image inventory.');
if (inventory.capabilities !== undefined && (!Array.isArray(inventory.capabilities) || inventory.capabilities.length > 32 || inventory.capabilities.some(value => typeof value !== 'string' || value.length > 80))) throw new Error('Invalid agent capability inventory.'); if (inventory.capabilities !== undefined && (!Array.isArray(inventory.capabilities) || inventory.capabilities.length > 32 || inventory.capabilities.some(value => typeof value !== 'string' || value.length > 80))) throw new Error('Invalid agent capability inventory.');
if (inventory.plutoniumCache !== undefined) {
const cache = inventory.plutoniumCache;
if (!cache || typeof cache !== 'object' || Array.isArray(cache) || !Number.isSafeInteger(cache.revision) || cache.revision < 1
|| !Number.isSafeInteger(cache.bytes) || cache.bytes < 0) throw new Error('Invalid Plutonium cache inventory.');
inventory.plutoniumCache = { revision: cache.revision, bytes: cache.bytes };
}
if (inventory.agentUpdate !== undefined) { if (inventory.agentUpdate !== undefined) {
const update = inventory.agentUpdate; const update = inventory.agentUpdate;
const version = (value: unknown) => typeof value === 'string' && value.length <= 80 && /^\d+\.\d+\.\d+$/.test(value); const version = (value: unknown) => typeof value === 'string' && value.length <= 80 && /^\d+\.\d+\.\d+$/.test(value);
@@ -396,6 +402,14 @@ export type OperationDeliveryRow = Omit<OperationRow, 'payload'>;
export type HostOperationStatus = Omit<HostOperation, 'payload'>; export type HostOperationStatus = Omit<HostOperation, 'payload'>;
const operationStatusColumns = 'o.id,o.host_id,o.kind,o.instance_id,o.generation,o.state,o.result,o.error,o.created_at,o.updated_at'; const operationStatusColumns = 'o.id,o.host_id,o.kind,o.instance_id,o.generation,o.state,o.result,o.error,o.created_at,o.updated_at';
const operationPayloadSql = 'COALESCE((SELECT p.payload FROM ec_host_operation_payloads p WHERE p.operation_id=o.id),o.payload)'; const operationPayloadSql = 'COALESCE((SELECT p.payload FROM ec_host_operation_payloads p WHERE p.operation_id=o.id),o.payload)';
const readOnlyOperationSql = `(o.kind IN ('host.inspect','instance.inspect','rcon.status-batch','files.read','files.list','files.stat','files.manifest','logs.read')
OR (o.kind='rcon.execute' AND COALESCE(json_extract(${operationPayloadSql},'$.command') IN ('status','getstatus','version'),0)))`;
/** Optional cache downloads yield their write lane to deployments and host changes. */
export function shouldDeferPlutoniumPreparation(hostId: string): boolean {
return !!database().prepare(`SELECT 1 FROM ec_host_operations o WHERE o.host_id=?
AND o.state IN ('pending','accepted','running','unknown') AND o.kind!='plutonium.prepare'
AND NOT ${readOnlyOperationSql} LIMIT 1`).get(hostId);
}
function operationStatusFromRow(row: Omit<OperationRow, 'payload'>): HostOperationStatus { return { id: row.id, hostId: row.host_id, kind: row.kind, instanceId: row.instance_id, generation: row.generation, state: row.state, result: row.result === null ? null : JSON.parse(row.result), error: row.error, createdAt: row.created_at, updatedAt: row.updated_at }; } function operationStatusFromRow(row: Omit<OperationRow, 'payload'>): HostOperationStatus { return { id: row.id, hostId: row.host_id, kind: row.kind, instanceId: row.instance_id, generation: row.generation, state: row.state, result: row.result === null ? null : JSON.parse(row.result), error: row.error, createdAt: row.created_at, updatedAt: row.updated_at }; }
function operationFromRow(row: OperationRow): HostOperation { const status = operationStatusFromRow(row); return { ...status, result: unpackFileJson(status.result), payload: unpackFileJson(JSON.parse(row.payload)) }; } function operationFromRow(row: OperationRow): HostOperation { const status = operationStatusFromRow(row); return { ...status, result: unpackFileJson(status.result), payload: unpackFileJson(JSON.parse(row.payload)) }; }
export function getHostOperation(id: string): HostOperation | null { export function getHostOperation(id: string): HostOperation | null {
@@ -483,7 +497,7 @@ function enqueueHostOperation(hostId: string, kind: string, payload: string | Bu
} }
} }
if (['instance.create','create'].includes(kind)) requireReadyHost(hostId); if (['instance.create','create'].includes(kind)) requireReadyHost(hostId);
if (host.mode === 'disabled' && kind !== 'instance.cancel-create' && !(host.enablingSince && ['runtime.pull','library.download','host.inspect'].includes(kind))) throw new Error('Host is disabled. Enable it before requesting operations.'); if (host.mode === 'disabled' && kind !== 'instance.cancel-create' && !(host.enablingSince && ['runtime.pull','library.download','plutonium.prepare','host.inspect'].includes(kind))) throw new Error('Host is disabled. Enable it before requesting operations.');
const requestHash = createHash('sha256').update(payload).digest('hex'); const requestHash = createHash('sha256').update(payload).digest('hex');
const packed = packFileJson(JSON.parse(payload.toString())); const packed = packFileJson(JSON.parse(payload.toString()));
payload = JSON.stringify(packed.value); payload = JSON.stringify(packed.value);
@@ -523,12 +537,10 @@ export function pendingHostOperationRows(hostId: string, lane: OperationLane = '
WHERE host_id=? AND kind='instance.cancel-create' AND state IN ('pending','accepted','running','unknown') LIMIT 1`).get(hostId)) return []; WHERE host_id=? AND kind='instance.cancel-create' AND state IN ('pending','accepted','running','unknown') LIMIT 1`).get(hostId)) return [];
// Accepted/running/unknown commands keep the same immutable ID. The agent // Accepted/running/unknown commands keep the same immutable ID. The agent
// consults its durable journal before acknowledging or executing a replay. // consults its durable journal before acknowledging or executing a replay.
const readOnly = `(kind IN ('host.inspect','instance.inspect','rcon.status-batch','files.read','files.list','files.stat','files.manifest','logs.read')
OR (kind='rcon.execute' AND json_extract(${operationPayloadSql},'$.command') IN ('status','getstatus','version')))`;
const healthOnly = `(kind IN ('instance.inspect','rcon.status-batch') OR (kind='rcon.execute' AND json_extract(${operationPayloadSql},'$.command') IN ('status','getstatus','version')))`; const healthOnly = `(kind IN ('instance.inspect','rcon.status-batch') OR (kind='rcon.execute' AND json_extract(${operationPayloadSql},'$.command') IN ('status','getstatus','version')))`;
const healthLane = host.inventory.capabilities?.includes('operations.health-lane'); const healthLane = host.inventory.capabilities?.includes('operations.health-lane');
const laneFilter = lane === 'all' ? '' : lane === 'health' ? ` AND ${healthOnly}` const laneFilter = lane === 'all' ? '' : lane === 'health' ? ` AND ${healthOnly}`
: ` AND ${lane === 'write' ? 'NOT ' : ''}${readOnly}${lane === 'read' && healthLane ? ` AND NOT ${healthOnly}` : ''}`; : ` AND ${lane === 'write' ? 'NOT ' : ''}${readOnlyOperationSql}${lane === 'read' && healthLane ? ` AND NOT ${healthOnly}` : ''}`;
// Health never queues behind a delivered file batch. Keep background reads // Health never queues behind a delivered file batch. Keep background reads
// in small batches to amortize delivery without handing out a long backlog. // in small batches to amortize delivery without handing out a long backlog.
const limit = healthLane ? lane === 'health' ? 1 : lane === 'read' ? 4 : 25 : 25; const limit = healthLane ? lane === 'health' ? 1 : lane === 'read' ? 4 : 25 : 25;
@@ -536,13 +548,14 @@ export function pendingHostOperationRows(hostId: string, lane: OperationLane = '
const columns = includePayload ? `${operationStatusColumns},${operationPayloadSql} AS payload` : operationStatusColumns; const columns = includePayload ? `${operationStatusColumns},${operationPayloadSql} AS payload` : operationStatusColumns;
const rows = database().prepare(`SELECT ${columns} FROM ec_host_operations o WHERE host_id=? AND state IN ('pending','accepted','running','unknown') const rows = database().prepare(`SELECT ${columns} FROM ec_host_operations o WHERE host_id=? AND state IN ('pending','accepted','running','unknown')
${laneFilter} ${laneFilter}
${host.mode === 'disabled' ? "AND kind IN ('runtime.pull','library.download','host.inspect')" : ''} ${host.mode === 'disabled' ? "AND kind IN ('runtime.pull','library.download','plutonium.prepare','host.inspect')" : ''}
AND (? OR state != 'pending' OR kind NOT IN ('instance.create','create')) AND (? OR state != 'pending' OR kind NOT IN ('instance.create','create'))
AND (kind!='instance.cancel-create' OR ?) AND (kind!='instance.cancel-create' OR ?)
AND NOT EXISTS (SELECT 1 FROM ec_host_operations cancellation WHERE cancellation.host_id=o.host_id AND NOT EXISTS (SELECT 1 FROM ec_host_operations cancellation WHERE cancellation.host_id=o.host_id
AND cancellation.kind='instance.cancel-create' AND json_extract(cancellation.payload,'$.createOperationId')=o.id) AND cancellation.kind='instance.cancel-create' AND json_extract(cancellation.payload,'$.createOperationId')=o.id)
${lane === 'health' ? "AND (state NOT IN ('accepted','running') OR MAX(updated_at,COALESCE(last_delivered_at,'')) <= ?)" : ''} ${lane === 'health' ? "AND (state NOT IN ('accepted','running') OR MAX(updated_at,COALESCE(last_delivered_at,'')) <= ?)" : ''}
ORDER BY CASE WHEN kind='instance.cancel-create' THEN 0 ELSE 1 END,COALESCE(last_delivered_at,''),created_at,rowid LIMIT ${limit}`) ORDER BY CASE WHEN kind='instance.cancel-create' THEN 0 WHEN kind='plutonium.prepare' THEN 2 ELSE 1 END,
COALESCE(last_delivered_at,''),created_at,rowid LIMIT ${limit}`)
// A short replay lease avoids handing the idle health lane its busy // A short replay lease avoids handing the idle health lane its busy
// sibling's work, but still recovers a lost receipt or agent restart. // sibling's work, but still recovers a lost receipt or agent restart.
.all(hostId, Number(host.readiness === 'ready' || host.mode === 'draining'), Number(host.inventory.capabilities?.includes('instance.cancel-create') ?? false), .all(hostId, Number(host.readiness === 'ready' || host.mode === 'draining'), Number(host.inventory.capabilities?.includes('instance.cancel-create') ?? false),
+4
View File
@@ -35,6 +35,10 @@ export const importantOperationSql = `(o.routine=0 OR o.state IN ('failed','unkn
const payloadSql = 'COALESCE((SELECT p.payload FROM ec_host_operation_payloads p WHERE p.operation_id=o.id),o.payload)'; const payloadSql = 'COALESCE((SELECT p.payload FROM ec_host_operation_payloads p WHERE p.operation_id=o.id),o.payload)';
/** Deliberately exclude arbitrary RCON commands and all file contents/credentials. */ /** Deliberately exclude arbitrary RCON commands and all file contents/credentials. */
export const operationTargetSql = `CASE export const operationTargetSql = `CASE
WHEN o.kind='plutonium.prepare' THEN CASE
WHEN json_type(${payloadSql},'$.revision')='integer' AND json_extract(${payloadSql},'$.revision')>0
THEN 'Production revision r' || json_extract(${payloadSql},'$.revision')
ELSE 'Latest production revision' END
WHEN o.kind IN ('files.read','files.stat','files.list','logs.read') THEN WHEN o.kind IN ('files.read','files.stat','files.list','logs.read') THEN
CASE WHEN json_type(${payloadSql},'$.path')='text' THEN substr(json_extract(${payloadSql},'$.path'),1,1024) END CASE WHEN json_type(${payloadSql},'$.path')='text' THEN substr(json_extract(${payloadSql},'$.path'),1,1024) END
WHEN o.kind='files.manifest' THEN substr((SELECT group_concat(value,', ') FROM json_each(${payloadSql},'$.roots') WHERE type='text'),1,1024) WHEN o.kind='files.manifest' THEN substr((SELECT group_concat(value,', ') FROM json_each(${payloadSql},'$.roots') WHERE type='text'),1,1024)
+51 -21
View File
@@ -1,15 +1,17 @@
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { config } from '../config.js'; import { config } from '../config.js';
import { AGENT_PROTOCOL_VERSION, getHost, queueHostOperation } from './index.js'; import { AGENT_PROTOCOL_VERSION, getHost, queueHostOperation, shouldDeferPlutoniumPreparation } from './index.js';
import { getHostLibraryCache, latestPublishedLibraries, queueLibraryDownload } from '../game-files/index.js'; import { getHostLibraryCache, latestPublishedLibraries, queueLibraryDownload } from '../game-files/index.js';
import { GAME_FILE_GAMES } from '../../game-files.js'; import { GAME_FILE_GAMES } from '../../game-files.js';
import type { Host, HostOperationState, HostPreparation, HostPreparationItem } from '../../hosts.js'; import type { Host, HostOperationState, HostPreparation, HostPreparationItem } from '../../hosts.js';
import { scheduleAvailabilityRefresh } from './availability.js'; import { scheduleAvailabilityRefresh } from './availability.js';
import { readSetting } from '../maintenance/settings.js';
import type { PlutoniumRelease } from '../../plutonium.js';
const activeStates = ['pending', 'accepted', 'running', 'unknown']; const activeStates = ['pending', 'accepted', 'running', 'unknown'];
const supersededDownload = 'Superseded by newer game files before delivery to the host.'; const supersededDownload = 'Superseded by newer game files before delivery to the host.';
type PreparationOperation = { id: string; sequence: number; last_delivered_at: string | null; state: HostOperationState; updated_at: string; error: string | null }; type PreparationOperation = { id: string; sequence: number; last_delivered_at: string | null; state: HostOperationState; updated_at: string; error: string | null };
type Target = { item: HostPreparationItem; image?: string; versionId?: string; requiredBytes: number; operation?: PreparationOperation }; type Target = { item: HostPreparationItem; image?: string; versionId?: string; revision?: number; requiredBytes: number; operation?: PreparationOperation };
type QueuedSelection = { libraryId: string }; type QueuedSelection = { libraryId: string };
type LibraryTarget = Pick<ReturnType<typeof latestPublishedLibraries>[number], 'id' | 'sizeBytes' | 'manifestSha256'> & { name: string; queued?: boolean }; type LibraryTarget = Pick<ReturnType<typeof latestPublishedLibraries>[number], 'id' | 'sizeBytes' | 'manifestSha256'> & { name: string; queued?: boolean };
@@ -48,25 +50,27 @@ function targetLibraries(selections: QueuedSelection[]): LibraryTarget[] {
return [...queued.values(), ...latest]; return [...queued.values(), ...latest];
} }
function latestOperation(hostId: string, kind: string, field: string, value: string): PreparationOperation | undefined { function latestOperation(hostId: string, kind: string, field?: string, value?: string): PreparationOperation | undefined {
return db().prepare(`SELECT id,rowid AS sequence,last_delivered_at,state,updated_at,error FROM ec_host_operations return db().prepare(`SELECT id,rowid AS sequence,last_delivered_at,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`) WHERE host_id=? AND kind=? ${field ? 'AND json_extract(payload,?)=?' : ''} ORDER BY created_at DESC,rowid DESC LIMIT 1`)
.get(hostId, kind, `$.${field}`, value) as PreparationOperation | undefined; .get(hostId, kind, ...(field ? [`$.${field}`, value!] : [])) as PreparationOperation | undefined;
} }
function retryAt(hostId: string, target: Target): number { function retryAt(hostId: string, target: Target): number {
const operation = target.operation; const operation = target.operation;
if (!operation || operation.state !== 'failed') return 0; if (!operation || operation.state !== 'failed') return 0;
const kind = target.image ? 'runtime.pull' : 'library.download'; const kind = target.image ? 'runtime.pull' : target.versionId ? 'library.download' : 'plutonium.prepare';
const field = target.image ? '$.image' : '$.libraryVersionId'; const field = target.image ? '$.image' : target.versionId ? '$.libraryVersionId' : null;
const value = target.image ?? target.versionId!; const value = target.image ?? target.versionId;
const match = field ? 'AND json_extract(payload,?)=?' : '';
const bindings = [hostId, kind, ...(field ? [field, value!] : [])];
// Count consecutive failures, including across controller restarts. Unknown // Count consecutive failures, including across controller restarts. Unknown
// operations are never retried under a new ID: their journal must reconcile. // operations are never retried under a new ID: their journal must reconcile.
const { count } = db().prepare(`SELECT COUNT(*) AS count FROM ec_host_operations const { count } = db().prepare(`SELECT COUNT(*) AS count FROM ec_host_operations
WHERE host_id=? AND kind=? AND json_extract(payload,?)=? AND state='failed' WHERE host_id=? AND kind=? ${match} AND state='failed'
AND rowid>COALESCE((SELECT MAX(rowid) FROM ec_host_operations AND rowid>COALESCE((SELECT MAX(rowid) FROM ec_host_operations
WHERE host_id=? AND kind=? AND json_extract(payload,?)=? AND state='completed'),0)`) WHERE host_id=? AND kind=? ${match} AND state='completed'),0)`)
.get(hostId, kind, field, value, hostId, kind, field, value) as { count: number }; .get(...bindings, ...bindings) 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))); return Date.parse(operation.updated_at) + Math.min(15 * 60_000, 60_000 * 2 ** Math.min(4, Math.max(0, count - 1)));
} }
@@ -108,6 +112,22 @@ function targets(host: Host): Target[] {
: ready ? 'The latest game files are downloaded and verified.' : 'The latest game files will be downloaded and verified automatically.', : 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, ...(cache ? { cache } : {}) } }); bytesDownloaded: cache?.bytesDownloaded ?? 0, bytesTotal: version.sizeBytes, ...(cache ? { cache } : {}) } });
} }
if (host.inventory.capabilities?.includes('plutonium.cache')) {
// Read the same production release evidence as freshRelease without
// importing its fleet/provisioning observers into host preparation.
const release = readSetting<PlutoniumRelease>('plutonium_release', { revision: null, checkedAt: null, error: null });
const age = release.checkedAt ? Date.now() - Date.parse(release.checkedAt) : NaN;
const revision = !release.error && age >= 0 && age < 60 * 60_000
&& Number.isSafeInteger(release.revision) && release.revision! > 0 ? release.revision! : 0;
const cache = host.inventory.plutoniumCache;
const ready = !!cache && cache.revision >= revision;
result.push({ revision, requiredBytes: host.minimumFreeBytes,
operation: latestOperation(host.id, 'plutonium.prepare'),
item: { kind: 'plutonium', label: 'Plutonium cache (optional)', state: ready ? 'ready' : 'waiting',
detail: ready ? `Production revision r${cache.revision} is cached for new deployments.`
: cache ? `Production revision r${revision} will replace cached r${cache.revision} automatically.`
: 'Plutonium files will be cached after the runtime and game files are ready.' } });
}
const blocker = hostBlocker(host); const blocker = hostBlocker(host);
for (const target of result) { for (const target of result) {
if (target.item.state === 'ready') continue; if (target.item.state === 'ready') continue;
@@ -116,14 +136,15 @@ function targets(host: Host): Target[] {
if (blocker) { Object.assign(target.item, blocker); continue; } if (blocker) { Object.assign(target.item, blocker); continue; }
if (operation && activeStates.includes(operation.state)) { if (operation && activeStates.includes(operation.state)) {
target.item.state = 'preparing'; target.item.state = 'preparing';
target.item.detail = !target.image && operation.state === 'pending' && !operation.last_delivered_at target.item.detail = target.versionId && operation.state === 'pending' && !operation.last_delivered_at
? 'Queued for game-file preparation; waiting for agent delivery or fleet preparation capacity.' ? 'Queued for game-file preparation; waiting for agent delivery or fleet preparation capacity.'
: operation.state === 'unknown' ? 'Waiting for the agent to reconcile interrupted preparation.' : operation.state === 'unknown' ? 'Waiting for the agent to reconcile interrupted preparation.'
: target.image ? 'Downloading the runtime automatically.' : target.image ? 'Downloading the runtime automatically.'
: target.revision !== undefined ? 'Downloading and verifying the optional Plutonium cache.'
: target.item.cache?.status === 'verifying' ? 'Download complete. Verifying all game files.' : 'Downloading game files. Verification starts after all files arrive.'; : target.item.cache?.status === 'verifying' ? 'Download complete. Verifying all game files.' : 'Downloading game files. Verification starts after all files arrive.';
} else if (operation && Date.parse(host.lastHeartbeat!) <= Date.parse(operation.updated_at)) { } 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.'; target.item.state = 'preparing'; target.item.detail = 'Waiting for a fresh host inventory after preparation.';
} else if ((!enabling || (operation?.sequence ?? 0) > enabling.operation_floor) && retryAt(host.id, target) > Date.now()) { } else if ((target.revision !== undefined || !enabling || (operation?.sequence ?? 0) > enabling.operation_floor) && retryAt(host.id, target) > Date.now()) {
target.item.state = 'blocked'; target.item.state = 'blocked';
target.item.detail = `${operation?.error ?? 'Preparation failed.'} Automatic retry after ${new Date(retryAt(host.id, target)).toISOString()}.`; 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) { } else if (Number(host.inventory.storageAvailable ?? 0) < target.requiredBytes) {
@@ -142,16 +163,19 @@ export function hostPreparationStatus(host: Host): HostPreparation {
} }
} else if (!items.some(item => item.kind === 'library')) items.push({ kind: 'library', label: 'Game files', state: 'waiting', } else 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.' }); detail: 'Upload T5 or T6 game files; hosts download the latest available files automatically.' });
const state = items.some(item => item.state === 'blocked') ? 'blocked' // Cache health is visible, but never makes a usable host ineligible or
: items.some(item => item.state === 'preparing') ? 'preparing' // delays re-enabling: a lobby's normal updater handles cache misses.
: items.some(item => item.state === 'waiting') ? 'waiting' : 'ready'; const required = items.filter(item => item.kind !== 'plutonium');
const state = required.some(item => item.state === 'blocked') ? 'blocked'
: required.some(item => item.state === 'preparing') ? 'preparing'
: required.some(item => item.state === 'waiting') ? 'waiting' : 'ready';
return { state, items }; return { state, items };
} }
export function presentHostPreparation(host: Host): Host { export function presentHostPreparation(host: Host): Host {
const preparation = hostPreparationStatus(host); const preparation = hostPreparationStatus(host);
if (['needs-runtime', 'needs-game-files'].includes(host.readiness) && preparation.state === 'preparing') { 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, readiness: 'preparing', readinessReasons: preparation.items.filter(item => item.kind !== 'plutonium' && item.state !== 'ready').map(item => item.detail), preparation };
} }
return { ...host, preparation }; return { ...host, preparation };
} }
@@ -180,20 +204,26 @@ export function reconcileHostPreparation(hostId: string): HostPreparation {
if (!host) throw new Error('Host was not found.'); if (!host) throw new Error('Host was not found.');
if (!hostBlocker(host)) { if (!hostBlocker(host)) {
supersedeUndeliveredDownloads(hostId); supersedeUndeliveredDownloads(hostId);
// Queue only one setup mutation at a time so each capacity check uses // Queue one required setup mutation at a time with fresh disk
// fresh disk inventory. This also respects manually queued downloads. // inventory. A running optional cache yields to required work via
// the heartbeat flag, then resumes under its original operation ID.
const active = db().prepare(`SELECT 1 FROM ec_host_operations WHERE host_id=? 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); 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=? 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 kind IN ('runtime.pull','library.download','library.import','plutonium.prepare') AND state IN ('completed','failed')
AND (error IS NULL OR error != ?) AND (error IS NULL OR error != ?)
ORDER BY updated_at DESC LIMIT 1`).get(hostId, supersededDownload) as { updated_at: string } | undefined; 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))) { if (!active && (!finished || Date.parse(host.lastHeartbeat!) > Date.parse(finished.updated_at))) {
// A failed or oversized library must not prevent other libraries // A failed or oversized library must not prevent other libraries
// from preparing. The runtime still comes first when eligible. // from preparing. The runtime still comes first when eligible.
const target = targets(host).find(target => target.item.state === 'waiting'); const candidates = targets(host);
const requiredReady = candidates.every(target => target.item.kind === 'plutonium' || target.item.state === 'ready');
const target = candidates.find(target => target.item.state === 'waiting' && (target.item.kind !== 'plutonium' || requiredReady));
if (target?.image) queueHostOperation(host.id, 'runtime.pull', { image: target.image }); if (target?.image) queueHostOperation(host.id, 'runtime.pull', { image: target.image });
else if (target?.versionId) queueLibraryDownload(host.id, target.versionId, { automatic: true }); else if (target?.versionId) queueLibraryDownload(host.id, target.versionId, { automatic: true });
else if (target?.revision !== undefined && !shouldDeferPlutoniumPreparation(host.id)) queueHostOperation(host.id, 'plutonium.prepare', {
revision: target.revision, minimumFreeBytes: host.minimumFreeBytes, automatic: true
});
} }
} }
const status = hostPreparationStatus(host); const status = hostPreparationStatus(host);
@@ -7,6 +7,7 @@
<div class="preparation" aria-live="polite"> <div class="preparation" aria-live="polite">
<p>The default runtime and the latest available T5 and T6 game files are prepared automatically. Re-enabling a host requires a fresh runtime pull and verification of both BO1 (T5) and BO2 (T6) libraries before it accepts deployments. Running servers keep their current files.</p> <p>The default runtime and the latest available T5 and T6 game files are prepared automatically. Re-enabling a host requires a fresh runtime pull and verification of both BO1 (T5) and BO2 (T6) libraries before it accepts deployments. Running servers keep their current files.</p>
<p>Supported hosts also cache Plutonium files to speed up new deployments. This optional cache refreshes when a new production release is detected; deployments can proceed while it is unavailable.</p>
{#if host.preparation} {#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> <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> <ul>
+2 -1
View File
@@ -1,5 +1,5 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { heartbeat, pendingCreationCancellations } from '$lib/server/hosts/index.js'; import { heartbeat, pendingCreationCancellations, shouldDeferPlutoniumPreparation } 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'; import { reconcileHostPreparation } from '$lib/server/hosts/preparation.js';
import { reconcileHostLibraryCleanup } from '$lib/server/game-files/index.js'; import { reconcileHostLibraryCleanup } from '$lib/server/game-files/index.js';
@@ -17,6 +17,7 @@ export async function POST({ request }: { request: Request }) {
reconcileHostPreparation(host.id); reconcileHostPreparation(host.id);
reconcileHostLibraryCleanup(host.id); reconcileHostLibraryCleanup(host.id);
return json({ host, cancelledCreations: pendingCreationCancellations(host.id), heartbeatSeconds: 15, return json({ host, cancelledCreations: pendingCreationCancellations(host.id), heartbeatSeconds: 15,
deferPlutoniumPreparation: shouldDeferPlutoniumPreparation(host.id),
observationKeepDays: fleetSettings().observationKeepDays, serverTime: new Date().toISOString() }); observationKeepDays: fleetSettings().observationKeepDays, serverTime: new Date().toISOString() });
} catch (cause) { return hostApiError(cause); } } catch (cause) { return hostApiError(cause); }
} }
+1 -1
View File
@@ -90,7 +90,7 @@
<thead><tr><th scope="col">Operation</th><th scope="col">Host</th><th scope="col">Target</th><th scope="col">Source</th><th scope="col">State</th><th scope="col">Updated</th><th scope="col">Result</th></tr></thead> <thead><tr><th scope="col">Operation</th><th scope="col">Host</th><th scope="col">Target</th><th scope="col">Source</th><th scope="col">State</th><th scope="col">Updated</th><th scope="col">Result</th></tr></thead>
<tbody>{#each history.operations as operation (operation.id)} <tbody>{#each history.operations as operation (operation.id)}
<tr> <tr>
<td class="operation">{operation.kind}<small>{operation.id}</small></td> <td class="operation">{operation.kind === 'plutonium.prepare' ? 'Prepare Plutonium cache' : operation.kind}<small>{operation.id}</small></td>
<td>{#if operation.hostName !== null}<a href={contextHref(`/hosts/${operation.hostId}`, $page.url)}>{operation.hostName}</a>{:else}Deleted host<small>{operation.hostId}</small>{/if}</td> <td>{#if operation.hostName !== null}<a href={contextHref(`/hosts/${operation.hostId}`, $page.url)}>{operation.hostName}</a>{:else}Deleted host<small>{operation.hostId}</small>{/if}</td>
<td class="target"> <td class="target">
<span>{operation.serverName ?? operation.instanceId ?? 'Host-level'}</span> <span>{operation.serverName ?? operation.instanceId ?? 'Host-level'}</span>
+262 -4
View File
@@ -33,7 +33,7 @@ import urllib.parse
import urllib.request import urllib.request
import uuid import uuid
VERSION = "1.7.0" VERSION = "1.8.0"
LABEL = "io.electric-cherry" LABEL = "io.electric-cherry"
MAX_FILE = 32 * 1024 * 1024 MAX_FILE = 32 * 1024 * 1024
MANIFEST_MAX_ENTRIES = 10000 MANIFEST_MAX_ENTRIES = 10000
@@ -42,6 +42,10 @@ MANIFEST_MAX_SECONDS = 15
MANIFEST_MAX_DEPTH = 64 MANIFEST_MAX_DEPTH = 64
LIBRARY_DOWNLOAD_WORKERS = 4 LIBRARY_DOWNLOAD_WORKERS = 4
HEALTH_LOCK_TIMEOUT_SECONDS = 5 HEALTH_LOCK_TIMEOUT_SECONDS = 5
PLUTONIUM_MANIFEST_URL = "https://cdn.plutoniummod.com/updater/prod/info.json"
PLUTONIUM_MANIFEST_LIMIT = 2 * 1024 * 1024
PLUTONIUM_FILE_LIMIT = 512 * 1024 * 1024
PLUTONIUM_CACHE_LIMIT = 4 * 1024 * 1024 * 1024
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")
@@ -487,6 +491,34 @@ def managed_write(root, value, content, uid=1000, gid=1000):
os.unlink(temp_name, dir_fd=parent) os.unlink(temp_name, dir_fd=parent)
def managed_copy(root, value, source, uid=1000, gid=1000):
"""Copy a private file, using Linux reflinks when supported, never hardlinks."""
with managed_open(Path(source).parent, Path(source).name) as incoming:
with managed_parent(root, value, create=True, uid=uid, gid=gid) as (parent, name):
temp_name = ".ec-" + uuid.uuid4().hex
fd = os.open(temp_name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=parent)
try:
os.fchmod(fd, 0o644)
if os.geteuid() == 0:
os.fchown(fd, uid, gid)
try:
if not sys.platform.startswith("linux"):
raise OSError("Reflinks unavailable")
fcntl.ioctl(fd, 0x40049409, incoming.fileno()) # FICLONE
except OSError:
os.ftruncate(fd, 0)
with os.fdopen(fd, "wb", closefd=False) as outgoing:
shutil.copyfileobj(incoming, outgoing, 1024 * 1024)
outgoing.flush()
os.fsync(fd)
os.replace(temp_name, name, src_dir_fd=parent, dst_dir_fd=parent)
os.fsync(parent)
finally:
os.close(fd)
with contextlib.suppress(FileNotFoundError):
os.unlink(temp_name, dir_fd=parent)
class ReadWriteGate: class ReadWriteGate:
"""Let observations run together; global mutations wait for existing readers.""" """Let observations run together; global mutations wait for existing readers."""
def __init__(self): def __init__(self):
@@ -921,11 +953,13 @@ class Agent:
self.read_claims = set() self.read_claims = set()
self.health_probe_gate = threading.BoundedSemaphore(2) self.health_probe_gate = threading.BoundedSemaphore(2)
self.cancelled_creations = frozenset() self.cancelled_creations = frozenset()
self.defer_plutonium_preparation = False
self.observation_keep_days = 0 # Older controllers do not authorize cleanup. self.observation_keep_days = 0 # Older controllers do not authorize cleanup.
self.library_progress = {} self.library_progress = {}
self.library_transfers = {} self.library_transfers = {}
self.inventory_manifest_cache = {} self.inventory_manifest_cache = {}
self.inventory_manifest_lock = threading.Lock() self.inventory_manifest_lock = threading.Lock()
self.plutonium_lock = threading.RLock()
self.config_path = config.get("configPath", "/etc/electric-cherry/agent.json") self.config_path = config.get("configPath", "/etc/electric-cherry/agent.json")
self.executing_id = None self.executing_id = None
self.lane_lock = threading.Lock() self.lane_lock = threading.Lock()
@@ -1360,8 +1394,9 @@ class Agent:
if line.startswith("PRETTY_NAME="): if line.startswith("PRETTY_NAME="):
os_name = line.split("=", 1)[1].strip('"') os_name = line.split("=", 1)[1].strip('"')
update = self.update_inventory() update = self.update_inventory()
capabilities = ["library.remove", "instance.cancel-create", "instance.safety-override", "instance.autoscaling-remove", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest", "files.blobs"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else []) capabilities = ["library.remove", "plutonium.cache", "instance.cancel-create", "instance.safety-override", "instance.autoscaling-remove", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest", "files.blobs"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "agentLanes": self.lane_inventory(), "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, **resources, **({"cpuUsagePercent": cpu_usage} if cpu_usage is not None else {}), "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})} plutonium_cache = self.plutonium_cache_inventory()
return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "agentLanes": self.lane_inventory(), "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, **resources, **({"cpuUsagePercent": cpu_usage} if cpu_usage is not None else {}), "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), **({"plutoniumCache": plutonium_cache} if plutonium_cache else {}), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})}
def safe_to_disrupt(self, instance_id, generation): def safe_to_disrupt(self, instance_id, generation):
health = self.inspect(instance_id, generation) health = self.inspect(instance_id, generation)
@@ -1510,6 +1545,223 @@ class Agent:
raise Rejected("Initial files exceed the 64 MiB operation limit") raise Rejected("Initial files exceed the 64 MiB operation limit")
return game, library, image, port, bind, env return game, library, image, port, bind, env
@staticmethod
def plutonium_manifest(data):
"""Validate the official updater format before using any paths or URLs."""
if len(data) > PLUTONIUM_MANIFEST_LIMIT:
raise AgentError("Plutonium manifest is too large")
try:
manifest = json.loads(data)
except (ValueError, UnicodeError):
raise AgentError("Invalid Plutonium manifest") from None
if not isinstance(manifest, dict) or manifest.get("product") != "plutonium-core-prod":
raise AgentError("Invalid Plutonium production manifest")
revision = manifest.get("revision")
if type(revision) is not int or not 1 <= revision <= 2147483647:
raise AgentError("Invalid Plutonium revision")
base = manifest.get("baseUrl")
if not isinstance(base, str) or not re.fullmatch(r"https://cdn\.plutoniummod\.com/updater/prod/(?:[A-Za-z0-9_-]+/)*", base):
raise AgentError("Plutonium downloads must use the official production CDN")
entries = manifest.get("files")
if not isinstance(entries, list) or not 1 <= len(entries) <= 4096:
raise AgentError("Invalid Plutonium file list")
names, total = set(), 0
for entry in entries:
if not isinstance(entry, dict):
raise AgentError("Invalid Plutonium file entry")
name, size, checksum = entry.get("name"), entry.get("size"), entry.get("hash")
if not isinstance(name, str) or len(name) > 512 or re.search(r'[\x00-\x1f\x7f\\:*?"<>|]', name):
raise AgentError("Invalid Plutonium file path")
path = PurePosixPath(name)
if not path.parts or path.is_absolute() or str(path) != name or any(part in (".", "..") or part.endswith((".", " ")) for part in path.parts) or name.lower() == "cdn_info.json":
raise AgentError("Invalid Plutonium file path")
if name.lower() in names:
raise AgentError("Duplicate Plutonium file path")
names.add(name.lower())
if type(size) is not int or not 0 <= size <= PLUTONIUM_FILE_LIMIT:
raise AgentError("Invalid Plutonium file size")
if not isinstance(checksum, str) or not re.fullmatch(r"[a-f0-9]{40}", checksum):
raise AgentError("Invalid Plutonium file hash")
total += size
if total > PLUTONIUM_CACHE_LIMIT:
raise AgentError("Plutonium cache exceeds the size limit")
for name in names:
if any(str(parent) in names for parent in PurePosixPath(name).parents if str(parent) != "."):
raise AgentError("Conflicting Plutonium file paths")
if "bin/plutonium-bootstrapper-win32.exe" not in {entry["name"] for entry in entries}:
raise AgentError("Plutonium manifest is missing the server bootstrapper")
# Match plutonium-updater's default: launcher assets are optional.
return manifest, [entry for entry in entries if not entry["name"].startswith("launcher")]
@staticmethod
def plutonium_file_matches(root, entry):
try:
with managed_open(root, entry["name"]) as stream:
if os.fstat(stream.fileno()).st_size != entry["size"]:
return False
checksum = hashlib.sha1()
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
checksum.update(chunk)
return checksum.hexdigest() == entry["hash"]
except (OSError, AgentError):
return False
def plutonium_open(self, url):
if not isinstance(url, str) or not re.fullmatch(r"https://cdn\.plutoniummod\.com/updater/prod/[A-Za-z0-9_./-]+", url):
raise AgentError("Plutonium downloads must use the official production CDN")
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, hdrs, newurl):
raise AgentError("Plutonium download redirects are not allowed")
# Never send the controller's host credentials to the public CDN.
return urllib.request.build_opener(NoRedirect()).open(url, timeout=30)
def plutonium_snapshot(self):
root = scoped(self.root / "agent", "plutonium-cache")
pointer = json.loads(managed_read(root, "current.json") or b"null")
if not isinstance(pointer, dict) or not isinstance(pointer.get("key"), str) or not re.fullmatch(r"[a-f0-9]{64}", pointer["key"]):
raise AgentError("Plutonium cache is not prepared")
content = scoped(root, pointer["key"] + "/content")
raw = managed_read(content, "cdn_info.json")
if raw is None or digest(raw) != pointer["key"]:
raise AgentError("Plutonium cache manifest is invalid")
manifest, files = self.plutonium_manifest(raw)
if pointer.get("revision") != manifest["revision"] or pointer.get("bytes") != sum(entry["size"] for entry in files):
raise AgentError("Plutonium cache metadata is invalid")
# Heartbeat checks are cheap. Every file is hashed before it is seeded.
for entry in files:
info = scoped(content, entry["name"]).lstat()
if not stat_module.S_ISREG(info.st_mode) or info.st_size != entry["size"]:
raise AgentError("Plutonium cache files are incomplete")
return pointer, content, raw, files
def plutonium_cache_inventory(self):
with self.plutonium_lock:
try:
pointer, _, _, _ = self.plutonium_snapshot()
return {"revision": pointer["revision"], "bytes": pointer["bytes"]}
except (OSError, ValueError, AgentError):
return None
def prepare_plutonium(self, payload):
target, reserve = payload.get("revision", 0), payload.get("minimumFreeBytes", 0)
if type(target) is not int or not 0 <= target <= 2147483647 or type(reserve) is not int or reserve < 0:
raise Rejected("Invalid Plutonium preparation request")
root = scoped(self.root / "agent", "plutonium-cache")
root.mkdir(mode=0o700, exist_ok=True)
os.chmod(root, 0o700)
deadline = time.monotonic() + 1800
def active():
if self.stopping.is_set() or time.monotonic() >= deadline:
raise AgentError("Plutonium cache preparation was interrupted or timed out")
if self.defer_plutonium_preparation:
raise Held("Plutonium cache preparation is waiting for other host operations")
active()
try:
with self.plutonium_open(PLUTONIUM_MANIFEST_URL) as response:
raw = response.read(PLUTONIUM_MANIFEST_LIMIT + 1)
manifest, files = self.plutonium_manifest(raw)
if manifest["revision"] < target:
raise AgentError("Plutonium CDN has not reached the requested revision")
key = digest(raw)
previous = None
with self.plutonium_lock:
with contextlib.suppress(OSError, ValueError, AgentError):
_, previous, _, _ = self.plutonium_snapshot()
if previous is not None and previous.parent.name == key and all(self.plutonium_file_matches(previous, entry) for entry in files):
return {"revision": manifest["revision"], "bytes": sum(entry["size"] for entry in files)}
staging = scoped(root, key + ".partial/content")
staging.mkdir(parents=True, exist_ok=True)
expected = {entry["name"] for entry in files}
# SIGKILL/power loss bypasses download/copy finally blocks. Remove
# abandoned temporary files before counting space or publishing.
for directory, subdirs, filenames in os.walk(staging, followlinks=False):
for name in subdirs + filenames:
path = Path(directory) / name
if path.is_symlink() or (name in filenames and path.relative_to(staging).as_posix() not in expected):
path.unlink()
# Keep at most the current snapshot and this resumable preparation.
current_key = previous.parent.name if previous is not None else None
for directory in root.iterdir():
if re.fullmatch(r"[a-f0-9]{64}(?:\.partial)?", directory.name) and directory.name not in (current_key, key + ".partial"):
if directory.is_symlink():
directory.unlink()
else:
shutil.rmtree(directory)
missing = sum(entry["size"] for entry in files if not self.plutonium_file_matches(staging, entry))
if shutil.disk_usage(root).free < reserve + missing + len(raw):
raise AgentError("Insufficient free storage for the Plutonium cache and host reserve")
for entry in files:
active()
if self.plutonium_file_matches(staging, entry):
continue
if previous is not None and self.plutonium_file_matches(previous, entry):
managed_copy(staging, entry["name"], scoped(previous, entry["name"]), os.geteuid(), os.getegid())
continue
destination = scoped(staging, entry["name"])
destination.parent.mkdir(parents=True, exist_ok=True)
temporary = destination.with_name(".download-" + uuid.uuid4().hex)
try:
with self.plutonium_open(manifest["baseUrl"] + entry["hash"]) as incoming, temporary.open("xb") as outgoing:
size, checksum = 0, hashlib.sha1()
while True:
active()
chunk = incoming.read(min(1024 * 1024, entry["size"] - size + 1))
if not chunk:
break
size += len(chunk)
if size > entry["size"]:
raise AgentError("Plutonium download exceeds its expected size")
checksum.update(chunk)
outgoing.write(chunk)
if size != entry["size"] or checksum.hexdigest() != entry["hash"]:
raise AgentError("Plutonium download failed size or checksum verification")
outgoing.flush()
os.fsync(outgoing.fileno())
os.replace(temporary, destination)
sync_parent(destination)
finally:
with contextlib.suppress(FileNotFoundError):
temporary.unlink()
# Publish metadata last. Interrupted transfers can never claim ready.
active()
atomic_bytes(staging / "cdn_info.json", raw)
pointer = {"key": key, "revision": manifest["revision"], "bytes": sum(entry["size"] for entry in files)}
with self.plutonium_lock:
final = scoped(root, key)
if final.exists():
# Invalidate before replacing a damaged/same-revision cache.
if current_key == key:
(root / "current.json").unlink(missing_ok=True)
shutil.rmtree(final)
os.replace(staging.parent, final)
sync_parent(final)
atomic_json(root / "current.json", pointer)
if previous is not None and current_key != key:
shutil.rmtree(previous.parent)
return {"revision": pointer["revision"], "bytes": pointer["bytes"]}
except (urllib.error.URLError, TimeoutError, http.client.HTTPException):
raise AgentError("Plutonium CDN download is unavailable; the previous cache is retained") from None
def seed_plutonium(self, app, image):
if not re.fullmatch(r"ghcr\.io/ayymoss/plutainer(?::[A-Za-z0-9_.-]+)?(?:@sha256:[a-f0-9]{64})?", image):
return False
with self.plutonium_lock:
try:
_, content, raw, files = self.plutonium_snapshot()
if not all(self.plutonium_file_matches(content, entry) for entry in files):
scoped(self.root / "agent", "plutonium-cache/current.json").unlink(missing_ok=True)
raise AgentError("Plutonium cache checksum verification failed")
except (OSError, ValueError, AgentError):
return False
uid, gid = int(self.config.get("runtimeUid", 1000)), int(self.config.get("runtimeGid", 1000))
marker = "runtime/plutonium/cdn_info.json"
managed_write(app, marker, None, uid, gid)
for entry in files:
self.check_creation_cancellation()
managed_copy(app, "runtime/plutonium/" + entry["name"], scoped(content, entry["name"]), uid, gid)
managed_write(app, marker, raw, uid, gid)
return True
def create(self, instance_id, generation, payload): def create(self, instance_id, generation, payload):
self.check_creation_cancellation() self.check_creation_cancellation()
directory = self.instance_dir(instance_id, generation) directory = self.instance_dir(instance_id, generation)
@@ -1527,6 +1779,7 @@ class Agent:
app = directory / "app" app = directory / "app"
app.mkdir(parents=True, exist_ok=True) app.mkdir(parents=True, exist_ok=True)
self.make_writable(app, directory=True) self.make_writable(app, directory=True)
self.seed_plutonium(app, image)
self.write_initial_files(app, payload.get("files", [])) self.write_initial_files(app, payload.get("files", []))
atomic_json(directory / "spec.json", payload) atomic_json(directory / "spec.json", payload)
env_path = directory / "environment" env_path = directory / "environment"
@@ -2184,6 +2437,8 @@ class Agent:
return {"image": image, "ready": True} return {"image": image, "ready": True}
if kind == "host.inspect": if kind == "host.inspect":
return self.inventory() return self.inventory()
if kind == "plutonium.prepare":
return self.prepare_plutonium(payload)
if kind == "rcon.status-batch": if kind == "rcon.status-batch":
return self.rcon_status_batch(operation) return self.rcon_status_batch(operation)
if kind == "library.download": if kind == "library.download":
@@ -2290,7 +2545,7 @@ 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": if kind in ("runtime.pull", "plutonium.prepare"):
# Docker resumes already downloaded layers. Pull again under # Docker resumes already downloaded layers. Pull again under
# the same operation ID: an existing tag can still refer to an # the same operation ID: an existing tag can still refer to an
# older image and cannot prove an interrupted pull completed. # older image and cannot prove an interrupted pull completed.
@@ -2298,6 +2553,8 @@ class Agent:
try: try:
result = self.dispatch(operation) result = self.dispatch(operation)
return self.journal.update(operation["id"], "completed", {**result, "reconciled": True}) return self.journal.update(operation["id"], "completed", {**result, "reconciled": True})
except Held as err:
return self.journal.update(operation["id"], "accepted", {"blocked": "host-busy", "message": str(err)})
except Exception: except Exception:
return self.journal.update(operation["id"], "failed", error="Interrupted runtime download could not be resumed") return self.journal.update(operation["id"], "failed", error="Interrupted runtime download could not be resumed")
if kind == "library.import": if kind == "library.import":
@@ -2407,6 +2664,7 @@ class Agent:
# Only the heartbeat thread replaces this immutable snapshot; it never # Only the heartbeat thread replaces this immutable snapshot; it never
# mutates the operation journal or races Docker cleanup against create. # mutates the operation journal or races Docker cleanup against create.
self.cancelled_creations = frozenset(value for value in response.get("cancelledCreations", []) if isinstance(value, str)) self.cancelled_creations = frozenset(value for value in response.get("cancelledCreations", []) if isinstance(value, str))
self.defer_plutonium_preparation = response.get("deferPlutoniumPreparation") is True
keep_days = response.get("observationKeepDays") keep_days = response.get("observationKeepDays")
self.observation_keep_days = keep_days if valid_observation_keep_days(keep_days) else 0 self.observation_keep_days = keep_days if valid_observation_keep_days(keep_days) else 0
host = response.get("host", {}) host = response.get("host", {})
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"version": "1.7.0", "version": "1.8.0",
"protocolVersion": 1, "protocolVersion": 1,
"platform": "debian-amd64", "platform": "debian-amd64",
"sha256": "4b40206f477dcf35f0506281b3dddca32b57214a964bcf5169c8611dc705132d", "sha256": "eb36afb3113b92304da42fc78ac7f3b8218456f33c5bc1c0f57b018bbc2ad65d",
"updaterProtocolVersion": 1, "updaterProtocolVersion": 1,
"updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8" "updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8"
} }
+249 -3
View File
@@ -47,7 +47,7 @@ function heartbeat(id: string, overrides: HostInventory = {}) {
} }
function operations(id: string) { function operations(id: string) {
return hosts.listHostOperations(id).filter(operation => ['runtime.pull', 'library.download'].includes(operation.kind)); return hosts.listHostOperations(id).filter(operation => ['runtime.pull', 'library.download', 'plutonium.prepare'].includes(operation.kind));
} }
async function publish(game: 't5' | 't6' = 't5', bytes = content) { async function publish(game: 't5' | 't6' = 't5', bytes = content) {
@@ -85,10 +85,11 @@ describe('automatic host preparation', () => {
} finally { cleanup.mockRestore(); } } finally { cleanup.mockRestore(); }
}); });
it('keeps re-enabled hosts disabled until a fresh runtime pull and both game libraries are verified', async () => { it.each([false, true])('keeps re-enabled hosts disabled until required files are verified, without waiting for the optional cache (supported: %s)', async supported => {
const t5 = await publish('t5'); const t5 = await publish('t5');
const t6 = await publish('t6'); const t6 = await publish('t6');
const readyInventory = { runtimeImages: [runtime], libraries: [t5, t6].map(version => ({ id: version.id, state: 'ready' as const })) }; const readyInventory = { runtimeImages: [runtime], libraries: [t5, t6].map(version => ({ id: version.id, state: 'ready' as const })),
capabilities: [...inventory.capabilities!, ...(supported ? ['plutonium.cache'] : [])] };
const id = enroll(readyInventory); const id = enroll(readyInventory);
verified(id, t5); verified(id, t6); verified(id, t5); verified(id, t6);
const oldRuntime = hosts.queueHostOperation(id, 'runtime.pull', { image: runtime }); const oldRuntime = hosts.queueHostOperation(id, 'runtime.pull', { image: runtime });
@@ -126,6 +127,7 @@ describe('automatic host preparation', () => {
preparation.reconcileHostPreparation(id); preparation.reconcileHostPreparation(id);
expect(hosts.getHost(id)).toMatchObject({ mode: 'active', readiness: 'ready', enablingSince: null }); expect(hosts.getHost(id)).toMatchObject({ mode: 'active', readiness: 'ready', enablingSince: null });
expect(operations(id).filter(operation => operation.kind === 'library.download')).toHaveLength(2); expect(operations(id).filter(operation => operation.kind === 'library.download')).toHaveLength(2);
expect(operations(id).filter(operation => operation.kind === 'plutonium.prepare')).toHaveLength(supported ? 1 : 0);
}); });
it('requires both games when enabling even if one library is already cached', async () => { it('requires both games when enabling even if one library is already cached', async () => {
@@ -551,3 +553,247 @@ describe('automatic host preparation', () => {
expect(libraries.getHostLibraryCache(id, version.id)?.bytesDownloaded).toBe(80); expect(libraries.getHostLibraryCache(id, version.id)?.bytesDownloaded).toBe(80);
}); });
}); });
describe('optional host Plutonium cache', () => {
const capable = { capabilities: ['host.decommission', 'plutonium.cache'] };
async function release(revision: number, overrides: { checkedAt?: string; error?: string | null } = {}) {
const { writeSetting } = await import('../../src/lib/server/maintenance/settings.js');
writeSetting('plutonium_release', { revision, checkedAt: new Date().toISOString(), error: null, ...overrides });
}
it.each(['pending', 'accepted', 'running', 'unknown'] as const)('waits to warm while a deployment is %s', state => {
const ready = { ...capable, runtimeImages: [runtime], libraries: [{ id: 'game-files', state: 'ready' as const }] };
const id = enroll(ready);
const deployment = hosts.queueHostOperation(id, 'instance.create', {});
if (state !== 'pending') hosts.reportHostOperation(id, deployment.id, { state });
preparation.reconcileHostPreparation(id);
expect(hosts.shouldDeferPlutoniumPreparation(id)).toBe(true);
expect(operations(id)).toEqual([]);
hosts.reportHostOperation(id, deployment.id, { state: 'completed' });
heartbeat(id, ready);
preparation.reconcileHostPreparation(id);
expect(operations(id)).toMatchObject([{ kind: 'plutonium.prepare' }]);
});
it('delivers newer deployments before queued cache work and before a cache replay', () => {
const id = enroll({ ...capable, runtimeImages: [runtime], libraries: [{ id: 'game-files', state: 'ready' }] });
preparation.reconcileHostPreparation(id);
const cache = operations(id)[0];
const deployment = hosts.queueHostOperation(id, 'instance.create', {});
expect(hosts.pendingHostOperations(id, 'write').map(operation => operation.id)).toEqual([deployment.id, cache.id]);
hosts.reportHostOperation(id, cache.id, { state: 'running' });
const change = hosts.queueHostOperation(id, 'files.stage', {});
const delivery = hosts.pendingHostOperations(id, 'write').map(operation => operation.id);
expect(delivery.at(-1)).toBe(cache.id);
expect(delivery).toContain(deployment.id);
expect(delivery).toContain(change.id);
});
it('signals deferral on heartbeats for mutations while ignoring cache work and all observation lanes', async () => {
const host = hosts.createHost({ name: 'Cache yielding host' });
const ready = { ...inventory, ...capable, runtimeImages: [runtime], libraries: [{ id: 'game-files', state: 'ready' as const }] };
const { credential } = hosts.enrollHost(hosts.createEnrollment(host.id).token, ready);
preparation.reconcileHostPreparation(host.id);
const cache = operations(host.id)[0];
hosts.reportHostOperation(host.id, cache.id, { state: 'running' });
for (const kind of ['host.inspect', 'instance.inspect', 'rcon.status-batch', 'files.read', 'files.list', 'files.stat', 'files.manifest', 'logs.read']) {
hosts.queueHostOperation(host.id, kind, {});
}
for (const command of ['status', 'getstatus', 'version']) hosts.queueHostOperation(host.id, 'rcon.execute', { command });
// Work belonging to another host must not pause this one's cache.
const other = enroll(ready);
hosts.queueHostOperation(other, 'instance.restart', {});
const { POST } = await import('../../src/routes/api/agent/heartbeat/+server.js');
const poll = async () => {
vi.setSystemTime(Date.now() + 1);
const response = await POST({ request: new Request('https://controller.test/api/agent/heartbeat', {
method: 'POST', headers: { Authorization: `Bearer ${credential}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ inventory: ready })
}) });
expect(response.status).toBe(200);
return response.json();
};
expect(await poll()).toMatchObject({ deferPlutoniumPreparation: false });
const change = hosts.queueHostOperation(host.id, 'rcon.execute', { command: 'map_restart' });
expect(await poll()).toMatchObject({ deferPlutoniumPreparation: true });
hosts.reportHostOperation(host.id, change.id, { state: 'failed', error: 'Command rejected' });
expect(await poll()).toMatchObject({ deferPlutoniumPreparation: false });
expect(operations(host.id).filter(operation => operation.kind === 'plutonium.prepare').map(operation => operation.id)).toEqual([cache.id]);
});
it('lets newly published game files interrupt cache refresh, then resumes the same cache operation', async () => {
const version = await publish();
const ready = { ...capable, runtimeImages: [runtime], libraries: [{ id: version.id, state: 'ready' as const }],
plutoniumCache: { revision: 5354, bytes: 200 } };
const id = enroll(ready);
verified(id, version);
await release(5355);
preparation.reconcileHostPreparation(id);
const cache = operations(id)[0];
expect(cache).toMatchObject({ kind: 'plutonium.prepare', payload: { revision: 5355 } });
hosts.reportHostOperation(id, cache.id, { state: 'running' });
const latest = await publish();
heartbeat(id, ready);
preparation.reconcileHostPreparation(id);
const download = operations(id)[0];
expect(download).toMatchObject({ kind: 'library.download', payload: { libraryVersionId: latest.id } });
expect(hosts.shouldDeferPlutoniumPreparation(id)).toBe(true);
expect(hosts.pendingHostOperations(id, 'write').map(operation => operation.id)).toEqual([download.id, cache.id]);
verified(id, latest);
hosts.reportHostOperation(id, download.id, { state: 'completed' });
heartbeat(id, { ...ready, libraries: [{ id: latest.id, state: 'ready' }] });
preparation.reconcileHostPreparation(id);
expect(hosts.shouldDeferPlutoniumPreparation(id)).toBe(false);
expect(hosts.pendingHostOperations(id, 'write').map(operation => operation.id)).toEqual([cache.id]);
expect(operations(id).filter(operation => operation.kind === 'plutonium.prepare')).toHaveLength(1);
});
it('warms only after required files, preserves launch readiness, and waits for fresh cache inventory', async () => {
const version = await publish();
await release(5354);
const id = enroll(capable);
preparation.reconcileHostPreparation(id);
const pull = operations(id)[0];
expect(pull.kind).toBe('runtime.pull');
hosts.reportHostOperation(id, pull.id, { state: 'completed' });
heartbeat(id, { ...capable, runtimeImages: [runtime] });
preparation.reconcileHostPreparation(id);
const download = operations(id)[0];
expect(download.kind).toBe('library.download');
verified(id, version);
hosts.reportHostOperation(id, download.id, { state: 'completed' });
const ready = { ...capable, runtimeImages: [runtime], libraries: [{ id: version.id, state: 'ready' as const }] };
heartbeat(id, ready);
expect(preparation.reconcileHostPreparation(id).state).toBe('ready');
const cache = operations(id)[0];
expect(cache).toMatchObject({ kind: 'plutonium.prepare', payload: {
revision: 5354, minimumFreeBytes: hosts.getHost(id)!.minimumFreeBytes, automatic: true
} });
expect(hosts.requireReadyHost(id).readiness).toBe('ready');
expect(hosts.pendingHostOperations(id, 'read')).toEqual([]);
expect(hosts.pendingHostOperations(id, 'write').map(operation => operation.id)).toEqual([cache.id]);
expect(hosts.listHostOperationHistory(1, 'important', { host: id }).operations[0]).toMatchObject({
id: cache.id, routine: false, targetPath: 'Production revision r5354'
});
preparation.reconcileHostPreparation(id);
hosts.reportHostOperation(id, cache.id, { state: 'completed' });
const pendingInventory = preparation.reconcileHostPreparation(id);
expect(pendingInventory.items.find(item => item.kind === 'plutonium')).toMatchObject({ state: 'preparing' });
expect(operations(id)).toHaveLength(3);
heartbeat(id, { ...ready, plutoniumCache: { revision: 5354, bytes: 25_000 } });
expect(preparation.reconcileHostPreparation(id).items.find(item => item.kind === 'plutonium')).toMatchObject({
state: 'ready', detail: expect.stringContaining('r5354')
});
const { closeDb } = await import('../../src/lib/server/db/index.js');
closeDb();
preparation.reconcileHostPreparation(id);
expect(operations(id)).toHaveLength(3);
});
it('refreshes for newer production releases and reconciles an interrupted refresh without duplicates', async () => {
const ready = { ...capable, runtimeImages: [runtime], plutoniumCache: { revision: 5354, bytes: 25_000 } };
const id = enroll(ready);
await release(5354);
preparation.reconcileHostPreparation(id);
expect(operations(id)).toEqual([]);
await release(5355);
preparation.reconcileHostPreparation(id);
const cache = operations(id)[0];
expect(cache.payload.revision).toBe(5355);
hosts.reportHostOperation(id, cache.id, { state: 'unknown' });
await release(5356);
heartbeat(id, ready);
const { closeDb } = await import('../../src/lib/server/db/index.js');
closeDb();
expect(preparation.reconcileHostPreparation(id).items.find(item => item.kind === 'plutonium')).toMatchObject({
state: 'preparing', detail: expect.stringContaining('reconcile interrupted')
});
expect(operations(id)).toHaveLength(1);
expect(hosts.pendingHostOperations(id, 'write').map(operation => operation.id)).toEqual([cache.id]);
hosts.reportHostOperation(id, cache.id, { state: 'completed' });
heartbeat(id, { ...ready, plutoniumCache: { revision: 5356, bytes: 26_000 } });
preparation.reconcileHostPreparation(id);
expect(operations(id)).toHaveLength(1);
await release(5355);
preparation.reconcileHostPreparation(id);
expect(operations(id)).toHaveLength(1);
});
it.each(['missing', 'stale', 'failed'] as const)('uses the agent production manifest when release evidence is %s', async evidence => {
if (evidence === 'stale') await release(5354, { checkedAt: new Date(Date.now() - 60 * 60_000).toISOString() });
if (evidence === 'failed') await release(5354, { error: 'Manifest unavailable' });
const id = enroll({ ...capable, runtimeImages: [runtime] });
preparation.reconcileHostPreparation(id);
expect(operations(id)[0]).toMatchObject({ kind: 'plutonium.prepare', payload: { revision: 0 } });
expect(hosts.listHostOperationHistory(1, 'important', { host: id }).operations[0].targetPath).toBe('Latest production revision');
const cached = enroll({ ...capable, runtimeImages: [runtime], plutoniumCache: { revision: 5353, bytes: 200 } });
preparation.reconcileHostPreparation(cached);
expect(operations(cached)).toEqual([]);
});
it('leaves agents without the cache capability unchanged', async () => {
await release(5355);
const id = enroll({ runtimeImages: [runtime], plutoniumCache: { revision: 5354, bytes: 200 } });
expect(preparation.reconcileHostPreparation(id).items.some(item => item.kind === 'plutonium')).toBe(false);
expect(operations(id)).toEqual([]);
});
it('backs off failed cache work without blocking launch readiness or newly published game files', async () => {
const version = await publish();
const ready = { ...capable, runtimeImages: [runtime], libraries: [{ id: version.id, state: 'ready' as const }] };
const id = enroll(ready);
verified(id, version);
await release(5354);
preparation.reconcileHostPreparation(id);
const cache = operations(id)[0];
hosts.reportHostOperation(id, cache.id, { state: 'failed', error: 'Plutonium CDN unavailable' });
const failedAt = Date.now();
heartbeat(id, ready);
const status = preparation.reconcileHostPreparation(id);
expect(status).toMatchObject({ state: 'ready' });
expect(status.items.find(item => item.kind === 'plutonium')).toMatchObject({
state: 'blocked', detail: expect.stringContaining('Automatic retry')
});
expect(hosts.requireReadyHost(id).readiness).toBe('ready');
expect(operations(id)).toHaveLength(1);
// A changed or expired release must not bypass a failed cache's backoff.
await release(5355);
preparation.reconcileHostPreparation(id);
expect(operations(id)).toHaveLength(1);
const latest = await publish();
preparation.reconcileHostPreparation(id);
expect(operations(id)[0]).toMatchObject({ kind: 'library.download', payload: { libraryVersionId: latest.id } });
verified(id, latest);
hosts.reportHostOperation(id, operations(id)[0].id, { state: 'completed' });
heartbeat(id, ready);
preparation.reconcileHostPreparation(id);
expect(operations(id)).toHaveLength(2);
vi.setSystemTime(failedAt + 60_000);
heartbeat(id, ready);
preparation.reconcileHostPreparation(id);
expect(operations(id)).toHaveLength(3);
expect(operations(id)[0]).toMatchObject({ kind: 'plutonium.prepare', payload: { revision: 5355 } });
});
it('waits for required library capacity before consuming space for the optional cache', async () => {
const version = await publish();
const id = enroll({ ...capable, 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([]);
});
it.each([null, [], {}, { revision: 0, bytes: 1 }, { revision: 1.5, bytes: 1 }, { revision: 1, bytes: -1 }, { revision: 1, bytes: 0.5 }])(
'rejects invalid cache inventory %j', cache => {
const id = enroll();
expect(() => heartbeat(id, { plutoniumCache: cache as HostInventory['plutoniumCache'] })).toThrow('Invalid Plutonium cache inventory');
}
);
it('retains only revision and byte count from cache inventory and clears an omitted cache', () => {
const id = enroll({ plutoniumCache: { revision: 5354, bytes: 0, localPath: '/private/cache' } as HostInventory['plutoniumCache'] });
expect(hosts.getHost(id)?.inventory.plutoniumCache).toEqual({ revision: 5354, bytes: 0 });
heartbeat(id);
expect(hosts.getHost(id)?.inventory.plutoniumCache).toBeUndefined();
});
});