Verify prepared runtime IDs at launch (Refs #93)

This commit is contained in:
2026-10-09 19:18:54 -04:00
parent 596b160be3
commit d1f581d6a8
5 changed files with 135 additions and 17 deletions
+20 -7
View File
@@ -35,7 +35,7 @@ import urllib.parse
import urllib.request import urllib.request
import uuid import uuid
VERSION = "1.8.6" VERSION = "1.8.7"
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
@@ -1643,7 +1643,20 @@ class Agent:
raise AgentError("Invalid runtime environment") raise AgentError("Invalid runtime environment")
# Runtime download is a separate preparation operation. A deployment # Runtime download is a separate preparation operation. A deployment
# uses the already prepared image even during a registry outage. # uses the already prepared image even during a registry outage.
self.docker.call("image", "inspect", image) image_id = self.docker.call("image", "inspect", "--format", "{{.Id}}", image).strip()
if not re.fullmatch(r"sha256:[a-f0-9]{64}", image_id):
raise AgentError("Docker did not report a valid runtime image ID")
try:
raw = managed_read(self.root / "agent", "runtime-images.json")
prepared = json.loads(raw) if raw is not None else {}
except (OSError, ValueError):
raise AgentError("Runtime preparation records are unavailable") from None
if not isinstance(prepared, dict):
raise AgentError("Runtime preparation records are invalid")
# The controller also authorizes its configured default without a pull
# record. Whenever preparation was recorded, enforce that exact image.
if image in prepared and prepared[image] != image_id:
raise AgentError("Prepared runtime image has changed. An admin must prepare it again before launching.")
files = payload.get("files", []) files = payload.get("files", [])
if not isinstance(files, list) or len(files) > 10000: if not isinstance(files, list) or len(files) > 10000:
raise Rejected("Invalid initial file list") raise Rejected("Invalid initial file list")
@@ -1656,7 +1669,7 @@ class Agent:
total += len(self.decode_file(item)) total += len(self.decode_file(item))
if total > 64 * 1024 * 1024: if total > 64 * 1024 * 1024:
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, image_id, port, bind, env
@staticmethod @staticmethod
def plutonium_manifest(data): def plutonium_manifest(data):
@@ -1890,7 +1903,7 @@ class Agent:
raise AgentError("Instance already exists with a different specification") raise AgentError("Instance already exists with a different specification")
return existing return existing
self.operation_phase = "validating-create-spec" self.operation_phase = "validating-create-spec"
game, library, image, port, bind, env = self.validate_spec(payload) game, library, image, image_id, port, bind, env = self.validate_spec(payload)
app = directory / "app" app = directory / "app"
self.operation_phase = "preparing-create-directory" self.operation_phase = "preparing-create-directory"
app.mkdir(parents=True, exist_ok=True) app.mkdir(parents=True, exist_ok=True)
@@ -1905,15 +1918,15 @@ class Agent:
atomic_bytes(env_path, "".join(f"{key}={value}\n" for key, value in env.items()).encode()) atomic_bytes(env_path, "".join(f"{key}={value}\n" for key, value in env.items()).encode())
name = self.container_name(instance_id, generation) name = self.container_name(instance_id, generation)
spec_sha = digest(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()) spec_sha = digest(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode())
# Do not let Docker fetch a replacement if the prepared image disappears # Use the validated immutable ID so retagging after validation cannot
# between validation and container creation. # substitute another image. A deleted image must not trigger a pull.
args = ["container", "create", "--pull=never", "--name", name, "--restart", "unless-stopped", "--label", LABEL + ".managed=true", "--label", LABEL + ".instance=" + instance_id, "--label", LABEL + ".generation=" + str(generation), "--label", LABEL + ".operation=" + (self.executing_id or "direct"), "--label", LABEL + ".spec-sha256=" + spec_sha, "--mount", f"type=bind,src={app},dst=/home/plutainer/app", "--mount", f"type=bind,src={library / 'content'},dst=/home/plutainer/gamefiles,readonly", "--env-file", str(env_path)] args = ["container", "create", "--pull=never", "--name", name, "--restart", "unless-stopped", "--label", LABEL + ".managed=true", "--label", LABEL + ".instance=" + instance_id, "--label", LABEL + ".generation=" + str(generation), "--label", LABEL + ".operation=" + (self.executing_id or "direct"), "--label", LABEL + ".spec-sha256=" + spec_sha, "--mount", f"type=bind,src={app},dst=/home/plutainer/app", "--mount", f"type=bind,src={library / 'content'},dst=/home/plutainer/gamefiles,readonly", "--env-file", str(env_path)]
if self.config.get("hostId"): if self.config.get("hostId"):
args.extend(["--label", LABEL + ".host=" + identifier(self.config["hostId"])]) args.extend(["--label", LABEL + ".host=" + identifier(self.config["hostId"])])
bind_arg = "[" + bind + "]" if ":" in bind else bind bind_arg = "[" + bind + "]" if ":" in bind else bind
for protocol in ("udp", "tcp"): for protocol in ("udp", "tcp"):
args.extend(["--publish", f"{bind_arg}:{port}:{port}/{protocol}"]) args.extend(["--publish", f"{bind_arg}:{port}:{port}/{protocol}"])
args.append(image) args.append(image_id)
self.check_creation_cancellation() self.check_creation_cancellation()
self.operation_phase = "creating-container" self.operation_phase = "creating-container"
self.checkpoint({"phase": "creating", "generation": generation, "specSha256": spec_sha}) self.checkpoint({"phase": "creating", "generation": generation, "specSha256": spec_sha})
+87 -1
View File
@@ -24,6 +24,7 @@ class FakeDocker:
self.fail_restart = False self.fail_restart = False
self.lose_reply_after = None self.lose_reply_after = None
self.started = 0 self.started = 0
self.image_id = "sha256:" + "1" * 64
def inspect(self, name): def inspect(self, name):
return self.containers.get(name) return self.containers.get(name)
@@ -34,6 +35,8 @@ class FakeDocker:
return json.dumps({"ServerVersion": "27.5.1"}) return json.dumps({"ServerVersion": "27.5.1"})
if args[:2] == ("image", "ls"): if args[:2] == ("image", "ls"):
return "ghcr.io/ayymoss/plutainer:latest\n" return "ghcr.io/ayymoss/plutainer:latest\n"
if args[:4] == ("image", "inspect", "--format", "{{.Id}}"):
return self.image_id
if args[:2] == ("container", "create"): if args[:2] == ("container", "create"):
labels = {args[index + 1].split("=", 1)[0]: args[index + 1].split("=", 1)[1] for index, arg in enumerate(args) if arg == "--label"} labels = {args[index + 1].split("=", 1)[0]: args[index + 1].split("=", 1)[1] for index, arg in enumerate(args) if arg == "--label"}
name = args[args.index("--name") + 1] name = args[args.index("--name") + 1]
@@ -989,7 +992,8 @@ class AgentTests(unittest.TestCase):
payload = self.create() payload = self.create()
creation = next(args for args in self.docker.calls if args[:2] == ("container", "create")) creation = next(args for args in self.docker.calls if args[:2] == ("container", "create"))
self.assertIn("--pull=never", creation) self.assertIn("--pull=never", creation)
self.assertEqual(creation[-1], payload["image"]) self.assertEqual(creation[-1], self.docker.image_id)
self.assertEqual(self.agent.spec(INSTANCE, 1)["image"], payload["image"])
self.assertFalse(any(args[:2] == ("image", "pull") for args in self.docker.calls)) self.assertFalse(any(args[:2] == ("image", "pull") for args in self.docker.calls))
def test_image_disappearing_after_validation_cannot_be_pulled_or_started(self): def test_image_disappearing_after_validation_cannot_be_pulled_or_started(self):
@@ -1008,6 +1012,88 @@ class AgentTests(unittest.TestCase):
self.assertEqual(self.docker.started, 0) self.assertEqual(self.docker.started, 0)
self.assertFalse(any(args[:2] == ("image", "pull") for args in self.docker.calls)) self.assertFalse(any(args[:2] == ("image", "pull") for args in self.docker.calls))
def test_retargeted_prepared_image_requires_a_new_admin_pull_before_creation(self):
image = "registry.example/custom-runtime:stable"
payload = {"game": "t5", "libraryId": self.library_id, "image": image, "port": 28960,
"rconPassword": "rcon-secret", "env": {"PLUTAINER_KEY": "server-secret"}}
self.assertEqual(self.agent.execute(self.operation("runtime.pull", {"image": image}, "prepare"))["state"], "completed")
prepared_id = self.docker.image_id
self.docker.image_id = "sha256:" + "2" * 64
original = self.docker.call
def call(*args, **kwargs):
if args[:2] == ("image", "ls"):
# The old image still exists, so cached-ID inventory alone
# cannot prove that this mutable tag is safe to launch.
return (f"registry.example/custom-runtime|stable|<none>|{self.docker.image_id}\n"
f"<none>|<none>|<none>|{prepared_id}\n")
return original(*args, **kwargs)
with patch.object(self.docker, "call", side_effect=call):
self.assertIn(image, self.agent.inventory()["preparedRuntimeImages"])
result = self.agent.execute(self.operation("instance.create", payload, "retargeted"))
self.assertEqual(result["state"], "failed")
self.assertIn("Prepared runtime image has changed", result["error"])
self.assertFalse(any(args[:2] == ("container", "create") for args in self.docker.calls))
self.assertEqual(self.docker.started, 0)
self.assertFalse((self.agent.instance_dir(INSTANCE, 1) / "environment").exists())
self.assertEqual(self.agent.execute(self.operation("runtime.pull", {"image": image}, "prepare-again"))["state"], "completed")
self.assertEqual(self.agent.execute(self.operation("instance.create", payload, "retry"))["state"], "completed")
creation = next(args for args in self.docker.calls if args[:2] == ("container", "create"))
self.assertEqual(creation[-1], self.docker.image_id)
def test_retag_after_validation_cannot_replace_the_image_used_for_creation(self):
image = "ghcr.io/ayymoss/plutainer:latest"
self.agent.dispatch(self.operation("runtime.pull", {"image": image}))
prepared_id = self.docker.image_id
original = self.agent.write_initial_files
def retag(*args):
self.docker.image_id = "sha256:" + "2" * 64
return original(*args)
with patch.object(self.agent, "write_initial_files", side_effect=retag):
payload = self.create()
creation = next(args for args in self.docker.calls if args[:2] == ("container", "create"))
self.assertIn("--pull=never", creation)
self.assertEqual(creation[-1], prepared_id)
self.assertNotEqual(creation[-1], self.docker.image_id)
self.assertEqual(self.agent.spec(INSTANCE, 1), payload)
# Retagging must not invalidate an already created immutable instance.
calls = list(self.docker.calls)
self.assertTrue(self.agent.create(INSTANCE, 1, payload)["exists"])
self.assertEqual(self.docker.calls, calls)
def test_rebuild_rejects_retargeted_image_before_stopping_existing_container(self):
image = "ghcr.io/ayymoss/plutainer:latest"
self.agent.dispatch(self.operation("runtime.pull", {"image": image}))
payload = self.create()
self.docker.image_id = "sha256:" + "2" * 64
self.docker.calls.clear()
result = self.agent.execute(self.operation("instance.rebuild", {"nextGeneration": 2, "spec": payload}, "rebuild"))
self.assertEqual(result["state"], "failed")
self.assertIn("Prepared runtime image has changed", result["error"])
self.assertFalse(any(args[:2] in (("container", "stop"), ("container", "rm"), ("container", "create")) for args in self.docker.calls))
self.assertTrue(self.docker.inspect(self.agent.container_name(INSTANCE, 1))["State"]["Running"])
def test_launch_rejects_invalid_image_ids_and_preparation_records(self):
payload = {"game": "t5", "libraryId": self.library_id, "image": "prepared:runtime", "port": 28960}
for image_id in ("", "prepared:runtime", "sha256:short"):
with self.subTest(image_id=image_id):
self.docker.image_id = image_id
with self.assertRaisesRegex(AgentError, "valid runtime image ID"):
self.agent.create(INSTANCE, 1, payload)
self.docker.image_id = "sha256:" + "1" * 64
aliases = self.agent.root / "agent/runtime-images.json"
for raw in ("invalid json", "[]", "null"):
with self.subTest(records=raw):
aliases.write_text(raw)
with self.assertRaisesRegex(AgentError, "Runtime preparation records"):
self.agent.create(INSTANCE, 1, payload)
self.assertFalse(any(args[:2] == ("container", "create") for args in self.docker.calls))
self.assertEqual(self.docker.started, 0)
def test_manifest_is_advertised_and_reconciles_as_a_read_operation(self): def test_manifest_is_advertised_and_reconciles_as_a_read_operation(self):
self.create() self.create()
operation = self.operation("files.manifest", {"roots": ["configs", "scripts"], "ignoreNames": [], "paths": ["configs/server.cfg"]}) operation = self.operation("files.manifest", {"roots": ["configs", "scripts"], "ignoreNames": [], "paths": ["configs/server.cfg"]})
+6
View File
@@ -253,6 +253,12 @@ still run the configured default, but must update before accepting custom runtim
Queued launches and migrations never trigger custom image pulls, and container Queued launches and migrations never trigger custom image pulls, and container
creation cannot implicitly pull a missing image. creation cannot implicitly pull a missing image.
Agent 1.8.7 also checks a runtime's current image ID against its recorded preparation
before creating or rebuilding a container. If another program retags a prepared
image, an admin must prepare it again from the host page before launching it.
Containers are created using the validated immutable image ID, so a tag change
after validation cannot substitute another image. Existing containers keep running.
### Resource samples and learned allowances ### Resource samples and learned allowances
Only provisioning reserves a host and port. Agent **1.4.6** fixes container Only provisioning reserves a host and port. Agent **1.4.6** fixes container
+20 -7
View File
@@ -35,7 +35,7 @@ import urllib.parse
import urllib.request import urllib.request
import uuid import uuid
VERSION = "1.8.6" VERSION = "1.8.7"
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
@@ -1643,7 +1643,20 @@ class Agent:
raise AgentError("Invalid runtime environment") raise AgentError("Invalid runtime environment")
# Runtime download is a separate preparation operation. A deployment # Runtime download is a separate preparation operation. A deployment
# uses the already prepared image even during a registry outage. # uses the already prepared image even during a registry outage.
self.docker.call("image", "inspect", image) image_id = self.docker.call("image", "inspect", "--format", "{{.Id}}", image).strip()
if not re.fullmatch(r"sha256:[a-f0-9]{64}", image_id):
raise AgentError("Docker did not report a valid runtime image ID")
try:
raw = managed_read(self.root / "agent", "runtime-images.json")
prepared = json.loads(raw) if raw is not None else {}
except (OSError, ValueError):
raise AgentError("Runtime preparation records are unavailable") from None
if not isinstance(prepared, dict):
raise AgentError("Runtime preparation records are invalid")
# The controller also authorizes its configured default without a pull
# record. Whenever preparation was recorded, enforce that exact image.
if image in prepared and prepared[image] != image_id:
raise AgentError("Prepared runtime image has changed. An admin must prepare it again before launching.")
files = payload.get("files", []) files = payload.get("files", [])
if not isinstance(files, list) or len(files) > 10000: if not isinstance(files, list) or len(files) > 10000:
raise Rejected("Invalid initial file list") raise Rejected("Invalid initial file list")
@@ -1656,7 +1669,7 @@ class Agent:
total += len(self.decode_file(item)) total += len(self.decode_file(item))
if total > 64 * 1024 * 1024: if total > 64 * 1024 * 1024:
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, image_id, port, bind, env
@staticmethod @staticmethod
def plutonium_manifest(data): def plutonium_manifest(data):
@@ -1890,7 +1903,7 @@ class Agent:
raise AgentError("Instance already exists with a different specification") raise AgentError("Instance already exists with a different specification")
return existing return existing
self.operation_phase = "validating-create-spec" self.operation_phase = "validating-create-spec"
game, library, image, port, bind, env = self.validate_spec(payload) game, library, image, image_id, port, bind, env = self.validate_spec(payload)
app = directory / "app" app = directory / "app"
self.operation_phase = "preparing-create-directory" self.operation_phase = "preparing-create-directory"
app.mkdir(parents=True, exist_ok=True) app.mkdir(parents=True, exist_ok=True)
@@ -1905,15 +1918,15 @@ class Agent:
atomic_bytes(env_path, "".join(f"{key}={value}\n" for key, value in env.items()).encode()) atomic_bytes(env_path, "".join(f"{key}={value}\n" for key, value in env.items()).encode())
name = self.container_name(instance_id, generation) name = self.container_name(instance_id, generation)
spec_sha = digest(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()) spec_sha = digest(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode())
# Do not let Docker fetch a replacement if the prepared image disappears # Use the validated immutable ID so retagging after validation cannot
# between validation and container creation. # substitute another image. A deleted image must not trigger a pull.
args = ["container", "create", "--pull=never", "--name", name, "--restart", "unless-stopped", "--label", LABEL + ".managed=true", "--label", LABEL + ".instance=" + instance_id, "--label", LABEL + ".generation=" + str(generation), "--label", LABEL + ".operation=" + (self.executing_id or "direct"), "--label", LABEL + ".spec-sha256=" + spec_sha, "--mount", f"type=bind,src={app},dst=/home/plutainer/app", "--mount", f"type=bind,src={library / 'content'},dst=/home/plutainer/gamefiles,readonly", "--env-file", str(env_path)] args = ["container", "create", "--pull=never", "--name", name, "--restart", "unless-stopped", "--label", LABEL + ".managed=true", "--label", LABEL + ".instance=" + instance_id, "--label", LABEL + ".generation=" + str(generation), "--label", LABEL + ".operation=" + (self.executing_id or "direct"), "--label", LABEL + ".spec-sha256=" + spec_sha, "--mount", f"type=bind,src={app},dst=/home/plutainer/app", "--mount", f"type=bind,src={library / 'content'},dst=/home/plutainer/gamefiles,readonly", "--env-file", str(env_path)]
if self.config.get("hostId"): if self.config.get("hostId"):
args.extend(["--label", LABEL + ".host=" + identifier(self.config["hostId"])]) args.extend(["--label", LABEL + ".host=" + identifier(self.config["hostId"])])
bind_arg = "[" + bind + "]" if ":" in bind else bind bind_arg = "[" + bind + "]" if ":" in bind else bind
for protocol in ("udp", "tcp"): for protocol in ("udp", "tcp"):
args.extend(["--publish", f"{bind_arg}:{port}:{port}/{protocol}"]) args.extend(["--publish", f"{bind_arg}:{port}:{port}/{protocol}"])
args.append(image) args.append(image_id)
self.check_creation_cancellation() self.check_creation_cancellation()
self.operation_phase = "creating-container" self.operation_phase = "creating-container"
self.checkpoint({"phase": "creating", "generation": generation, "specSha256": spec_sha}) self.checkpoint({"phase": "creating", "generation": generation, "specSha256": spec_sha})
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"version": "1.8.6", "version": "1.8.7",
"protocolVersion": 1, "protocolVersion": 1,
"platform": "debian-amd64", "platform": "debian-amd64",
"sha256": "79702dc8cf80d2521153bfda9241f7240285014fe746fc921f0a7793dd81212b", "sha256": "e773c907141c97eab2055b354306973b248a67109a51337f1f1d76d80aaa8701",
"updaterProtocolVersion": 1, "updaterProtocolVersion": 1,
"updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8" "updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8"
} }