Verify prepared runtime IDs at launch (Refs #93)
This commit is contained in:
+20
-7
@@ -35,7 +35,7 @@ import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
VERSION = "1.8.6"
|
||||
VERSION = "1.8.7"
|
||||
LABEL = "io.electric-cherry"
|
||||
MAX_FILE = 32 * 1024 * 1024
|
||||
MANIFEST_MAX_ENTRIES = 10000
|
||||
@@ -1643,7 +1643,20 @@ class Agent:
|
||||
raise AgentError("Invalid runtime environment")
|
||||
# Runtime download is a separate preparation operation. A deployment
|
||||
# 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", [])
|
||||
if not isinstance(files, list) or len(files) > 10000:
|
||||
raise Rejected("Invalid initial file list")
|
||||
@@ -1656,7 +1669,7 @@ class Agent:
|
||||
total += len(self.decode_file(item))
|
||||
if total > 64 * 1024 * 1024:
|
||||
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
|
||||
def plutonium_manifest(data):
|
||||
@@ -1890,7 +1903,7 @@ class Agent:
|
||||
raise AgentError("Instance already exists with a different specification")
|
||||
return existing
|
||||
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"
|
||||
self.operation_phase = "preparing-create-directory"
|
||||
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())
|
||||
name = self.container_name(instance_id, generation)
|
||||
spec_sha = digest(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode())
|
||||
# Do not let Docker fetch a replacement if the prepared image disappears
|
||||
# between validation and container creation.
|
||||
# Use the validated immutable ID so retagging after validation cannot
|
||||
# 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)]
|
||||
if self.config.get("hostId"):
|
||||
args.extend(["--label", LABEL + ".host=" + identifier(self.config["hostId"])])
|
||||
bind_arg = "[" + bind + "]" if ":" in bind else bind
|
||||
for protocol in ("udp", "tcp"):
|
||||
args.extend(["--publish", f"{bind_arg}:{port}:{port}/{protocol}"])
|
||||
args.append(image)
|
||||
args.append(image_id)
|
||||
self.check_creation_cancellation()
|
||||
self.operation_phase = "creating-container"
|
||||
self.checkpoint({"phase": "creating", "generation": generation, "specSha256": spec_sha})
|
||||
|
||||
@@ -24,6 +24,7 @@ class FakeDocker:
|
||||
self.fail_restart = False
|
||||
self.lose_reply_after = None
|
||||
self.started = 0
|
||||
self.image_id = "sha256:" + "1" * 64
|
||||
|
||||
def inspect(self, name):
|
||||
return self.containers.get(name)
|
||||
@@ -34,6 +35,8 @@ class FakeDocker:
|
||||
return json.dumps({"ServerVersion": "27.5.1"})
|
||||
if args[:2] == ("image", "ls"):
|
||||
return "ghcr.io/ayymoss/plutainer:latest\n"
|
||||
if args[:4] == ("image", "inspect", "--format", "{{.Id}}"):
|
||||
return self.image_id
|
||||
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"}
|
||||
name = args[args.index("--name") + 1]
|
||||
@@ -989,7 +992,8 @@ class AgentTests(unittest.TestCase):
|
||||
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], 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))
|
||||
|
||||
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.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):
|
||||
self.create()
|
||||
operation = self.operation("files.manifest", {"roots": ["configs", "scripts"], "ignoreNames": [], "paths": ["configs/server.cfg"]})
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
Only provisioning reserves a host and port. Agent **1.4.6** fixes container
|
||||
|
||||
@@ -35,7 +35,7 @@ import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
VERSION = "1.8.6"
|
||||
VERSION = "1.8.7"
|
||||
LABEL = "io.electric-cherry"
|
||||
MAX_FILE = 32 * 1024 * 1024
|
||||
MANIFEST_MAX_ENTRIES = 10000
|
||||
@@ -1643,7 +1643,20 @@ class Agent:
|
||||
raise AgentError("Invalid runtime environment")
|
||||
# Runtime download is a separate preparation operation. A deployment
|
||||
# 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", [])
|
||||
if not isinstance(files, list) or len(files) > 10000:
|
||||
raise Rejected("Invalid initial file list")
|
||||
@@ -1656,7 +1669,7 @@ class Agent:
|
||||
total += len(self.decode_file(item))
|
||||
if total > 64 * 1024 * 1024:
|
||||
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
|
||||
def plutonium_manifest(data):
|
||||
@@ -1890,7 +1903,7 @@ class Agent:
|
||||
raise AgentError("Instance already exists with a different specification")
|
||||
return existing
|
||||
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"
|
||||
self.operation_phase = "preparing-create-directory"
|
||||
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())
|
||||
name = self.container_name(instance_id, generation)
|
||||
spec_sha = digest(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode())
|
||||
# Do not let Docker fetch a replacement if the prepared image disappears
|
||||
# between validation and container creation.
|
||||
# Use the validated immutable ID so retagging after validation cannot
|
||||
# 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)]
|
||||
if self.config.get("hostId"):
|
||||
args.extend(["--label", LABEL + ".host=" + identifier(self.config["hostId"])])
|
||||
bind_arg = "[" + bind + "]" if ":" in bind else bind
|
||||
for protocol in ("udp", "tcp"):
|
||||
args.extend(["--publish", f"{bind_arg}:{port}:{port}/{protocol}"])
|
||||
args.append(image)
|
||||
args.append(image_id)
|
||||
self.check_creation_cancellation()
|
||||
self.operation_phase = "creating-container"
|
||||
self.checkpoint({"phase": "creating", "generation": generation, "specSha256": spec_sha})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": "1.8.6",
|
||||
"version": "1.8.7",
|
||||
"protocolVersion": 1,
|
||||
"platform": "debian-amd64",
|
||||
"sha256": "79702dc8cf80d2521153bfda9241f7240285014fe746fc921f0a7793dd81212b",
|
||||
"sha256": "e773c907141c97eab2055b354306973b248a67109a51337f1f1d76d80aaa8701",
|
||||
"updaterProtocolVersion": 1,
|
||||
"updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user