Reuse verified artifacts during rebuilds (Refs #154)
This commit is contained in:
+36
-25
@@ -35,7 +35,7 @@ import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
VERSION = "1.10.0"
|
||||
VERSION = "1.10.1"
|
||||
LABEL = "io.electric-cherry"
|
||||
MAX_FILE = 32 * 1024 * 1024
|
||||
MANIFEST_MAX_ENTRIES = 10000
|
||||
@@ -1685,7 +1685,7 @@ class Agent:
|
||||
players = status_players(text) if command == "status" else None
|
||||
return {"ok": True, "text": text, "players": players}
|
||||
|
||||
def validate_spec(self, payload):
|
||||
def validate_spec(self, payload, *, prepared_files=None):
|
||||
game = payload.get("game")
|
||||
if game not in ("t5", "t6"):
|
||||
raise AgentError("Only T5 and T6 are supported")
|
||||
@@ -1728,16 +1728,22 @@ class Agent:
|
||||
files = self.initial_files(payload)
|
||||
if not isinstance(files, list) or len(files) > 10000:
|
||||
raise Rejected("Invalid initial file list")
|
||||
if prepared_files is not None:
|
||||
if set(prepared_files) != {item["path"] for item in files} or any(not isinstance(content, bytes) for content in prepared_files.values()):
|
||||
raise Rejected("Prepared files do not match the replacement specification")
|
||||
verified_files = {}
|
||||
total, paths = 0, set()
|
||||
for item in files:
|
||||
scoped(self.root / "agent" / "validation", item["path"])
|
||||
if item["path"] in paths:
|
||||
raise Rejected("Duplicate initial file path")
|
||||
paths.add(item["path"])
|
||||
total += len(self.decode_file(item))
|
||||
content = self.decode_file(item, content=None if prepared_files is None else prepared_files[item["path"]])
|
||||
total += len(content)
|
||||
if total > 64 * 1024 * 1024:
|
||||
raise Rejected("Initial files exceed the 64 MiB operation limit")
|
||||
return game, library, image, image_id, port, bind, env
|
||||
verified_files[item["path"]] = content
|
||||
return game, library, image, image_id, port, bind, env, verified_files
|
||||
|
||||
@staticmethod
|
||||
def plutonium_manifest(data):
|
||||
@@ -1956,7 +1962,7 @@ class Agent:
|
||||
managed_write(app, marker, raw, uid, gid)
|
||||
return True
|
||||
|
||||
def create(self, instance_id, generation, payload, *, before=None):
|
||||
def create(self, instance_id, generation, payload, *, before=None, prepared_files=None):
|
||||
self.operation_phase = "checking-instance"
|
||||
self.check_creation_cancellation()
|
||||
directory = self.instance_dir(instance_id, generation)
|
||||
@@ -1971,7 +1977,7 @@ class Agent:
|
||||
raise AgentError("Instance already exists with a different specification")
|
||||
return existing
|
||||
self.operation_phase = "validating-create-spec"
|
||||
game, library, image, image_id, port, bind, env = self.validate_spec(payload)
|
||||
game, library, image, image_id, port, bind, env, prepared_files = self.validate_spec(payload, prepared_files=prepared_files)
|
||||
app = directory / "app"
|
||||
self.operation_phase = "preparing-create-directory"
|
||||
app.mkdir(parents=True, exist_ok=True)
|
||||
@@ -1979,7 +1985,7 @@ class Agent:
|
||||
self.operation_phase = "seeding-runtime"
|
||||
self.seed_plutonium(app, image)
|
||||
self.operation_phase = "writing-create-files"
|
||||
self.write_initial_files(app, self.initial_files(payload))
|
||||
self.write_initial_files(app, prepared_files)
|
||||
self.operation_phase = "persisting-create-metadata"
|
||||
atomic_json(directory / "spec.json", payload)
|
||||
env_path = directory / "environment"
|
||||
@@ -2032,30 +2038,32 @@ class Agent:
|
||||
content = f'set tws_bank_token "{bank["token"]}"\n'.encode()
|
||||
return files + [{"path": path, "contentBase64": base64.b64encode(content).decode()}]
|
||||
|
||||
def write_initial_files(self, root, files):
|
||||
if not isinstance(files, list) or len(files) > 10000:
|
||||
raise AgentError("Invalid file list")
|
||||
for item in files:
|
||||
content = self.decode_file(item)
|
||||
managed_write(root, item["path"], content, int(self.config.get("runtimeUid", 1000)), int(self.config.get("runtimeGid", 1000)))
|
||||
def write_initial_files(self, root, prepared_files):
|
||||
for path, content in prepared_files.items():
|
||||
managed_write(root, path, content, int(self.config.get("runtimeUid", 1000)), int(self.config.get("runtimeGid", 1000)))
|
||||
|
||||
def decode_file(self, item):
|
||||
def decode_file(self, item, *, content=None):
|
||||
if "contentSha256" in item:
|
||||
sha = item["contentSha256"]
|
||||
if not isinstance(sha, str) or not re.fullmatch(r"[a-f0-9]{64}", sha):
|
||||
raise AgentError("Invalid file content hash")
|
||||
url = self.config["controller"].rstrip("/") + "/api/agent/blobs/" + sha
|
||||
with self.artifact_open("GET", url) as response:
|
||||
content = response.read(MAX_FILE + 1)
|
||||
if content is None:
|
||||
url = self.config["controller"].rstrip("/") + "/api/agent/blobs/" + sha
|
||||
with self.artifact_open("GET", url) as response:
|
||||
content = response.read(MAX_FILE + 1)
|
||||
if len(content) > MAX_FILE:
|
||||
raise AgentError("File exceeds staging size limit")
|
||||
if digest(content) != sha or item.get("sha256", sha) != sha:
|
||||
raise AgentError("Staged file checksum mismatch")
|
||||
return content
|
||||
try:
|
||||
content = base64.b64decode(item["contentBase64"], validate=True)
|
||||
decoded = base64.b64decode(item["contentBase64"], validate=True)
|
||||
except (ValueError, KeyError, TypeError):
|
||||
raise AgentError("Invalid encoded file content") from None
|
||||
if content is None:
|
||||
content = decoded
|
||||
elif content != decoded:
|
||||
raise Rejected("Prepared files do not match the replacement specification")
|
||||
if len(content) > MAX_FILE:
|
||||
raise AgentError("File exceeds staging size limit")
|
||||
if item.get("sha256") and item["sha256"] != digest(content):
|
||||
@@ -2190,13 +2198,16 @@ class Agent:
|
||||
if isinstance(next_generation, bool) or not isinstance(next_generation, int) or next_generation <= generation:
|
||||
raise AgentError("Rebuild must use a newer immutable generation")
|
||||
spec = payload.get("spec") or {key: value for key, value in payload.items() if key not in ("nextGeneration", "newGeneration", "unhealthyRecovery")}
|
||||
self.validate_spec(spec)
|
||||
# Keep the complete verified set on this call's stack only. Never
|
||||
# mutate the immutable spec or cache bytes on the shared agent:
|
||||
# held, failed and later operations must prepare their own files.
|
||||
*_, prepared_files = self.validate_spec(spec)
|
||||
if not override_safety:
|
||||
self.safe_to_disrupt(instance_id, generation, allow_unhealthy=unhealthy_recovery)
|
||||
if "autoscaling" in payload:
|
||||
self.authorize_autoscaling_removal(instance_id, generation, payload)
|
||||
if kind == "instance.rebuild" and payload.get("stageId"):
|
||||
before = self.rebuild_before(instance_id, generation, payload["stageId"], spec)
|
||||
before = self.rebuild_before(instance_id, generation, payload["stageId"], prepared_files)
|
||||
if not override_safety:
|
||||
self.safe_to_disrupt(instance_id, generation)
|
||||
self.checkpoint({"phase": "disrupting", "containerId": health.get("containerId"), "startedAt": health.get("startedAt"), **({"before": before} if kind == "instance.rebuild" and payload.get("stageId") else {})})
|
||||
@@ -2208,7 +2219,7 @@ class Agent:
|
||||
self.docker.call("container", "rm", name)
|
||||
self.settle_bank(instance_id, generation)
|
||||
if kind == "instance.rebuild":
|
||||
result = self.create(instance_id, next_generation, spec, before=before if payload.get("stageId") else None)
|
||||
result = self.create(instance_id, next_generation, spec, before=before if payload.get("stageId") else None, prepared_files=prepared_files)
|
||||
self.operation_phase = "removing-old-instance"
|
||||
old = self.instance_dir(instance_id, generation)
|
||||
if old.exists():
|
||||
@@ -2309,21 +2320,21 @@ class Agent:
|
||||
atomic_json(marker, manifest)
|
||||
return {"stageId": stage_id, "files": len(planned), "state": "staged"}
|
||||
|
||||
def rebuild_before(self, instance_id, generation, stage_id, spec):
|
||||
def rebuild_before(self, instance_id, generation, stage_id, prepared_files):
|
||||
"""Validate requested replacement bytes and snapshot the old generation before teardown."""
|
||||
directory = self.instance_dir(instance_id, generation)
|
||||
marker = directory / "stages" / identifier(stage_id) / "manifest.json"
|
||||
saved = json.loads(marker.read_text())
|
||||
if saved["state"] != "staged":
|
||||
raise Rejected("Rebuild requires an unactivated file stage")
|
||||
replacement = {item["path"]: item for item in self.initial_files(spec)}
|
||||
before = []
|
||||
for item in saved["files"]:
|
||||
target = replacement.get(item["path"])
|
||||
require_public_file(item["path"])
|
||||
target = prepared_files.get(item["path"])
|
||||
if item["delete"]:
|
||||
if target is not None:
|
||||
raise Rejected("Rebuild includes a file requested for removal")
|
||||
elif target is None or digest(self.decode_file(target)) != item["sha256"]:
|
||||
elif target is None or digest(target) != item["sha256"]:
|
||||
raise Rejected("Rebuild does not match staged file content")
|
||||
content = managed_read(directory / "app", item["path"])
|
||||
actual_sha = None if content is None else digest(content)
|
||||
|
||||
Reference in New Issue
Block a user