Compare commits

..
2 Commits
Author SHA1 Message Date
josh ce6dba17e6 Preserve fleet match timing across empty telemetry
CI / checks (push) Successful in 4m46s
2026-10-09 19:41:55 -04:00
josh d1f581d6a8 Verify prepared runtime IDs at launch (Refs #93) 2026-10-09 19:18:54 -04:00
17 changed files with 306 additions and 70 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
@@ -190,6 +190,12 @@ an end closes the match. Receipt order determines the latest report. This small
protocol does not attempt to identify delayed events from earlier games or protocol does not attempt to identify delayed events from earlier games or
provide exactly-once delivery across games. provide exactly-once delivery across games.
Match timing is independent of player count: an empty `game_start` still records
the start, and empty rosters or RCON polls do not erase it. Fleet can display a
fresh reported round even when the start was missed; match duration stays unknown
in that case. When match history identifies a new match from subsequent gameplay,
Fleet clears the previous match's timing rather than carrying it into the new one.
Reports also feed the persistent [Leaderboard](leaderboard.md), independently Reports also feed the persistent [Leaderboard](leaderboard.md), independently
of beta activity. That page documents cumulative stat accounting, round credit, of beta activity. That page documents cumulative stat accounting, round credit,
and the limits of identifying matches from this protocol. and the limits of identifying matches from this protocol.
+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
+1 -2
View File
@@ -105,14 +105,13 @@ export function currentRound(
(report.event !== 'round_change' && report.event !== 'heartbeat') || (report.event !== 'round_change' && report.event !== 'heartbeat') ||
!freshServer(server, now) || !freshServer(server, now) ||
!server.active_since || !server.active_since ||
!server.game_started_at ||
server.game_ended_at || server.game_ended_at ||
(server.last_players ?? 0) === 0 (server.last_players ?? 0) === 0
) )
return null; return null;
const received = Date.parse(report.receivedAt); const received = Date.parse(report.receivedAt);
return freshTelemetry(report, now, server.telemetry_after) && return freshTelemetry(report, now, server.telemetry_after) &&
received >= Date.parse(server.game_started_at) && (!server.game_started_at || received >= Date.parse(server.game_started_at)) &&
received >= Date.parse(server.active_since) received >= Date.parse(server.active_since)
? report.round ? report.round
: null; : null;
+2 -2
View File
@@ -176,8 +176,8 @@ export function selectServerObservation<T extends ServerObservation>(
})), })),
active_since: players > 0 ? timing.activeSince : null, active_since: players > 0 ? timing.activeSince : null,
inactive_since: players === 0 ? timing.inactiveSince : null, inactive_since: players === 0 ? timing.inactiveSince : null,
game_started_at: players === 0 ? null : timing.gameStartedAt, game_started_at: timing.gameStartedAt,
game_ended_at: players === 0 ? null : timing.gameEndedAt game_ended_at: timing.gameEndedAt
}; };
} }
// A current authenticated RCON reply can establish game liveness when // A current authenticated RCON reply can establish game liveness when
+2 -4
View File
@@ -20,15 +20,13 @@ export function successfulStatusFields(
}; };
} }
/** Occupancy timers and match clearing follow the same source as the UI. */ /** Occupancy follows the UI's source; only gameplay evidence changes match timing. */
export function activityFields(current: ServerRow, status: ServerObservation): Partial<ServerRow> { export function activityFields(current: ServerRow, status: ServerObservation): Partial<ServerRow> {
if (status.health !== 'online' || status.last_players === null) return {}; if (status.health !== 'online' || status.last_players === null) return {};
return status.last_players > 0 return status.last_players > 0
? { active_since: current.active_since ?? status.last_seen_at, inactive_since: null } ? { active_since: current.active_since ?? status.last_seen_at, inactive_since: null }
: { : {
active_since: null, active_since: null,
inactive_since: current.inactive_since ?? status.last_seen_at, inactive_since: current.inactive_since ?? status.last_seen_at
game_started_at: null,
game_ended_at: null
}; };
} }
+41 -32
View File
@@ -25,46 +25,55 @@ export function saveTelemetry(report: GameTelemetry): GameTelemetry {
? JSON.parse(row.payload) ? JSON.parse(row.payload)
: undefined; : undefined;
recordLeaderboardTelemetry(handle, report); recordLeaderboardTelemetry(handle, report);
// Record each occupancy transition at receipt, before a heartbeat can const match = handle
// replace game_start or an empty report between status polls.
const after = latestGameSubmission(report.server);
if (!after || Date.parse(report.receivedAt) > Date.parse(after)) {
if (report.players.length > 0) {
handle
.prepare( .prepare(
'UPDATE servers SET active_since = COALESCE(active_since, ?), inactive_since = NULL WHERE name = ?' 'SELECT id, started_at, start_observed FROM leaderboard_matches WHERE server = ? ORDER BY id DESC LIMIT 1'
) )
.run(report.receivedAt, report.server); .get(report.server) as { id: number; started_at: string; start_observed: number };
if (report.event === 'game_start') { const sameMatch = previous?.timing?.matchId === match.id;
handle
.prepare(
'UPDATE servers SET game_started_at = ?, game_ended_at = NULL WHERE name = ?'
)
.run(report.receivedAt, report.server);
} else if (report.event === 'game_end') {
handle
.prepare('UPDATE servers SET game_ended_at = ? WHERE name = ?')
.run(report.receivedAt, report.server);
}
} else {
handle
.prepare(
`UPDATE servers SET active_since = NULL, inactive_since = COALESCE(inactive_since, ?),
game_started_at = NULL, game_ended_at = NULL WHERE name = ?`
)
.run(report.receivedAt, report.server);
}
}
const serverTiming = handle const serverTiming = handle
.prepare( .prepare(
`SELECT active_since AS activeSince, inactive_since AS inactiveSince, `SELECT active_since AS activeSince, inactive_since AS inactiveSince,
game_started_at AS gameStartedAt, game_ended_at AS gameEndedAt FROM servers WHERE name = ?` game_started_at AS gameStartedAt, game_ended_at AS gameEndedAt FROM servers WHERE name = ?`
) )
.get(report.server) as ServerTiming; .get(report.server) as ServerTiming;
const match = handle // Record each occupancy transition at receipt, before a heartbeat can
.prepare('SELECT id FROM leaderboard_matches WHERE server = ? ORDER BY id DESC LIMIT 1') // replace game_start or an empty report between status polls.
.get(report.server) as { id: number }; const after = latestGameSubmission(report.server);
const sameMatch = previous?.timing?.matchId === match.id; if (!after || Date.parse(report.receivedAt) > Date.parse(after)) {
const occupied = report.players.length > 0;
serverTiming.activeSince = occupied
? (serverTiming.activeSince ?? report.receivedAt)
: null;
serverTiming.inactiveSince = occupied
? null
: (serverTiming.inactiveSince ?? report.receivedAt);
// Match boundaries follow history, independently of roster occupancy.
// Recover starts discarded by older receivers only when a start was
// actually observed; an inferred match has no known start time.
const startedAt =
report.event === 'game_start'
? report.receivedAt
: ((sameMatch ? serverTiming.gameStartedAt : null) ??
(match.start_observed ? match.started_at : null));
serverTiming.gameStartedAt =
startedAt && (!after || Date.parse(startedAt) > Date.parse(after))
? startedAt
: null;
serverTiming.gameEndedAt = report.event === 'game_end' ? report.receivedAt : null;
handle
.prepare(
`UPDATE servers SET active_since = ?, inactive_since = ?,
game_started_at = ?, game_ended_at = ? WHERE name = ?`
)
.run(
serverTiming.activeSince,
serverTiming.inactiveSince,
serverTiming.gameStartedAt,
serverTiming.gameEndedAt,
report.server
);
}
// A gap beyond the normal heartbeat window cannot establish continuity. // A gap beyond the normal heartbeat window cannot establish continuity.
const continuous = const continuous =
previous && Date.parse(report.receivedAt) - Date.parse(previous.receivedAt) <= 120_000; previous && Date.parse(report.receivedAt) - Date.parse(previous.receivedAt) <= 120_000;
+2 -2
View File
@@ -100,10 +100,10 @@
: null : null
); );
const currentRound = $derived( const currentRound = $derived(
!gameEndedAt ? (round !== undefined ? round : reportedRound) : null players !== 0 && !gameEndedAt ? (round !== undefined ? round : reportedRound) : null
); );
const gameElapsed = $derived( const gameElapsed = $derived(
gameStartedAt && !gameEndedAt ? elapsedSince(gameStartedAt, $uiNow) : null players !== 0 && gameStartedAt && !gameEndedAt ? elapsedSince(gameStartedAt, $uiNow) : null
); );
const emptyElapsed = $derived(elapsedSince(inactiveSince, $uiNow)); const emptyElapsed = $derived(elapsedSince(inactiveSince, $uiNow));
// These lobbies have four slots when the live status omits the limit. // These lobbies have four slots when the live status omits the limit.
+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"
} }
+8
View File
@@ -119,6 +119,14 @@ describe('Fleet discovery and match freshness', () => {
(event) => { (event) => {
const report = { ...gameEvent(event, 38, 4, server.name), receivedAt: iso(-1000) }; const report = { ...gameEvent(event, 38, 4, server.name), receivedAt: iso(-1000) };
expect(currentRound(server, report, now)).toBe(38); expect(currentRound(server, report, now)).toBe(38);
const unknownStart = { ...server, game_started_at: null };
expect(currentRound(unknownStart, report, now)).toBe(38);
expect(
currentRound({ ...unknownStart, telemetry_after: iso() }, report, now)
).toBeNull();
expect(
currentRound(unknownStart, { ...report, receivedAt: iso(-121_000) }, now)
).toBeNull();
for (const change of [ for (const change of [
{ receivedAt: iso(-121_000) }, { receivedAt: iso(-121_000) },
{ receivedAt: iso(-61_000) }, { receivedAt: iso(-61_000) },
+3 -3
View File
@@ -434,7 +434,7 @@ describe('shared server observation', () => {
expect(freshTelemetry({ ...report(), players: [{}] } as never, now)).toBe(false); expect(freshTelemetry({ ...report(), players: [{}] } as never, now)).toBe(false);
}); });
it('uses game-start rosters without inventing scores and clears match times on an empty report', () => { it('uses game-start rosters without inventing scores and preserves match times on an empty report', () => {
const start = { ...gameEvent('game_start', 0, 2, 'managed'), receivedAt: at() }; const start = { ...gameEvent('game_start', 0, 2, 'managed'), receivedAt: at() };
const active = selectServerObservation(server(), start, now); const active = selectServerObservation(server(), start, now);
expect(active.roster?.[0]).toMatchObject({ score: null, ping: null }); expect(active.roster?.[0]).toMatchObject({ score: null, ping: null });
@@ -444,8 +444,8 @@ describe('shared server observation', () => {
).toMatchObject({ ).toMatchObject({
last_players: 0, last_players: 0,
roster: [], roster: [],
game_started_at: null, game_started_at: at(),
game_ended_at: null game_ended_at: at()
}); });
}); });
}); });
+52 -2
View File
@@ -608,7 +608,7 @@ describe('database fleet and container readiness', () => {
} }
); );
it('keeps game_end through occupied polls until game_start or an empty lobby', async () => { it('keeps game_end through RCON polls and recovers when telemetry observes a new match', async () => {
const { inspect, rcon, fanoutStatus, getServer } = await setup(); const { inspect, rcon, fanoutStatus, getServer } = await setup();
inspect.mockResolvedValue({ ok: true, state: 'running' }); inspect.mockResolvedValue({ ok: true, state: 'running' });
const reply = (players: number) => ({ const reply = (players: number) => ({
@@ -621,9 +621,11 @@ describe('database fleet and container readiness', () => {
await postTelemetry(gameEvent('game_end', 10, 1, 'managed')); await postTelemetry(gameEvent('game_end', 10, 1, 'managed'));
const ended = getServer('managed')?.game_ended_at; const ended = getServer('managed')?.game_ended_at;
expect(ended).toEqual(expect.any(String)); expect(ended).toEqual(expect.any(String));
await postTelemetry(gameEvent('heartbeat', 10, 1, 'managed'));
await fanoutStatus(); await fanoutStatus();
expect(getServer('managed')?.game_ended_at).toBe(ended); expect(getServer('managed')?.game_ended_at).toBe(ended);
await postTelemetry(gameEvent('heartbeat', 1, 1, 'managed'));
await fanoutStatus();
expect(getServer('managed')).toMatchObject({ game_started_at: null, game_ended_at: null });
await postTelemetry(gameEvent('game_start', 0, 1, 'managed')); await postTelemetry(gameEvent('game_start', 0, 1, 'managed'));
expect(getServer('managed')?.game_ended_at).toBeNull(); expect(getServer('managed')?.game_ended_at).toBeNull();
await postTelemetry(gameEvent('game_end', 10, 1, 'managed')); await postTelemetry(gameEvent('game_end', 10, 1, 'managed'));
@@ -638,6 +640,54 @@ describe('database fleet and container readiness', () => {
expect(getServer('managed')?.game_ended_at).toBeNull(); expect(getServer('managed')?.game_ended_at).toBeNull();
}); });
it.each([0, 1])(
'preserves a match starting with %i players through empty reports, polls, a gap, and reload',
async (startPlayers) => {
const { inspect, rcon, fanoutStatus, getServer } = await setup();
const { GET } = await import('../../src/routes/api/servers/+server.js');
const { currentRound } = await import('../../src/lib/fleet-overview.js');
const { closeDb } = await import('../../src/lib/server/db/index.js');
const beginning = Date.parse('2026-10-09T12:00:00Z');
const at = (seconds: number) => new Date(beginning + seconds * 1000).toISOString();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(beginning);
inspect.mockResolvedValue({ ok: true, state: 'running' });
rcon.mockResolvedValue({
ok: true,
endpoint: { host: 'localhost', port: 28960 },
status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] }
});
const start = await (
await postTelemetry(gameEvent('game_start', 0, startPlayers, 'managed'))
).json();
expect(start.telemetry.timing.server.gameStartedAt).toBe(at(0));
vi.setSystemTime(beginning + 1000);
await postTelemetry(gameEvent('round_change', 1, 0, 'managed'));
for (const seconds of [30, 121]) {
vi.setSystemTime(beginning + seconds * 1000);
await fanoutStatus();
expect(getServer('managed')).toMatchObject({
active_since: null,
game_started_at: at(0),
game_ended_at: null
});
}
vi.setSystemTime(beginning + 180_000);
await postTelemetry(gameEvent('heartbeat', 4, 1, 'managed'));
await fanoutStatus();
closeDb();
const server = (await (await GET()).json()).servers[0];
expect(server).toMatchObject({
health: 'online',
last_players: 1,
active_since: at(180),
game_started_at: at(0),
game_ended_at: null
});
expect(currentRound(server, server.telemetry, Date.now())).toBe(4);
}
);
it('keeps the first occupied report as the active timer when more telemetry arrives before polling', async () => { it('keeps the first occupied report as the active timer when more telemetry arrives before polling', async () => {
const { inspect, rcon, fanoutStatus, getServer } = await setup(); const { inspect, rcon, fanoutStatus, getServer } = await setup();
const { GET } = await import('../../src/routes/api/servers/+server.js'); const { GET } = await import('../../src/routes/api/servers/+server.js');
+39
View File
@@ -18,6 +18,45 @@ afterEach(async () => fixture.cleanup());
const payload = gameEvent('round_change', 17); const payload = gameEvent('round_change', 17);
it.each(['game_end', 'round reset', 'map change'])(
'drops the previous match timing when game_start is missed after %s',
async (boundary) => {
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
const { getServer } = await import('../../src/lib/server/fleet/servers.js');
const at = (seconds: number) =>
new Date(Date.parse('2026-10-09T12:00:00Z') + seconds * 1000).toISOString();
saveTelemetry({ ...gameEvent('game_start', 0, 1), receivedAt: at(0) });
const previous = saveTelemetry({ ...gameEvent('heartbeat', 7, 1), receivedAt: at(30) });
if (boundary === 'game_end') {
const ended = saveTelemetry({ ...gameEvent('game_end', 7, 0), receivedAt: at(31) });
expect(ended.timing?.server?.gameEndedAt).toBe(at(31));
}
const next = saveTelemetry({
...gameEvent('heartbeat', boundary === 'map change' ? 8 : 1, 1),
...(boundary === 'map change' ? { map: 'zombie_moon' } : {}),
receivedAt: at(60)
});
expect(next.timing?.matchId).not.toBe(previous.timing?.matchId);
expect(next.timing?.server).toMatchObject({ gameStartedAt: null, gameEndedAt: null });
expect(getServer(payload.server)).toMatchObject({
game_started_at: null,
game_ended_at: null
});
}
);
it('repairs a previously discarded start from an observed match without inventing one', async () => {
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
const { db } = await import('../../src/lib/server/db/index.js');
const at = (seconds: number) =>
new Date(Date.parse('2026-10-09T12:00:00Z') + seconds * 1000).toISOString();
saveTelemetry({ ...gameEvent('game_start', 0, 0), receivedAt: at(0) });
// Simulate the old ingestion behavior for an empty start.
db().prepare('UPDATE servers SET game_started_at = NULL WHERE name = ?').run(payload.server);
const report = saveTelemetry({ ...gameEvent('heartbeat', 4, 1), receivedAt: at(120) });
expect(report.timing?.server?.gameStartedAt).toBe(at(0));
});
it('does not let a request received before a lifecycle submission restore cleared timers', async () => { it('does not let a request received before a lifecycle submission restore cleared timers', async () => {
const { db } = await import('../../src/lib/server/db/index.js'); const { db } = await import('../../src/lib/server/db/index.js');
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js'); const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
+9
View File
@@ -88,6 +88,15 @@ describe('compact server cards', () => {
expect(body).not.toContain('36ms'); expect(body).not.toContain('36ms');
}); });
it('shows a known round without a match clock and hides match metrics for an empty lobby', () => {
const occupied = html({ gameStartedAt: null, round: 31 });
expect(occupied).toMatch(/<dt[^>]*>Round<\/dt>\s*<dd[^>]*>31<\/dd>/);
expect(occupied).toMatch(/<dt[^>]*>Match time<\/dt>\s*<dd[^>]*>—<\/dd>/);
const empty = html({ players: 0, round: 31, compactEmpty: false });
expect(empty).toMatch(/<dt[^>]*>Round<\/dt>\s*<dd[^>]*>—<\/dd>/);
expect(empty).toMatch(/<dt[^>]*>Match time<\/dt>\s*<dd[^>]*>—<\/dd>/);
});
it('places Pin outside the only link and retains its pressed state and server name', () => { it('places Pin outside the only link and retains its pressed state and server name', () => {
const body = html({ pinned: true, onPin: () => {} }); const body = html({ pinned: true, onPin: () => {} });
expect(body).toMatch(/<article\b/); expect(body).toMatch(/<article\b/);
+4 -4
View File
@@ -460,26 +460,26 @@ describe('Fleet report clock', () => {
report('heartbeat', 34, 0, { report('heartbeat', 34, 0, {
activeSince: null, activeSince: null,
inactiveSince: at(34), inactiveSince: at(34),
gameStartedAt: null, gameStartedAt: at(32),
gameEndedAt: null gameEndedAt: null
}) })
).toMatchObject({ ).toMatchObject({
active_since: null, active_since: null,
inactive_since: at(34), inactive_since: at(34),
game_started_at: null, game_started_at: at(32),
game_ended_at: null game_ended_at: null
}); });
expect( expect(
report('heartbeat', 35, 1, { report('heartbeat', 35, 1, {
activeSince: at(35), activeSince: at(35),
inactiveSince: null, inactiveSince: null,
gameStartedAt: null, gameStartedAt: at(32),
gameEndedAt: null gameEndedAt: null
}) })
).toMatchObject({ ).toMatchObject({
active_since: at(35), active_since: at(35),
inactive_since: null, inactive_since: null,
game_started_at: null, game_started_at: at(32),
game_ended_at: null game_ended_at: null
}); });
}); });