Fix native health polling and isolate file operation traffic

This commit is contained in:
2026-10-04 21:50:29 -04:00
parent 8a31de92fa
commit 81f088739c
22 changed files with 446 additions and 44 deletions
+35 -11
View File
@@ -32,7 +32,7 @@ import urllib.parse
import urllib.request import urllib.request
import uuid import uuid
VERSION = "1.4.3" VERSION = "1.4.5"
LABEL = "io.electric-cherry" LABEL = "io.electric-cherry"
MAX_FILE = 32 * 1024 * 1024 MAX_FILE = 32 * 1024 * 1024
LIBRARY_DOWNLOAD_WORKERS = 4 LIBRARY_DOWNLOAD_WORKERS = 4
@@ -1092,7 +1092,7 @@ 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 = ["instance.cancel-create", "operations.read-lane"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else []) capabilities = ["instance.cancel-create", "operations.read-lane", "operations.health-lane"] + (["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, "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})} return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "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})}
def safe_to_disrupt(self, instance_id, generation): def safe_to_disrupt(self, instance_id, generation):
@@ -1126,11 +1126,20 @@ class Agent:
bind = saved.get("bindAddress", "0.0.0.0") bind = saved.get("bindAddress", "0.0.0.0")
host = "127.0.0.1" if bind in ("0.0.0.0", "") else "::1" if bind == "::" else bind host = "127.0.0.1" if bind in ("0.0.0.0", "") else "::1" if bind == "::" else bind
family = socket.AF_INET6 if ":" in host else socket.AF_INET family = socket.AF_INET6 if ":" in host else socket.AF_INET
# Retry only the read-only occupancy query. A missing UDP reply must
# never cause commands with effects to be replayed.
for attempt in range(3 if command == "status" else 1):
result = self.rcon_attempt(family, host, int(saved["port"]), password, command)
if result["ok"] or result.get("reason") not in ("timeout", "unreachable") or attempt == 2 or command != "status":
return result
time.sleep(0.25 * 2 ** attempt)
def rcon_attempt(self, family, host, port, password, command):
chunks = [] chunks = []
with socket.socket(family, socket.SOCK_DGRAM) as sock: with socket.socket(family, socket.SOCK_DGRAM) as sock:
sock.settimeout(3) sock.settimeout(3)
try: try:
sock.connect((host, int(saved["port"]))) sock.connect((host, port))
sock.send(b"\xff\xff\xff\xffrcon " + password.encode() + b" " + command.encode() + b"\n") sock.send(b"\xff\xff\xff\xffrcon " + password.encode() + b" " + command.encode() + b"\n")
while len(chunks) < 64 and sum(map(len, chunks)) < 1024 * 1024: while len(chunks) < 64 and sum(map(len, chunks)) < 1024 * 1024:
chunks.append(sock.recv(65535)) chunks.append(sock.recv(65535))
@@ -2016,7 +2025,11 @@ class Agent:
key = operation.get("instanceId") key = operation.get("instanceId")
return self.instance_locks.setdefault(key, threading.RLock()) if key else contextlib.nullcontext() return self.instance_locks.setdefault(key, threading.RLock()) if key else contextlib.nullcontext()
def read_loop(self): @staticmethod
def health_operation(operation):
return operation.get("kind") == "instance.inspect" or (operation.get("kind") == "rcon.execute" and operation.get("payload", {}).get("command") in ("status", "getstatus", "version"))
def read_loop(self, lane="read"):
# Each lane owns its SQLite connection and never writes executing_id, # Each lane owns its SQLite connection and never writes executing_id,
# checkpoints, or current_operation used by the mutation state machine. # checkpoints, or current_operation used by the mutation state machine.
journal = Journal(self.root / "agent" / "operations.sqlite3", recover=False) journal = Journal(self.root / "agent" / "operations.sqlite3", recover=False)
@@ -2026,10 +2039,13 @@ class Agent:
if self.update_pending(): if self.update_pending():
self.stopping.wait(3) self.stopping.wait(3)
continue continue
response = self.request("GET", "/api/agent/operations?lane=read&wait=20") response = self.request("GET", f"/api/agent/operations?lane={lane}&wait=20")
handled = False
for operation in response.get("operations", []): for operation in response.get("operations", []):
if self.stopping.is_set() or not self.read_operation(operation): if self.stopping.is_set() or not self.read_operation(operation):
continue continue
if lane == "health" and not self.health_operation(operation):
continue
with self.read_claim_lock: with self.read_claim_lock:
if operation["id"] in self.read_claims: if operation["id"] in self.read_claims:
continue continue
@@ -2038,19 +2054,26 @@ class Agent:
with self.activity(read_only=True) as acquired: with self.activity(read_only=True) as acquired:
if not acquired or self.update_pending(): if not acquired or self.update_pending():
continue continue
with self.read_gate.read(), self.instance_lock(operation): row, _ = journal.accept(operation)
row, _ = journal.accept(operation) if row["state"] in ("accepted", "unknown", "running"):
if row["state"] in ("accepted", "unknown", "running"): self.report(journal.update(operation["id"], "running"))
self.report(journal.update(operation["id"], "running")) with self.read_gate.read(), self.instance_lock(operation):
try: try:
result = self.dispatch(operation) result = self.dispatch(operation)
row = journal.update(operation["id"], "completed", result) row = journal.update(operation["id"], "completed", result)
except Exception as err: except Exception as err:
row = journal.update(operation["id"], "failed", error=str(err) if isinstance(err, AgentError) else "Observation unavailable") row = journal.update(operation["id"], "failed", error=str(err) if isinstance(err, AgentError) else "Observation unavailable")
self.report(row) # Receipt delivery can take seconds; it must not
# hold the instance lock needed by health checks.
self.report(row)
handled = True
finally: finally:
with self.read_claim_lock: with self.read_claim_lock:
self.read_claims.discard(operation["id"]) self.read_claims.discard(operation["id"])
if response.get("operations") and not handled:
# A legacy controller may return another lane's work or
# a read already claimed locally. Avoid a busy poll loop.
self.stopping.wait(0.25)
except (AgentError, OSError, ValueError, KeyError, sqlite3.Error): except (AgentError, OSError, ValueError, KeyError, sqlite3.Error):
self.stopping.wait(3) self.stopping.wait(3)
finally: finally:
@@ -2109,7 +2132,8 @@ class Agent:
self.stopping.wait(3) self.stopping.wait(3)
heartbeats = threading.Thread(target=self.heartbeat_loop, daemon=True) heartbeats = threading.Thread(target=self.heartbeat_loop, daemon=True)
heartbeats.start() heartbeats.start()
readers = [threading.Thread(target=self.read_loop, daemon=True) for _ in range(2)] # Preserve two concurrent health checks even while files are being read.
readers = [threading.Thread(target=self.read_loop, args=(lane,), daemon=True) for lane in ("health", "health", "read")]
for reader in readers: for reader in readers:
reader.start() reader.start()
while not self.stopping.is_set(): while not self.stopping.is_set():
+59 -1
View File
@@ -6,7 +6,7 @@ from pathlib import Path
import tempfile import tempfile
import threading import threading
import unittest import unittest
from unittest.mock import mock_open, patch from unittest.mock import MagicMock, call, mock_open, patch
spec = importlib.util.spec_from_file_location("ec_agent", Path(__file__).resolve().parents[1] / "ec_agent.py") spec = importlib.util.spec_from_file_location("ec_agent", Path(__file__).resolve().parents[1] / "ec_agent.py")
module = importlib.util.module_from_spec(spec) module = importlib.util.module_from_spec(spec)
@@ -995,6 +995,64 @@ class AgentTests(unittest.TestCase):
self.assertEqual(result["players"], 1) self.assertEqual(result["players"], 1)
self.assertTrue(result["ok"]) self.assertTrue(result["ok"])
@staticmethod
def rcon_socket(*replies):
sock = MagicMock()
sock.__enter__.return_value = sock
sock.recv.side_effect = replies
return sock
def test_udp_status_recovers_after_transient_failures(self):
self.create()
for error in (module.socket.timeout(), OSError("unreachable")):
with self.subTest(error=type(error).__name__):
failed = self.rcon_socket(error)
# Keep all response packets, including occupied player rows.
recovered = self.rcon_socket(
b"\xff\xff\xff\xffprint\nmap: mp_kino\nnum score ping guid name\n--- ----- ---- ---- ----\n",
b"\xff\xff\xff\xffprint\n0 42 CNCT abc player\n", module.socket.timeout())
with patch.object(module.socket, "socket", side_effect=[failed, recovered]) as sockets, patch.object(module.time, "sleep") as sleep:
result = self.agent.rcon(INSTANCE, 1, "status")
self.assertTrue(result["ok"])
self.assertEqual(result["players"], 1)
self.assertEqual(sockets.call_count, 2)
sleep.assert_called_once_with(0.25)
failed.__exit__.assert_called_once()
recovered.__exit__.assert_called_once()
def test_udp_status_retries_are_bounded(self):
self.create()
attempts = [self.rcon_socket(module.socket.timeout()) for _ in range(3)]
with patch.object(module.socket, "socket", side_effect=attempts) as sockets, patch.object(module.time, "sleep") as sleep:
result = self.agent.rcon(INSTANCE, 1, "status")
self.assertFalse(result["ok"])
self.assertEqual(result["reason"], "timeout")
self.assertEqual(sockets.call_count, 3)
self.assertEqual(sleep.call_args_list, [call(0.25), call(0.5)])
def test_udp_status_does_not_retry_bad_replies_or_partial_tables(self):
self.create()
for packet in (b"wrong listener", b"\xff\xff\xff\xffprint\nBad rcon_password",
b"\xff\xff\xff\xffprint\nmap: mp_kino\nnum score ping guid name\n"):
with self.subTest(packet=packet):
sock = self.rcon_socket(packet, module.socket.timeout())
with patch.object(module.socket, "socket", return_value=sock) as sockets, patch.object(module.time, "sleep") as sleep:
result = self.agent.rcon(INSTANCE, 1, "status")
self.assertIsNone(result["players"])
sockets.assert_called_once()
sleep.assert_not_called()
def test_udp_commands_with_effects_are_never_replayed(self):
self.create()
for command in ("map_restart", "kick 0", "version"):
with self.subTest(command=command):
sock = self.rcon_socket(module.socket.timeout())
with patch.object(module.socket, "socket", return_value=sock) as sockets, patch.object(module.time, "sleep") as sleep:
result = self.agent.rcon(INSTANCE, 1, command)
self.assertEqual(result["reason"], "timeout")
sockets.assert_called_once()
sleep.assert_not_called()
def test_t6_bot_column_allows_empty_restart_but_holds_players_and_partial_replies(self): def test_t6_bot_column_allows_empty_restart_but_holds_players_and_partial_replies(self):
self.create() self.create()
header = "map: zm_nuked\nnum score bot ping guid name lastmsg address qport rate\n" header = "map: zm_nuked\nnum score bot ping guid name lastmsg address qport rate\n"
+38
View File
@@ -118,6 +118,44 @@ class UpdateIntegrationTests(unittest.TestCase):
self.agent.read_loop() self.agent.read_loop()
dispatch.assert_not_called() dispatch.assert_not_called()
def test_health_lane_runs_while_file_receipt_for_same_instance_is_slow(self):
receipt_waiting, release_receipt, health_done = threading.Event(), threading.Event(), threading.Event()
file_op = {"id": "file-one", "kind": "files.read", "instanceId": "same", "payload": {}}
health_op = {"id": "health-one", "kind": "instance.inspect", "instanceId": "same", "payload": {}}
errors = []
def request(method, path, body=None):
if method == "GET":
return {"operations": [health_op if "lane=health" in path else file_op]}
if path.endswith("file-one") and body["state"] == "completed":
receipt_waiting.set()
release_receipt.wait(3)
return {}
def dispatch(operation):
if operation["id"] == "health-one":
health_done.set()
self.agent.stopping.set()
return {"ok": True}
def run(lane):
try: self.agent.read_loop(lane)
except Exception as error: errors.append(error)
self.agent.request, self.agent.dispatch = request, dispatch
files = threading.Thread(target=run, args=("read",))
health = threading.Thread(target=run, args=("health",))
files.start()
try:
self.assertTrue(receipt_waiting.wait(2))
health.start()
self.assertTrue(health_done.wait(2), "Health waited for a file receipt")
finally:
self.agent.stopping.set()
release_receipt.set()
files.join(3)
if health.ident is not None: health.join(3)
self.assertFalse(files.is_alive())
self.assertFalse(health.is_alive())
self.assertEqual(errors, [])
self.assertEqual(self.agent.journal.get("health-one")["state"], "completed")
def test_instance_mutations_and_global_mutations_exclude_observations(self): def test_instance_mutations_and_global_mutations_exclude_observations(self):
operation = {"instanceId": "one"} operation = {"instanceId": "one"}
entered = threading.Event() entered = threading.Event()
+5
View File
@@ -126,6 +126,11 @@ Drift includes missing promoted files, changed bytes, and extra files in managed
script, config, and plugin directories. Config comparison includes the composed script, config, and plugin directories. Config comparison includes the composed
baseline, overlays, and the selected map. Unmanaged default config is exempt until baseline, overlays, and the selected map. Unmanaged default config is exempt until
a baseline is promoted. Missing mounts are scan errors, not empty directories. a baseline is promoted. Missing mounts are scan errors, not empty directories.
Routine drift scans run every five minutes per server, reducing file traffic
while health observations continue every minute. **Check now**, a replacement
registration, or a new lifecycle submission bypasses the drift interval. Failed
file scans retry on the next observer pass; post-rebuild verification still reads
fresh files.
Drift repair creates a Campaign capturing observed differences and requests a Drift repair creates a Campaign capturing observed differences and requests a
Provisioning rebuild. The Campaign observes that rebuild without requesting Provisioning rebuild. The Campaign observes that rebuild without requesting
+15 -3
View File
@@ -5,6 +5,13 @@ instance never uses Komodo or a filesystem mounted into the controller. The
controller owns desired state, campaigns, collections, safety queues and history; controller owns desired state, campaigns, collections, safety queues and history;
the agent owns local execution and its durable operation journal. the agent owns local execution and its durable operation journal.
Fleet health checks query RCON even when container inspection is unavailable. A
fresh successful RCON reply can establish game health while container state stays
unknown; confirmed stopped, unhealthy, and restarting states still take precedence.
Agent 1.4.4 retries transient failures of the read-only `status` query up to three
times, with 250 ms and 500 ms delays. Commands that change the game are not retried.
Status replies still expire normally, and native timeout diagnostics are retained.
## Install and launch ## Install and launch
1. Run the controller with persistent `/data` storage. Set `EC_PUBLIC_URL` to its 1. Run the controller with persistent `/data` storage. Set `EC_PUBLIC_URL` to its
@@ -479,9 +486,14 @@ of which task delayed a previous deployment.
Campaign, beta and maintenance observers run independently of provisioning. Campaign, beta and maintenance observers run independently of provisioning.
New requests and completed agent commands wake provisioning; an arrival during a New requests and completed agent commands wake provisioning; an arrival during a
pass schedules another pass. File drift scans use bounded batches of four reads pass schedules another pass. Routine file drift scans run every five minutes;
per scan to reduce agent polling round trips. Startup and disruption safety gates manual checks bypass that interval. Overlapping identical native observations
remain enforced at command admission. share one operation until it completes, without caching results for later checks.
Agent 1.4.5 reserves two observation workers for instance health and read-only RCON,
and one for files, logs, and host inventory. Background reads arrive in batches of
up to four. File receipt uploads release the instance lock before contacting the
controller. Older agents retain the shared read lane until upgraded. Startup and
disruption safety gates remain enforced at command admission.
Older completed operations whose progress snapshots were already discarded Older completed operations whose progress snapshots were already discarded
cannot recover missing check results from this change. Their surviving events cannot recover missing check results from this change. Their surviving events
+5 -2
View File
@@ -43,7 +43,8 @@ export function selectServerObservation<T extends ServerObservation>(server: T,
if (server.telemetry && (!report || Date.parse(server.telemetry.receivedAt) > Date.parse(report.receivedAt))) report = server.telemetry; if (server.telemetry && (!report || Date.parse(server.telemetry.receivedAt) > Date.parse(report.receivedAt))) report = server.telemetry;
const raw = server.rcon_status ?? server; const raw = server.rcon_status ?? server;
const result: T = { ...server, ...raw, telemetry: report, status_source: raw.last_seen_at ? 'rcon' : null }; const result: T = { ...server, ...raw, telemetry: report, status_source: raw.last_seen_at ? 'rcon' : null };
if (['provisioning','starting'].includes(server.health) || (server.container_state && server.container_state !== 'running') || (!server.container_state && server.health === 'offline')) { const containerUnknown = server.container_state === 'unknown';
if (['provisioning','starting'].includes(server.health) || (server.container_state && server.container_state !== 'running' && !containerUnknown) || (!server.container_state && server.health === 'offline')) {
return { ...result, health: server.health, last_error: null, last_players: null, roster: null, status_source: null }; return { ...result, health: server.health, last_error: null, last_players: null, roster: null, status_source: null };
} }
const checked = server.container_checked_at ?? server.last_checked_at ?? raw.last_seen_at; const checked = server.container_checked_at ?? server.last_checked_at ?? raw.last_seen_at;
@@ -69,7 +70,9 @@ export function selectServerObservation<T extends ServerObservation>(server: T,
} }
const after = !server.telemetry_after || Date.parse(raw.last_seen_at ?? '') > Date.parse(server.telemetry_after); const after = !server.telemetry_after || Date.parse(raw.last_seen_at ?? '') > Date.parse(server.telemetry_after);
const fresh = after && freshStatusTimestamp(raw.last_seen_at, now) && !raw.last_error; const fresh = after && freshStatusTimestamp(raw.last_seen_at, now) && !raw.last_error;
const containerExpired = !!server.container_checked_at && !containerFresh; // A current authenticated RCON reply can establish game liveness when
// inspection is unavailable, without claiming the container was inspected.
const containerExpired = !containerUnknown && !!server.container_checked_at && !containerFresh;
const age = now - Date.parse(raw.last_seen_at ?? ''); const age = now - Date.parse(raw.last_seen_at ?? '');
return { ...result, health: fresh && !containerExpired ? 'online' return { ...result, health: fresh && !containerExpired ? 'online'
: after && age >= 0 && age <= 120_000 ? 'stale' : 'offline' }; : after && age >= 0 && age <= 120_000 ? 'stale' : 'offline' };
+19 -1
View File
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { getHost, queueHostOperation, waitHostOperation } from '../hosts/index.js'; import { getHost, queueHostOperation, waitHostOperation } from '../hosts/index.js';
import { trackActivity } from '../pipeline/activity.js'; import { trackActivity } from '../pipeline/activity.js';
import { latestGameSubmission } from '../fleet/lifecycle-evidence.js';
export interface NativeInstance { export interface NativeInstance {
id: string; server: string; host_id: string; generation: number; library_id: string; id: string; server: string; host_id: string; generation: number; library_id: string;
@@ -17,10 +18,27 @@ export function nativeDeployment(id: string): boolean {
return !!db().prepare('SELECT 1 FROM native_provisioning WHERE deployment_id = ?').get(id); return !!db().prepare('SELECT 1 FROM native_provisioning WHERE deployment_id = ?').get(id);
} }
/** Only the agent receives host paths, Docker access, or game credentials. */ const observations = new Map<string, Promise<unknown>>();
const readKinds = new Set(['instance.inspect', 'files.read', 'files.stat', 'files.list', 'logs.read']);
/** Share overlapping observations only, never completed results or mutations. */
export async function nativeRequest<T = Record<string, unknown>>(instance: NativeInstance, kind: string, payload: Record<string, unknown> = {}, key?: string): Promise<T> { export async function nativeRequest<T = Record<string, unknown>>(instance: NativeInstance, kind: string, payload: Record<string, unknown> = {}, key?: string): Promise<T> {
const host = getHost(instance.host_id); const host = getHost(instance.host_id);
if (!host || host.connectionState !== 'online') throw new Error('Host agent is offline; instance state is unknown.'); if (!host || host.connectionState !== 'online') throw new Error('Host agent is offline; instance state is unknown.');
const readOnly = readKinds.has(kind) || (kind === 'rcon.execute' && ['status', 'getstatus', 'version'].includes(String(payload.command)));
if (key || !readOnly) return executeRequest<T>(instance, kind, payload, key);
// A probe after a lifecycle submission must not join an old-process read.
const identity = JSON.stringify([instance.host_id, instance.id, instance.generation, instance.server,
latestGameSubmission(instance.server), kind, payload]);
const pending = observations.get(identity);
if (pending) return pending as Promise<T>;
const request = executeRequest<T>(instance, kind, payload).finally(() => { observations.delete(identity); });
observations.set(identity, request);
return request;
}
/** Only the agent receives host paths, Docker access, or game credentials. */
async function executeRequest<T>(instance: NativeInstance, kind: string, payload: Record<string, unknown>, key?: string): Promise<T> {
const operation = queueHostOperation(instance.host_id, kind, payload, { const operation = queueHostOperation(instance.host_id, kind, payload, {
instanceId: instance.id, generation: instance.generation, idempotencyKey: key ?? randomUUID() instanceId: instance.id, generation: instance.generation, idempotencyKey: key ?? randomUUID()
}); });
+1 -1
View File
@@ -11,7 +11,7 @@ const STALE_MAX_MS = 120_000;
*/ */
export function classifyHealth(lastSeenAt: string | null, lastError: string | null, containerState?: string | null): Health { export function classifyHealth(lastSeenAt: string | null, lastError: string | null, containerState?: string | null): Health {
if (containerState === 'starting' || containerState === 'restarting') return 'starting'; if (containerState === 'starting' || containerState === 'restarting') return 'starting';
if (containerState && containerState !== 'running') return 'offline'; if (containerState && containerState !== 'running' && containerState !== 'unknown') return 'offline';
if (!lastSeenAt) return 'offline'; if (!lastSeenAt) return 'offline';
const age = Date.now() - new Date(lastSeenAt).getTime(); const age = Date.now() - new Date(lastSeenAt).getTime();
if (freshStatusTimestamp(lastSeenAt, Date.now())) { if (freshStatusTimestamp(lastSeenAt, Date.now())) {
+1 -1
View File
@@ -49,7 +49,7 @@ export function serverObservation(server: ServerRow, evidence?: Evidence) {
last_gametype: server.last_gametype, last_seen_at: server.last_seen_at, last_error: server.last_error, roster }; last_gametype: server.last_gametype, last_seen_at: server.last_seen_at, last_error: server.last_error, roster };
const provisioning = evidence ? evidence.provisioning : isProvisioning(server.name); const provisioning = evidence ? evidence.provisioning : isProvisioning(server.name);
const state = (evidence ? evidence.settling : restartSettling(server.name)) ? 'restarting' : server.container_state; const state = (evidence ? evidence.settling : restartSettling(server.name)) ? 'restarting' : server.container_state;
const ready = !provisioning && (!state || state === 'running'); const ready = !provisioning && (!state || state === 'running' || state === 'unknown');
const health = provisioning ? 'provisioning' : classifyHealth(server.last_seen_at, server.last_error, state); const health = provisioning ? 'provisioning' : classifyHealth(server.last_seen_at, server.last_error, state);
return selectServerObservation({ ...server, ...rcon, rcon_status: rcon, container_state: state, health, return selectServerObservation({ ...server, ...rcon, rcon_status: rcon, container_state: state, health,
status_source: null as 'telemetry' | 'rcon' | null, status_source: null as 'telemetry' | 'rcon' | null,
+9 -3
View File
@@ -348,7 +348,7 @@ export function queueHostOperation(hostId: string, kind: string, payload: Record
queueMicrotask(() => workEvents.emit(hostId)); queueMicrotask(() => workEvents.emit(hostId));
return operation; return operation;
} }
export function pendingHostOperations(hostId: string, lane: 'all' | 'read' | 'write' = 'all'): HostOperation[] { export function pendingHostOperations(hostId: string, lane: 'all' | 'read' | 'health' | 'write' = 'all'): HostOperation[] {
const host = requireHost(hostId); const host = requireHost(hostId);
if (host.mode === 'disabled' || !host.connected) return []; if (host.mode === 'disabled' || !host.connected) return [];
// Older agents hold activity.lock across the entire delivered batch, including // Older agents hold activity.lock across the entire delivered batch, including
@@ -362,7 +362,13 @@ export function pendingHostOperations(hostId: string, lane: 'all' | 'read' | 'wr
// 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','files.read','files.list','files.stat','logs.read') const readOnly = `(kind IN ('host.inspect','instance.inspect','files.read','files.list','files.stat','logs.read')
OR (kind='rcon.execute' AND json_extract(payload,'$.command') IN ('status','getstatus','version')))`; OR (kind='rcon.execute' AND json_extract(payload,'$.command') IN ('status','getstatus','version')))`;
const laneFilter = lane === 'all' ? '' : ` AND ${lane === 'write' ? 'NOT ' : ''}${readOnly}`; const healthOnly = `(kind='instance.inspect' OR (kind='rcon.execute' AND json_extract(payload,'$.command') IN ('status','getstatus','version')))`;
const healthLane = host.inventory.capabilities?.includes('operations.health-lane');
const laneFilter = lane === 'all' ? '' : lane === 'health' ? ` AND ${healthOnly}`
: ` AND ${lane === 'write' ? 'NOT ' : ''}${readOnly}${lane === 'read' && healthLane ? ` AND NOT ${healthOnly}` : ''}`;
// Health never queues behind a delivered file batch. Keep background reads
// in small batches to amortize delivery without handing out a long backlog.
const limit = healthLane ? lane === 'health' ? 1 : lane === 'read' ? 4 : 25 : 25;
return database().transaction(() => { return database().transaction(() => {
const rows = database().prepare(`SELECT * FROM ec_host_operations o WHERE host_id=? AND state IN ('pending','accepted','running','unknown') const rows = database().prepare(`SELECT * FROM ec_host_operations o WHERE host_id=? AND state IN ('pending','accepted','running','unknown')
${laneFilter} ${laneFilter}
@@ -370,7 +376,7 @@ export function pendingHostOperations(hostId: string, lane: 'all' | 'read' | 'wr
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)
ORDER BY CASE WHEN kind='instance.cancel-create' THEN 0 ELSE 1 END,COALESCE(last_delivered_at,''),created_at,rowid LIMIT 25`) ORDER BY CASE WHEN kind='instance.cancel-create' THEN 0 ELSE 1 END,COALESCE(last_delivered_at,''),created_at,rowid LIMIT ${limit}`)
.all(hostId, Number(host.readiness === 'ready'), Number(host.inventory.capabilities?.includes('instance.cancel-create') ?? false)) as OperationRow[]; .all(hostId, Number(host.readiness === 'ready'), Number(host.inventory.capabilities?.includes('instance.cancel-create') ?? false)) as OperationRow[];
const deliveredAt = now(); const deliveredAt = now();
for (const operation of rows) database().prepare('UPDATE ec_host_operations SET last_delivered_at=? WHERE id=?').run(deliveredAt, operation.id); for (const operation of rows) database().prepare('UPDATE ec_host_operations SET last_delivered_at=? WHERE id=?').run(deliveredAt, operation.id);
+13 -1
View File
@@ -382,7 +382,19 @@ export async function rconStatusResult(server: string): Promise<RconStatusResult
const endpoint = { host: `agent:${native.host_id}`, port: native.port }; const endpoint = { host: `agent:${native.host_id}`, port: native.port };
const request = (async (): Promise<RconStatusResult> => { const request = (async (): Promise<RconStatusResult> => {
try { try {
const reply = await nativeRequest<{ text: string }>(native, 'rcon.execute', { command: 'status' }); const reply = await nativeRequest<{ ok?: boolean; reason?: string; text: string }>(native, 'rcon.execute', { command: 'status' });
if (reply.ok === false) {
const failures: Record<string, { reason: RconFailure; detail: string }> = {
unconfigured: { reason: 'no-config', detail: 'Agent RCON password is not configured.' },
unreachable: { reason: 'send-error', detail: 'Agent could not reach the local RCON endpoint.' },
timeout: { reason: 'timeout', detail: 'Agent received no reply from the local RCON endpoint.' },
authentication: { reason: 'bad-reply', detail: 'wrong RCON password' },
'invalid-reply': { reason: 'bad-reply', detail: 'Agent RCON reply is missing the 0xff prefix.' }
};
return { ok: false, endpoint, ...(failures[reply.reason ?? ''] ?? {
reason: 'bad-reply' as const, detail: 'Agent RCON query failed.'
}) };
}
const players = parsePlayerCount(reply.text); const players = parsePlayerCount(reply.text);
if (players === null) return { ok: false, endpoint, reason: 'bad-reply', detail: 'Agent returned an unrecognized RCON status.', rawReply: reply.text.slice(0, 4000) }; if (players === null) return { ok: false, endpoint, reason: 'bad-reply', detail: 'Agent returned an unrecognized RCON status.', rawReply: reply.text.slice(0, 4000) };
const info = parseInfoLine(reply.text); const header = parseStatusHeader(reply.text); const info = parseInfoLine(reply.text); const header = parseStatusHeader(reply.text);
+14 -2
View File
@@ -16,6 +16,7 @@ import type { WatchdogAction, WatchdogDetail, WatchdogEvidence, WatchdogIncident
import { maintenanceAllowed, setMaintenancePolicy } from './policies.js'; import { maintenanceAllowed, setMaintenancePolicy } from './policies.js';
export const WATCHDOG_SCAN_MS = 60_000; export const WATCHDOG_SCAN_MS = 60_000;
export const WATCHDOG_DRIFT_SCAN_MS = 5 * 60_000;
export const WATCHDOG_FAILURE_MS = 120_000; export const WATCHDOG_FAILURE_MS = 120_000;
export const WATCHDOG_RECOVERY_MS = 300_000; export const WATCHDOG_RECOVERY_MS = 300_000;
const iso = () => new Date().toISOString(); const iso = () => new Date().toISOString();
@@ -240,7 +241,7 @@ async function observe(server: string, operationId?: string): Promise<Observatio
return { healthy: false, actionable: rcon.reason === 'timeout' || rcon.reason === 'send-error', return { healthy: false, actionable: rcon.reason === 'timeout' || rcon.reason === 'send-error',
reason: `Game status unavailable: ${rcon.reason}; waiting for player-count verification before recovery`, containerState: state }; reason: `Game status unavailable: ${rcon.reason}; waiting for player-count verification before recovery`, containerState: state };
} }
async function scanServer(server: string): Promise<void> { async function scanServer(server: string, forceScan: boolean): Promise<void> {
const row = getServer(server); const row = getServer(server);
if (!row || row.desired_state === 'stopped') return; if (!row || row.desired_state === 'stopped') return;
if (scanSuppressed(server) || protectedWork(server)) return; if (scanSuppressed(server) || protectedWork(server)) return;
@@ -265,8 +266,19 @@ async function scanServer(server: string): Promise<void> {
} }
// Unknown container health is an observation failure, not proof that a rebuild is safe. // Unknown container health is an observation failure, not proof that a rebuild is safe.
if ((!observation.healthy && !observation.actionable) || scanSuppressed(server, 'drift')) return; if ((!observation.healthy && !observation.actionable) || scanSuppressed(server, 'drift')) return;
// File trees change far less often than liveness. Persist the last successful
// scan per registration/process so health checks do not rescan every minute.
// Manual checks and new lifecycle submissions always get fresh file evidence.
const driftKey = `watchdog_drift_scan:${server}`;
const instance = nativeInstance(server);
const identity = JSON.stringify([row.created_at, instance?.id, instance?.generation, latestGameSubmission(server)]);
let previous: { identity?: string; at?: string } = {};
try { previous = JSON.parse(setting(driftKey) ?? '{}'); } catch { /* rescan legacy/invalid state */ }
const age = Date.now() - Date.parse(previous?.at ?? '');
if (!forceScan && previous?.identity === identity && age >= 0 && age < WATCHDOG_DRIFT_SCAN_MS) return;
const prepared = await prepareDriftCampaign(server); const prepared = await prepareDriftCampaign(server);
if (scanSuppressed(server, 'drift') || protectedWork(server) || getServer(server)?.desired_state === 'stopped') return; if (scanSuppressed(server, 'drift') || protectedWork(server) || getServer(server)?.desired_state === 'stopped') return;
setSetting(driftKey, JSON.stringify({ identity, at: iso() }));
if (!prepared.plan.files.length) { if (!prepared.plan.files.length) {
const current = active(server, 'drift'); const current = active(server, 'drift');
if (current) transition(current.id, 'resolved', 'Server files match the promoted setup'); if (current) transition(current.id, 'resolved', 'Server files match the promoted setup');
@@ -305,7 +317,7 @@ async function run(forceScan: boolean): Promise<void> {
async function pump() { async function pump() {
while (cursor < servers.length) { while (cursor < servers.length) {
const server = servers[cursor++]; const server = servers[cursor++];
try { await scanServer(server.name); } try { await scanServer(server.name, forceScan); }
catch (err) { catch (err) {
if (!hasActiveProvisioning(server.name)) errors.push(`${server.name}: ${(err as Error).message}`); if (!hasActiveProvisioning(server.name)) errors.push(`${server.name}: ${(err as Error).message}`);
} }
+6 -2
View File
@@ -150,10 +150,14 @@ async function refreshServerStatus(server: string): Promise<number | null> {
if (unchanged()) { if (unchanged()) {
updates.container_state = container.ok ? 'running' : container.state?.toLowerCase() ?? 'unknown'; updates.container_state = container.ok ? 'running' : container.state?.toLowerCase() ?? 'unknown';
updates.container_checked_at = new Date().toISOString(); updates.container_checked_at = new Date().toISOString();
if (!container.ok) { // An unavailable inspection is not evidence that the game stopped.
// Probe it just as a manual RCON check would; explicit lifecycle or
// unhealthy states still take precedence over game replies.
const unavailable = updates.container_state === 'unknown';
if (!container.ok && !unavailable) {
updates.last_error = null; updates.last_error = null;
updates.last_reply_snippet = null; updates.last_reply_snippet = null;
} else if (!telemetryEvidence(srv.name)) { } else if (unavailable || !telemetryEvidence(srv.name)) {
flush(); flush();
const result = await rconStatusResult(srv.name).catch(err => ({ ok: false as const, const result = await rconStatusResult(srv.name).catch(err => ({ ok: false as const,
reason: 'send-error' as RconFailure, detail: (err as Error).message, rawReply: undefined })); reason: 'send-error' as RconFailure, detail: (err as Error).message, rawReply: undefined }));
+1 -1
View File
@@ -6,7 +6,7 @@ export async function GET({ request }: { request: Request }) {
const host = authenticateAgent(request); const host = authenticateAgent(request);
reconcileHostPreparation(host.id); reconcileHostPreparation(host.id);
const params = new URL(request.url).searchParams; const params = new URL(request.url).searchParams;
const lane = params.get('lane') === 'read' ? 'read' : params.get('lane') === 'write' ? 'write' : 'all'; const lane = params.get('lane') === 'health' ? 'health' : params.get('lane') === 'read' ? 'read' : params.get('lane') === 'write' ? 'write' : 'all';
let operations = pendingHostOperations(host.id, lane); let operations = pendingHostOperations(host.id, lane);
if (!operations.length && params.get('wait') === '20') { if (!operations.length && params.get('wait') === '20') {
await waitForHostWork(host.id, request.signal); await waitForHostWork(host.id, request.signal);
+35 -11
View File
@@ -32,7 +32,7 @@ import urllib.parse
import urllib.request import urllib.request
import uuid import uuid
VERSION = "1.4.3" VERSION = "1.4.5"
LABEL = "io.electric-cherry" LABEL = "io.electric-cherry"
MAX_FILE = 32 * 1024 * 1024 MAX_FILE = 32 * 1024 * 1024
LIBRARY_DOWNLOAD_WORKERS = 4 LIBRARY_DOWNLOAD_WORKERS = 4
@@ -1092,7 +1092,7 @@ 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 = ["instance.cancel-create", "operations.read-lane"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else []) capabilities = ["instance.cancel-create", "operations.read-lane", "operations.health-lane"] + (["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, "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})} return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "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})}
def safe_to_disrupt(self, instance_id, generation): def safe_to_disrupt(self, instance_id, generation):
@@ -1126,11 +1126,20 @@ class Agent:
bind = saved.get("bindAddress", "0.0.0.0") bind = saved.get("bindAddress", "0.0.0.0")
host = "127.0.0.1" if bind in ("0.0.0.0", "") else "::1" if bind == "::" else bind host = "127.0.0.1" if bind in ("0.0.0.0", "") else "::1" if bind == "::" else bind
family = socket.AF_INET6 if ":" in host else socket.AF_INET family = socket.AF_INET6 if ":" in host else socket.AF_INET
# Retry only the read-only occupancy query. A missing UDP reply must
# never cause commands with effects to be replayed.
for attempt in range(3 if command == "status" else 1):
result = self.rcon_attempt(family, host, int(saved["port"]), password, command)
if result["ok"] or result.get("reason") not in ("timeout", "unreachable") or attempt == 2 or command != "status":
return result
time.sleep(0.25 * 2 ** attempt)
def rcon_attempt(self, family, host, port, password, command):
chunks = [] chunks = []
with socket.socket(family, socket.SOCK_DGRAM) as sock: with socket.socket(family, socket.SOCK_DGRAM) as sock:
sock.settimeout(3) sock.settimeout(3)
try: try:
sock.connect((host, int(saved["port"]))) sock.connect((host, port))
sock.send(b"\xff\xff\xff\xffrcon " + password.encode() + b" " + command.encode() + b"\n") sock.send(b"\xff\xff\xff\xffrcon " + password.encode() + b" " + command.encode() + b"\n")
while len(chunks) < 64 and sum(map(len, chunks)) < 1024 * 1024: while len(chunks) < 64 and sum(map(len, chunks)) < 1024 * 1024:
chunks.append(sock.recv(65535)) chunks.append(sock.recv(65535))
@@ -2016,7 +2025,11 @@ class Agent:
key = operation.get("instanceId") key = operation.get("instanceId")
return self.instance_locks.setdefault(key, threading.RLock()) if key else contextlib.nullcontext() return self.instance_locks.setdefault(key, threading.RLock()) if key else contextlib.nullcontext()
def read_loop(self): @staticmethod
def health_operation(operation):
return operation.get("kind") == "instance.inspect" or (operation.get("kind") == "rcon.execute" and operation.get("payload", {}).get("command") in ("status", "getstatus", "version"))
def read_loop(self, lane="read"):
# Each lane owns its SQLite connection and never writes executing_id, # Each lane owns its SQLite connection and never writes executing_id,
# checkpoints, or current_operation used by the mutation state machine. # checkpoints, or current_operation used by the mutation state machine.
journal = Journal(self.root / "agent" / "operations.sqlite3", recover=False) journal = Journal(self.root / "agent" / "operations.sqlite3", recover=False)
@@ -2026,10 +2039,13 @@ class Agent:
if self.update_pending(): if self.update_pending():
self.stopping.wait(3) self.stopping.wait(3)
continue continue
response = self.request("GET", "/api/agent/operations?lane=read&wait=20") response = self.request("GET", f"/api/agent/operations?lane={lane}&wait=20")
handled = False
for operation in response.get("operations", []): for operation in response.get("operations", []):
if self.stopping.is_set() or not self.read_operation(operation): if self.stopping.is_set() or not self.read_operation(operation):
continue continue
if lane == "health" and not self.health_operation(operation):
continue
with self.read_claim_lock: with self.read_claim_lock:
if operation["id"] in self.read_claims: if operation["id"] in self.read_claims:
continue continue
@@ -2038,19 +2054,26 @@ class Agent:
with self.activity(read_only=True) as acquired: with self.activity(read_only=True) as acquired:
if not acquired or self.update_pending(): if not acquired or self.update_pending():
continue continue
with self.read_gate.read(), self.instance_lock(operation): row, _ = journal.accept(operation)
row, _ = journal.accept(operation) if row["state"] in ("accepted", "unknown", "running"):
if row["state"] in ("accepted", "unknown", "running"): self.report(journal.update(operation["id"], "running"))
self.report(journal.update(operation["id"], "running")) with self.read_gate.read(), self.instance_lock(operation):
try: try:
result = self.dispatch(operation) result = self.dispatch(operation)
row = journal.update(operation["id"], "completed", result) row = journal.update(operation["id"], "completed", result)
except Exception as err: except Exception as err:
row = journal.update(operation["id"], "failed", error=str(err) if isinstance(err, AgentError) else "Observation unavailable") row = journal.update(operation["id"], "failed", error=str(err) if isinstance(err, AgentError) else "Observation unavailable")
self.report(row) # Receipt delivery can take seconds; it must not
# hold the instance lock needed by health checks.
self.report(row)
handled = True
finally: finally:
with self.read_claim_lock: with self.read_claim_lock:
self.read_claims.discard(operation["id"]) self.read_claims.discard(operation["id"])
if response.get("operations") and not handled:
# A legacy controller may return another lane's work or
# a read already claimed locally. Avoid a busy poll loop.
self.stopping.wait(0.25)
except (AgentError, OSError, ValueError, KeyError, sqlite3.Error): except (AgentError, OSError, ValueError, KeyError, sqlite3.Error):
self.stopping.wait(3) self.stopping.wait(3)
finally: finally:
@@ -2109,7 +2132,8 @@ class Agent:
self.stopping.wait(3) self.stopping.wait(3)
heartbeats = threading.Thread(target=self.heartbeat_loop, daemon=True) heartbeats = threading.Thread(target=self.heartbeat_loop, daemon=True)
heartbeats.start() heartbeats.start()
readers = [threading.Thread(target=self.read_loop, daemon=True) for _ in range(2)] # Preserve two concurrent health checks even while files are being read.
readers = [threading.Thread(target=self.read_loop, args=(lane,), daemon=True) for lane in ("health", "health", "read")]
for reader in readers: for reader in readers:
reader.start() reader.start()
while not self.stopping.is_set(): while not self.stopping.is_set():
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"version": "1.4.3", "version": "1.4.5",
"protocolVersion": 1, "protocolVersion": 1,
"platform": "debian-amd64", "platform": "debian-amd64",
"sha256": "9ca9bff1a42ab838b175329c6a8458e18053429efe2f050f477a9ad9c598410f", "sha256": "bd4afa2ec3ab6a3022dba99d0580b4b6b1ba5622ae4138035e3e3862885a97bd",
"updaterProtocolVersion": 1, "updaterProtocolVersion": 1,
"updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8" "updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8"
} }
+18
View File
@@ -15,6 +15,24 @@ async function enrolled() {
} }
describe('native host enrollment and scoped authentication', () => { describe('native host enrollment and scoped authentication', () => {
it('reserves health delivery while a file backlog is pending and bounds background batches', async () => {
const api = await enrolled();
api.heartbeat(api.host.id, { ...inventory, capabilities: ['operations.health-lane'] });
const files = Array.from({ length: 30 }, (_, i) => api.queueHostOperation(api.host.id, 'files.read', { path: `file-${i}` }));
const status = api.queueHostOperation(api.host.id, 'rcon.execute', { command: 'status' });
const inspect = api.queueHostOperation(api.host.id, 'instance.inspect', {});
const mutation = api.queueHostOperation(api.host.id, 'rcon.execute', { command: 'map_restart' });
expect(api.pendingHostOperations(api.host.id, 'health').map(op => op.id)).toEqual([status.id]);
api.reportHostOperation(api.host.id, status.id, { state: 'completed', result: {} });
expect(api.pendingHostOperations(api.host.id, 'health').map(op => op.id)).toEqual([inspect.id]);
expect(api.pendingHostOperations(api.host.id, 'read').map(op => op.id)).toEqual(files.slice(0, 4).map(op => op.id));
expect(api.pendingHostOperations(api.host.id, 'write').map(op => op.id)).toEqual([mutation.id]);
// Rolling upgrades: older agents still receive health through read.
api.heartbeat(api.host.id, inventory);
files.forEach(file => api.reportHostOperation(api.host.id, file.id, { state: 'completed', result: {} }));
expect(api.pendingHostOperations(api.host.id, 'read').map(op => op.id)).toEqual([inspect.id]);
});
it('separates observation and mutation delivery and wakes long polls on newly queued work', async () => { it('separates observation and mutation delivery and wakes long polls on newly queued work', async () => {
const api = await enrolled(); const api = await enrolled();
const inspect = api.queueHostOperation(api.host.id, 'host.inspect', {}); const inspect = api.queueHostOperation(api.host.id, 'host.inspect', {});
+76
View File
@@ -0,0 +1,76 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { await fixture.cleanup(); });
async function setup() {
const hosts = await import('../../src/lib/server/hosts/index.js');
const host = hosts.createHost({ name: 'Observation host' });
hosts.enrollHost(hosts.createEnrollment(host.id).token, { protocolVersion: 1 });
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare("INSERT INTO servers(name,komodo_name) VALUES('test','')").run();
db().prepare(`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at)
VALUES('instance','test',?,'library','image',28960,?)`).run(host.id, new Date().toISOString());
const native = await import('../../src/lib/server/backends/native.js');
const instance = native.nativeInstance('test')!;
return { ...hosts, ...native, db: db(), host, instance };
}
describe('overlapping native observations', () => {
it.each(['files.read', 'files.list', 'files.stat', 'logs.read', 'instance.inspect'])('shares %s only until completion', async kind => {
const api = await setup();
const requests = Array.from({ length: 4 }, () => api.nativeRequest(api.instance, kind, { path: 'same' }));
const operations = api.listHostOperations(api.host.id);
expect(operations).toHaveLength(1);
api.reportHostOperation(api.host.id, operations[0].id, { state: 'completed', result: { value: 'first' } });
expect(await Promise.all(requests)).toEqual(Array(4).fill({ value: 'first' }));
const fresh = api.nativeRequest(api.instance, kind, { path: 'same' });
const next = api.listHostOperations(api.host.id)[0];
expect(next.id).not.toBe(operations[0].id);
api.reportHostOperation(api.host.id, next.id, { state: 'completed', result: { value: 'changed' } });
expect(await fresh).toEqual({ value: 'changed' });
});
it('does not merge different paths, mutations, or reads across lifecycle submissions', async () => {
const api = await setup();
const requests = [api.nativeRequest(api.instance, 'files.read', { path: 'one' }),
api.nativeRequest(api.instance, 'files.read', { path: 'two' }),
api.nativeRequest(api.instance, 'rcon.execute', { command: 'map_restart' }),
api.nativeRequest(api.instance, 'rcon.execute', { command: 'map_restart' })];
api.db.prepare("INSERT INTO restart_rollout(server,requested_at) VALUES('test',?)").run(new Date().toISOString());
requests.push(api.nativeRequest(api.instance, 'files.read', { path: 'one' }));
const operations = api.listHostOperations(api.host.id);
expect(operations).toHaveLength(5);
for (const operation of operations) api.reportHostOperation(api.host.id, operation.id, { state: 'completed', result: {} });
await Promise.all(requests);
});
it('releases failed observations so the next request can recover', async () => {
const api = await setup();
const first = api.nativeRequest(api.instance, 'files.read', { path: 'one' });
const rejected = expect(first).rejects.toThrow('unavailable');
api.reportHostOperation(api.host.id, api.listHostOperations(api.host.id)[0].id, { state: 'failed', error: 'unavailable' });
await rejected;
const next = api.nativeRequest(api.instance, 'files.read', { path: 'one' });
expect(api.listHostOperations(api.host.id)).toHaveLength(2);
api.reportHostOperation(api.host.id, api.listHostOperations(api.host.id)[0].id, { state: 'completed', result: {} });
await next;
});
it('keeps replacement generations separate and rejects the old result', async () => {
const api = await setup();
const old = api.nativeRequest(api.instance, 'files.read', { path: 'one' });
const rejected = expect(old).rejects.toThrow('Instance was replaced');
api.db.prepare("UPDATE native_instances SET generation=2 WHERE id='instance'").run();
const current = api.nativeRequest(api.nativeInstance('test')!, 'files.read', { path: 'one' });
const operations = api.listHostOperations(api.host.id);
expect(operations).toHaveLength(2);
for (const operation of operations) api.reportHostOperation(api.host.id, operation.id, {
state: 'completed', result: { generation: operation.generation }
});
await rejected;
expect(await current).toEqual({ generation: 2 });
});
});
+24
View File
@@ -5,6 +5,7 @@ import { komodoConfigured, rconConfigured } from '../../src/lib/server/config.js
import { getServer, type ServerRow } from '../../src/lib/server/fleet/servers.js'; import { getServer, type ServerRow } from '../../src/lib/server/fleet/servers.js';
import { latestGameSubmission } from '../../src/lib/server/fleet/lifecycle-evidence.js'; import { latestGameSubmission } from '../../src/lib/server/fleet/lifecycle-evidence.js';
import { rconGetPlayerCount, rconPlutoniumVersion, rconStatusResult } from '../../src/lib/server/integrations/rcon.js'; import { rconGetPlayerCount, rconPlutoniumVersion, rconStatusResult } from '../../src/lib/server/integrations/rcon.js';
import { nativeInstance, nativeRequest } from '../../src/lib/server/backends/native.js';
vi.mock('node:dgram', () => ({ createSocket: vi.fn() })); vi.mock('node:dgram', () => ({ createSocket: vi.fn() }));
vi.mock('../../src/lib/server/config.js', () => ({ vi.mock('../../src/lib/server/config.js', () => ({
@@ -14,6 +15,7 @@ vi.mock('../../src/lib/server/config.js', () => ({
vi.mock('../../src/lib/server/fleet/servers.js', () => ({ getServer: vi.fn(), setServerField: vi.fn() })); vi.mock('../../src/lib/server/fleet/servers.js', () => ({ getServer: vi.fn(), setServerField: vi.fn() }));
vi.mock('../../src/lib/server/fleet/lifecycle-evidence.js', () => ({ latestGameSubmission: vi.fn() })); vi.mock('../../src/lib/server/fleet/lifecycle-evidence.js', () => ({ latestGameSubmission: vi.fn() }));
vi.mock('../../src/lib/server/integrations/komodo.js', () => ({ komodoResolveGamePort: vi.fn() })); vi.mock('../../src/lib/server/integrations/komodo.js', () => ({ komodoResolveGamePort: vi.fn() }));
vi.mock('../../src/lib/server/backends/native.js', () => ({ nativeInstance: vi.fn(), nativeRequest: vi.fn() }));
class FakeSocket extends EventEmitter { class FakeSocket extends EventEmitter {
send = vi.fn(); send = vi.fn();
@@ -47,6 +49,28 @@ afterEach(() => {
}); });
describe('RCON status transport', () => { describe('RCON status transport', () => {
it.each([
['timeout', 'timeout', 'Agent received no reply'],
['unreachable', 'send-error', 'Agent could not reach'],
['unconfigured', 'no-config', 'Agent RCON password'],
['authentication', 'bad-reply', 'wrong RCON password'],
['invalid-reply', 'bad-reply', 'Agent RCON reply is missing']
])('preserves the native agent %s diagnosis', async (agentReason, reason, detail) => {
server.native_instance_id = 'instance';
vi.mocked(nativeInstance).mockReturnValue({ id: 'instance', generation: 1, host_id: 'host', port: 28960 } as never);
vi.mocked(nativeRequest).mockResolvedValue({ ok: false, reason: agentReason, text: '' });
expect(await rconStatusResult(server.name)).toMatchObject({ ok: false, reason, detail: expect.stringContaining(detail) });
expect(nativeRequest).toHaveBeenCalledOnce();
expect(sockets).toHaveLength(0);
});
it('uses the successful native reply after agent-side recovery', async () => {
server.native_instance_id = 'instance';
vi.mocked(nativeInstance).mockReturnValue({ id: 'instance', generation: 1, host_id: 'host', port: 28960 } as never);
vi.mocked(nativeRequest).mockResolvedValue({ ok: true, text: 'map: mp_kino\nnum score ping guid name\n--- ----- ---- ---- ----\n' });
expect(await rconStatusResult(server.name)).toMatchObject({ ok: true, status: { players: 0, map: 'mp_kino' } });
});
it('shares overlapping health and occupancy probes and probes again after completion', async () => { it('shares overlapping health and occupancy probes and probes again after completion', async () => {
const health = rconStatusResult(server.name); const health = rconStatusResult(server.name);
const occupancy = rconGetPlayerCount(server.name); const occupancy = rconGetPlayerCount(server.name);
+10 -1
View File
@@ -30,13 +30,22 @@ describe('shared server observation', () => {
expect(fallback).toMatchObject({ status_source: 'rcon', health: 'online', last_players: 4, last_seen_at: at(40_000) }); expect(fallback).toMatchObject({ status_source: 'rcon', health: 'online', last_players: 4, last_seen_at: at(40_000) });
}); });
it.each(['starting', 'restarting', 'unhealthy', 'exited', 'unknown'])('telemetry cannot override container state %s', state => { it.each(['starting', 'restarting', 'unhealthy', 'exited'])('telemetry cannot override container state %s', state => {
const selected = selectServerObservation(server({ health: state === 'starting' || state === 'restarting' ? 'starting' : 'offline', container_state: state }), report(), now); const selected = selectServerObservation(server({ health: state === 'starting' || state === 'restarting' ? 'starting' : 'offline', container_state: state }), report(), now);
expect(selected.health).not.toBe('online'); expect(selected.health).not.toBe('online');
expect(selected.last_players).toBeNull(); expect(selected.last_players).toBeNull();
expect(selected.roster).toBeNull(); expect(selected.roster).toBeNull();
}); });
it('uses fresh RCON with unknown container state, but does not substitute telemetry or extend freshness', () => {
const raw = server({ container_state: 'unknown', container_checked_at: at(-60_000) });
expect(selectServerObservation(raw, report(), now)).toMatchObject({ health: 'online', last_players: 4, status_source: 'rcon' });
expect(selectServerObservation(raw, report(46_000), now + 46_000).health).toBe('stale');
expect(selectServerObservation(raw, report(121_000), now + 121_000).health).toBe('offline');
expect(selectServerObservation({ ...raw, telemetry_after: at() }, report(), now).health).toBe('offline');
expect(selectServerObservation({ ...raw, last_seen_at: null }, report(), now).health).toBe('offline');
});
it('requires fresh container evidence and reports after the latest lifecycle submission', () => { it('requires fresh container evidence and reports after the latest lifecycle submission', () => {
expect(selectServerObservation(server({ container_checked_at: at(-46_000) }), report(), now).health).not.toBe('online'); expect(selectServerObservation(server({ container_checked_at: at(-46_000) }), report(), now).health).not.toBe('online');
expect(selectServerObservation(server({ health: 'provisioning' }), report(), now).health).toBe('provisioning'); expect(selectServerObservation(server({ health: 'provisioning' }), report(), now).health).toBe('provisioning');
+25 -1
View File
@@ -379,7 +379,7 @@ describe('database fleet and container readiness', () => {
expect((await (await GET()).json()).servers[0]).toMatchObject({ health: 'online', last_error: null }); expect((await (await GET()).json()).servers[0]).toMatchObject({ health: 'online', last_error: null });
}); });
it.each(['unhealthy', 'exited', undefined])('does not poll RCON with container state %s', async (state) => { it.each(['unhealthy', 'exited', 'stopped', 'missing'])('does not poll RCON with container state %s', async (state) => {
const { inspect, rcon, fanoutStatus } = await setup(); const { inspect, rcon, fanoutStatus } = await setup();
inspect.mockResolvedValue({ ok: false, state, reason: 'not ready' }); inspect.mockResolvedValue({ ok: false, state, reason: 'not ready' });
await postTelemetry(gameEvent('heartbeat', 15, 3, 'managed')); await postTelemetry(gameEvent('heartbeat', 15, 3, 'managed'));
@@ -389,6 +389,30 @@ describe('database fleet and container readiness', () => {
expect((await (await GET()).json()).servers[0]).toMatchObject({ health: 'offline', last_players: null }); expect((await (await GET()).json()).servers[0]).toMatchObject({ health: 'offline', last_players: null });
}); });
it.each(['unknown', undefined, 'throw'])('automatically recovers through RCON when inspection returns %s', async state => {
const { inspect, rcon, fanoutStatus, getServer } = await setup();
const { GET } = await import('../../src/routes/api/servers/+server.js');
const { subscribe } = await import('../../src/lib/server/pipeline/bus.js');
if (state === 'throw') inspect.mockRejectedValue(new Error('Inspection timed out'));
else inspect.mockResolvedValue({ ok: false, state, reason: 'Inspection timed out' });
await postTelemetry(gameEvent('heartbeat', 15, 3, 'managed'));
rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status',
status: { players: 2, map: 'zombie_theater', gametype: 'zom', roster: [] } });
const events: unknown[] = [];
const unsubscribe = subscribe(event => events.push(event));
try { await fanoutStatus(); } finally { unsubscribe(); }
expect(rcon).toHaveBeenCalledOnce();
expect(getServer('managed')?.container_state).toBe('unknown');
expect((await (await GET()).json()).servers[0]).toMatchObject({ health: 'online', last_players: 2, status_source: 'rcon' });
expect(events).toContainEqual(expect.objectContaining({ type: 'server-status', entries: [expect.objectContaining({
health: 'online', players: 2, containerState: 'unknown'
})] }));
rcon.mockResolvedValue({ ok: false, endpoint: null, reason: 'timeout', detail: 'no reply' });
await fanoutStatus();
expect((await (await GET()).json()).servers[0]).toMatchObject({ health: 'stale', last_error: 'timeout:no reply' });
});
it('waits for an EC restart request to settle before trusting the old healthy container', async () => { it('waits for an EC restart request to settle before trusting the old healthy container', async () => {
const { db, inspect, rcon, fanoutStatus, getServer } = await setup(); const { db, inspect, rcon, fanoutStatus, getServer } = await setup();
db.prepare("INSERT INTO restart_rollout (server, requested_at) VALUES ('managed', ?)").run(new Date().toISOString()); db.prepare("INSERT INTO restart_rollout (server, requested_at) VALUES ('managed', ?)").run(new Date().toISOString());
+35
View File
@@ -68,6 +68,41 @@ async function sustainedFailure() {
} }
describe('Watchdog detection and ownership', () => { describe('Watchdog detection and ownership', () => {
it('scans files every five minutes while retaining minute health checks and immediate manual scans', async () => {
const drift = await import('../../src/lib/server/fleet/drift.js');
const scan = vi.spyOn(drift, 'prepareDriftCampaign');
await watchdog.watchdogTick();
expect(scan).toHaveBeenCalledTimes(1);
for (let minute = 1; minute < 5; minute++) {
advance(watchdog.WATCHDOG_SCAN_MS);
await watchdog.watchdogTick();
}
expect(scan).toHaveBeenCalledTimes(1);
expect(komodo.komodoInspectHealthy).toHaveBeenCalledTimes(5);
advance(watchdog.WATCHDOG_SCAN_MS);
await watchdog.watchdogTick();
expect(scan).toHaveBeenCalledTimes(2);
await watchdog.watchdogTick(true);
expect(scan).toHaveBeenCalledTimes(3);
advance(watchdog.WATCHDOG_SCAN_MS);
database.db().prepare('UPDATE servers SET created_at=? WHERE name=?').run(new Date().toISOString(), server);
await watchdog.watchdogTick();
expect(scan).toHaveBeenCalledTimes(4);
scan.mockRestore();
});
it('retries failed file scans on the next health scan instead of retaining successful freshness', async () => {
const drift = await import('../../src/lib/server/fleet/drift.js');
const scan = vi.spyOn(drift, 'prepareDriftCampaign').mockRejectedValueOnce(new Error('Agent unavailable'));
await watchdog.watchdogTick();
expect(watchdog.watchdogOverview().scanError).toContain('Agent unavailable');
advance(watchdog.WATCHDOG_SCAN_MS);
await watchdog.watchdogTick();
expect(scan).toHaveBeenCalledTimes(2);
expect(watchdog.watchdogOverview().scanError).toBeNull();
scan.mockRestore();
});
it('defaults to automatic recovery and debounces manual scans without accelerating the failure window', async () => { it('defaults to automatic recovery and debounces manual scans without accelerating the failure window', async () => {
expect(watchdog.watchdogOverview().enabled).toBe(true); expect(watchdog.watchdogOverview().enabled).toBe(true);
down(); down();