3275 lines
181 KiB
Python
3275 lines
181 KiB
Python
#!/usr/bin/env python3
|
|
"""Electric Cherry host agent. Python 3 standard library; no remote shell API.
|
|
|
|
Each operation is recorded before execution. An interrupted operation becomes
|
|
unknown and is reported for reconciliation, never automatically executed twice.
|
|
"""
|
|
|
|
import argparse
|
|
import base64
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
import contextlib
|
|
from datetime import datetime, timezone
|
|
import errno
|
|
import math
|
|
import hashlib
|
|
import http.client
|
|
import fcntl
|
|
import json
|
|
import os
|
|
from pathlib import Path, PurePosixPath
|
|
import platform
|
|
import re
|
|
import shutil
|
|
import signal
|
|
import socket
|
|
import sqlite3
|
|
import stat as stat_module
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
import types
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
import uuid
|
|
|
|
VERSION = "1.9.0"
|
|
LABEL = "io.electric-cherry"
|
|
MAX_FILE = 32 * 1024 * 1024
|
|
MANIFEST_MAX_ENTRIES = 10000
|
|
MANIFEST_MAX_BYTES = 16 * 1024 * 1024
|
|
MANIFEST_MAX_SECONDS = 15
|
|
MANIFEST_MAX_DEPTH = 64
|
|
LIBRARY_DOWNLOAD_WORKERS = 4
|
|
HEALTH_LOCK_TIMEOUT_SECONDS = 5
|
|
PLUTONIUM_MANIFEST_URL = "https://cdn.plutoniummod.com/updater/prod/info.json"
|
|
PLUTONIUM_MANIFEST_LIMIT = 2 * 1024 * 1024
|
|
PLUTONIUM_FILE_LIMIT = 512 * 1024 * 1024
|
|
PLUTONIUM_CACHE_LIMIT = 4 * 1024 * 1024 * 1024
|
|
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$")
|
|
AGENT_CONFIG = Path("/etc/electric-cherry/agent.json")
|
|
AGENT_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-agent.py")
|
|
AGENT_UNIT = Path("/etc/systemd/system/electric-cherry-agent.service")
|
|
UNINSTALL_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-uninstall.py")
|
|
UNINSTALL_UNIT = Path("/etc/systemd/system/electric-cherry-uninstall.service")
|
|
UNINSTALL_REQUEST = Path("/etc/electric-cherry/decommission.json")
|
|
UPDATE_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-update.py")
|
|
UPDATE_UNIT = Path("/etc/systemd/system/electric-cherry-update.service")
|
|
UPDATE_TIMER = Path("/etc/systemd/system/electric-cherry-update.timer")
|
|
UPDATE_TIMER_ENABLED = Path("/etc/systemd/system/timers.target.wants/electric-cherry-update.timer")
|
|
UPDATE_JOURNAL = Path("/etc/electric-cherry/update/journal.json")
|
|
UPDATE_STATUS = Path("/etc/electric-cherry/update-status.json")
|
|
AGENT_HEALTH = Path("/etc/electric-cherry/agent-health.json")
|
|
ROOT_MARKER = ".electric-cherry.json"
|
|
|
|
|
|
class AgentError(Exception):
|
|
pass
|
|
|
|
|
|
class Held(AgentError):
|
|
"""A reversible prerequisite prevented any disruptive action."""
|
|
|
|
|
|
class Rejected(AgentError):
|
|
"""A definitive precondition failure, before any destructive action."""
|
|
|
|
|
|
class AutoscalingInvalidated(Rejected):
|
|
"""This removal needs a new policy decision, never a later local retry."""
|
|
|
|
|
|
OPERATION_ERROR_PHASES = {
|
|
"executing": "executing the operation",
|
|
"checking-instance": "checking the existing instance",
|
|
"validating-create-spec": "validating the container specification",
|
|
"preparing-create-directory": "preparing the instance directory",
|
|
"seeding-runtime": "seeding the runtime cache",
|
|
"writing-create-files": "writing initial files",
|
|
"persisting-create-metadata": "saving instance metadata",
|
|
"creating-container": "creating the container",
|
|
"starting-container": "starting the container",
|
|
"recording-created-instance": "recording the started container",
|
|
"inspecting-created-instance": "checking the created container",
|
|
"removing-old-instance": "removing the old instance directory",
|
|
}
|
|
|
|
|
|
def unexpected_operation_error(error, phase):
|
|
"""Return bounded diagnostics without exception text, paths, or runtime data."""
|
|
def attribute(value, name):
|
|
try:
|
|
return getattr(value, name, None)
|
|
except Exception:
|
|
return None
|
|
|
|
def category(value):
|
|
# Names come from this fixed list, never an arbitrary exception class.
|
|
for kind in (urllib.error.HTTPError, urllib.error.URLError, http.client.IncompleteRead,
|
|
http.client.RemoteDisconnected, http.client.HTTPException,
|
|
subprocess.TimeoutExpired, TimeoutError,
|
|
PermissionError, FileNotFoundError, FileExistsError, IsADirectoryError,
|
|
NotADirectoryError, ConnectionError, OSError, sqlite3.OperationalError, sqlite3.Error,
|
|
json.JSONDecodeError, UnicodeError, KeyError, TypeError, ValueError,
|
|
MemoryError, RuntimeError):
|
|
if isinstance(value, kind):
|
|
return kind.__name__
|
|
return "Exception"
|
|
|
|
phase = phase if isinstance(phase, str) and phase in OPERATION_ERROR_PHASES else "executing"
|
|
details = {"type": category(error), "phase": phase, "agentVersion": VERSION}
|
|
evidence = error
|
|
if isinstance(error, urllib.error.HTTPError):
|
|
status = attribute(error, "code")
|
|
if type(status) is int and 100 <= status <= 599:
|
|
details["httpStatus"] = status
|
|
elif isinstance(error, urllib.error.URLError):
|
|
reason = attribute(error, "reason")
|
|
if isinstance(reason, BaseException):
|
|
details["causeType"] = category(reason)
|
|
evidence = reason
|
|
number = attribute(evidence, "errno") if isinstance(evidence, OSError) else None
|
|
number = number if type(number) is int and number in errno.errorcode else None
|
|
if number is not None:
|
|
details.update(errno=number, errnoName=errno.errorcode[number])
|
|
|
|
frames = []
|
|
trace = attribute(error, "__traceback__")
|
|
while isinstance(trace, types.TracebackType):
|
|
# Only agent-owned code locations are safe to report. Exclude source
|
|
# lines, filenames, other modules, frame locals, and exception chains.
|
|
if trace.tb_frame.f_globals is globals():
|
|
frames.append({"function": trace.tb_frame.f_code.co_name, "line": trace.tb_lineno})
|
|
trace = trace.tb_next
|
|
details["frames"] = frames[-8:]
|
|
|
|
if details.get("httpStatus") in (401, 403):
|
|
guidance = "Check host enrollment and controller artifact access permissions."
|
|
elif details.get("httpStatus") == 404:
|
|
guidance = "Check that the requested artifact is still available on the controller."
|
|
elif isinstance(error, urllib.error.HTTPError):
|
|
guidance = "Check controller availability and artifact delivery."
|
|
elif number == errno.ENOSPC:
|
|
guidance = "Check free disk space and inodes on the host."
|
|
elif number == errno.EDQUOT:
|
|
guidance = "Check the host filesystem quota."
|
|
elif number in (errno.EACCES, errno.EPERM) or isinstance(evidence, PermissionError):
|
|
guidance = "Check permissions on the host's managed files and directories."
|
|
elif number == errno.EROFS:
|
|
guidance = "Check whether the host filesystem is mounted read-only."
|
|
elif isinstance(error, (urllib.error.URLError, http.client.HTTPException)) or isinstance(evidence, (TimeoutError, ConnectionError)):
|
|
guidance = "Check host network, DNS, TLS, and controller connectivity."
|
|
elif isinstance(error, subprocess.TimeoutExpired):
|
|
guidance = "Check whether the host service command is stalled or unavailable."
|
|
elif isinstance(evidence, FileNotFoundError):
|
|
guidance = "Check that the required host files and directories are present."
|
|
elif isinstance(evidence, OSError):
|
|
guidance = "Check host storage, filesystem access, and service availability."
|
|
else:
|
|
guidance = "Inspect the saved operation diagnostics and host setup before retrying."
|
|
labels = [details["type"]]
|
|
if "httpStatus" in details:
|
|
labels.append(f"HTTP {details['httpStatus']}")
|
|
if "errnoName" in details:
|
|
labels.append(f"{details['errnoName']} ({details['errno']})")
|
|
if "causeType" in details:
|
|
labels.append("cause " + details["causeType"])
|
|
if frames:
|
|
labels.append(f"at {frames[-1]['function']}:{frames[-1]['line']}")
|
|
return f"Operation error while {OPERATION_ERROR_PHASES[phase]} ({'; '.join(labels)}). {guidance}", details
|
|
|
|
|
|
def systemctl(*args):
|
|
try:
|
|
result = subprocess.run(["systemctl", *args], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=60, check=False)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
raise AgentError("Host service management is unavailable") from None
|
|
if result.returncode:
|
|
raise AgentError("Host service management did not complete")
|
|
|
|
|
|
def protected_path(path, missing=False):
|
|
"""Deletion authority must never come from a runtime-writable path."""
|
|
path = Path(path)
|
|
for current in (*reversed(path.parents), path):
|
|
try:
|
|
info = current.lstat()
|
|
except FileNotFoundError:
|
|
if missing:
|
|
continue
|
|
raise AgentError("Host uninstall files are unavailable; reinstall the agent") from None
|
|
if stat_module.S_ISLNK(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
|
|
raise AgentError("Host uninstall paths must be owned and writable only by root")
|
|
return path
|
|
|
|
|
|
def protected_file(path, missing=False):
|
|
path = protected_path(path, missing=missing)
|
|
try:
|
|
if not stat_module.S_ISREG(path.lstat().st_mode):
|
|
raise AgentError("Host uninstall metadata must be a regular file")
|
|
except FileNotFoundError:
|
|
if not missing:
|
|
raise
|
|
return path
|
|
|
|
|
|
def sync_parent(path):
|
|
descriptor = os.open(Path(path).parent, os.O_RDONLY | os.O_DIRECTORY)
|
|
try:
|
|
os.fsync(descriptor)
|
|
finally:
|
|
os.close(descriptor)
|
|
|
|
|
|
def uninstall_root(value, identity=None):
|
|
"""Accept only a canonical, dedicated directory with no nested mounts."""
|
|
if not isinstance(value, str) or not re.fullmatch(r"/[A-Za-z0-9/_.-]+", value):
|
|
raise AgentError("Host data directory is unsafe to remove")
|
|
root = Path(value)
|
|
forbidden = {"/etc", "/usr", "/var", "/var/lib", "/var/log", "/var/cache", "/home", "/root", "/opt", "/srv", "/tmp", "/run", "/mnt", "/media", "/usr/local", "/usr/local/libexec"}
|
|
if str(root) != value or ".." in root.parts or "." in root.parts or len(root.parts) < 3 or value in forbidden:
|
|
raise AgentError("Host data directory is unsafe to remove")
|
|
for installed in (AGENT_CONFIG, AGENT_EXECUTABLE, AGENT_UNIT, UNINSTALL_EXECUTABLE, UNINSTALL_UNIT, UPDATE_EXECUTABLE, UPDATE_UNIT, UPDATE_TIMER, AGENT_EXECUTABLE.with_suffix(".py.update"), UNINSTALL_EXECUTABLE.with_suffix(".py.update")):
|
|
if installed == root or root in installed.parents:
|
|
raise AgentError("Host data directory overlaps the agent installation")
|
|
protected_path(root, missing=True)
|
|
if root.exists():
|
|
info = root.stat()
|
|
if not stat_module.S_ISDIR(info.st_mode):
|
|
raise AgentError("Host data directory is not a directory")
|
|
if identity and [info.st_dev, info.st_ino] != identity:
|
|
raise AgentError("Host data directory changed since deletion was authorized")
|
|
try:
|
|
# ProtectSystem/ReadWritePaths give the running agent a private bind
|
|
# mount at its data directory. Inspect the host systemd namespace,
|
|
# otherwise every legitimate installation appears to be a mount root.
|
|
mount_lines = Path("/proc/1/mountinfo").read_text().splitlines()
|
|
except FileNotFoundError:
|
|
mount_lines = [] # Non-Linux tests; the installed service requires Linux.
|
|
for line in mount_lines:
|
|
fields = line.split()
|
|
if len(fields) < 5:
|
|
continue
|
|
mount = re.sub(r"\\([0-7]{3})", lambda match: chr(int(match[1], 8)), fields[4])
|
|
if mount == value or mount.startswith(value + "/"):
|
|
raise AgentError("Unmount filesystems inside the host data directory before deletion")
|
|
return root
|
|
|
|
|
|
def uninstall_ownership(config):
|
|
root = uninstall_root(config.get("root"))
|
|
marker = protected_file(root / ROOT_MARKER)
|
|
try:
|
|
saved = json.loads(marker.read_text())
|
|
except (OSError, ValueError):
|
|
raise AgentError("Host data ownership is unavailable; reinstall the agent") from None
|
|
expected = {"version": 1, "root": str(root), "hostId": config.get("hostId"), "controller": config.get("controller", "").rstrip("/")}
|
|
if saved != expected or not expected["hostId"]:
|
|
raise AgentError("Host data ownership does not match this enrollment")
|
|
return root
|
|
|
|
|
|
def digest(data):
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def file_digest(path):
|
|
h = hashlib.sha256()
|
|
with open(path, "rb") as stream:
|
|
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
|
h.update(block)
|
|
return h.hexdigest()
|
|
|
|
|
|
def status_players(text):
|
|
lines = text.splitlines()
|
|
separator = next((index for index, line in enumerate(lines) if re.match(r"^\s*-+\s+-+", line)), None)
|
|
# T6 includes a bot column; T5 places ping directly after score.
|
|
if separator is not None and any(re.search(r"\bnum\s+score\s+(?:bot\s+)?ping\b", line, re.I) for line in lines[:separator]):
|
|
rows = [line.strip() for line in lines[separator + 1:] if line.strip()]
|
|
if any(not re.match(r"^\d+\s+", row) for row in rows):
|
|
return None
|
|
return len(rows)
|
|
info = next((index for index, line in enumerate(lines) if line.startswith("\\") and "\\mapname\\" in line), None)
|
|
if info is not None:
|
|
return sum(bool(line.strip()) for line in lines[info + 1:])
|
|
# A truncated header or an unfamiliar dialect is never proof of emptiness.
|
|
return None
|
|
|
|
|
|
def identifier(value):
|
|
if not isinstance(value, str) or not ID_RE.fullmatch(value):
|
|
raise AgentError("Invalid identifier")
|
|
return value
|
|
|
|
|
|
def instance_identity(instance_id, generation):
|
|
try:
|
|
if str(uuid.UUID(instance_id)) != instance_id.lower():
|
|
raise ValueError()
|
|
except (ValueError, AttributeError, TypeError):
|
|
raise AgentError("Instance ID must be a UUID") from None
|
|
if isinstance(generation, bool) or not isinstance(generation, int) or generation < 1:
|
|
raise AgentError("Invalid instance generation")
|
|
return instance_id.lower(), generation
|
|
|
|
|
|
def scoped(root, value, allow_root=False):
|
|
"""Reject traversal AND existing symlinks, including dangling symlinks."""
|
|
if not isinstance(value, str) or "\\" in value or "\x00" in value:
|
|
raise AgentError("Invalid managed path")
|
|
rel = PurePosixPath(value)
|
|
if rel.is_absolute() or any(p in ("..", "") for p in rel.parts):
|
|
raise AgentError("Path is outside the managed directory")
|
|
if value not in ("", ".") and str(rel) != value:
|
|
raise AgentError("Managed paths must use their canonical relative form")
|
|
if (not rel.parts or str(rel) == ".") and not allow_root:
|
|
raise AgentError("A file path is required")
|
|
root = Path(root)
|
|
current = root
|
|
if current.is_symlink():
|
|
raise AgentError("Managed root must not be a symlink")
|
|
for part in rel.parts:
|
|
current = current / part
|
|
if current.is_symlink():
|
|
raise AgentError("Symlinks are not allowed in managed file operations")
|
|
return current
|
|
|
|
|
|
def atomic_bytes(path, data, mode=0o600):
|
|
path = Path(path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = path.with_name(path.name + "." + uuid.uuid4().hex + ".tmp")
|
|
try:
|
|
with open(tmp, "xb") as stream:
|
|
os.chmod(tmp, mode)
|
|
stream.write(data)
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
os.replace(tmp, path)
|
|
fd = os.open(path.parent, os.O_RDONLY)
|
|
try:
|
|
os.fsync(fd)
|
|
finally:
|
|
os.close(fd)
|
|
finally:
|
|
with contextlib.suppress(FileNotFoundError):
|
|
tmp.unlink()
|
|
|
|
|
|
def atomic_json(path, value):
|
|
atomic_bytes(path, json.dumps(value, sort_keys=True).encode())
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def managed_parent(root, value, create=False, uid=1000, gid=1000):
|
|
"""Use dirfds so an instance cannot race a symlink into a host-root write."""
|
|
scoped(root, value)
|
|
parts = PurePosixPath(value).parts
|
|
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
|
|
fd = os.open(root, flags)
|
|
try:
|
|
for part in parts[:-1]:
|
|
if create:
|
|
with contextlib.suppress(FileExistsError):
|
|
os.mkdir(part, 0o755, dir_fd=fd)
|
|
if os.geteuid() == 0:
|
|
os.chown(part, uid, gid, dir_fd=fd, follow_symlinks=False)
|
|
child = os.open(part, flags, dir_fd=fd)
|
|
os.close(fd)
|
|
fd = child
|
|
yield fd, parts[-1]
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def managed_open(root, value):
|
|
with managed_parent(root, value) as (parent, name):
|
|
fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent)
|
|
try:
|
|
if not stat_module.S_ISREG(os.fstat(fd).st_mode):
|
|
raise AgentError("Requested managed path is not a regular file")
|
|
with os.fdopen(fd, "rb", closefd=False) as stream:
|
|
yield stream
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
def managed_read(root, value):
|
|
try:
|
|
with managed_open(root, value) as stream:
|
|
if os.fstat(stream.fileno()).st_size > MAX_FILE:
|
|
raise Rejected("Managed file exceeds the 32 MiB read limit")
|
|
content = stream.read(MAX_FILE + 1)
|
|
if len(content) > MAX_FILE:
|
|
raise Rejected("Managed file exceeds the 32 MiB read limit")
|
|
return content
|
|
except FileNotFoundError:
|
|
return None
|
|
|
|
|
|
def managed_manifest(root, payload, stopping):
|
|
"""Hash a bounded managed tree without following runtime-controlled symlinks."""
|
|
deadline = time.monotonic() + MANIFEST_MAX_SECONDS
|
|
|
|
def check_budget():
|
|
if stopping.is_set() or time.monotonic() >= deadline:
|
|
raise AgentError("Manifest scan exceeded its time limit or was interrupted")
|
|
|
|
roots = payload.get("roots")
|
|
ignored = payload.get("ignoreNames")
|
|
paths = payload.get("paths", [])
|
|
if not isinstance(roots, list) or not roots or len(roots) > 64:
|
|
raise AgentError("Manifest roots must contain between 1 and 64 managed directories")
|
|
if not isinstance(ignored, list) or len(ignored) > 256 or any(
|
|
not isinstance(name, str) or not name or name in (".", "..") or any(char in name for char in ("/", "\\", "\x00"))
|
|
for name in ignored
|
|
):
|
|
raise AgentError("Manifest ignored names must be plain file or directory names")
|
|
if not isinstance(paths, list) or len(paths) > 10000:
|
|
raise AgentError("Manifest managed paths must contain at most 10000 files")
|
|
for value in roots:
|
|
check_budget()
|
|
scoped(root, value)
|
|
roots = sorted(set(roots))
|
|
roots = [value for index, value in enumerate(roots) if not any(value.startswith(parent + "/") for parent in roots[:index])]
|
|
ignored = set(ignored)
|
|
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
|
|
entries = 0
|
|
result_bytes = len(json.dumps({"files": []}).encode())
|
|
files = []
|
|
|
|
def validate_paths():
|
|
for value in paths:
|
|
check_budget()
|
|
try:
|
|
with managed_open(root, value):
|
|
pass
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
def identity(info):
|
|
return info.st_dev, info.st_ino, info.st_mode, info.st_size, info.st_mtime_ns, info.st_ctime_ns
|
|
|
|
def open_directory(app, relative):
|
|
directory = os.dup(app)
|
|
try:
|
|
for part in PurePosixPath(relative).parts:
|
|
nested = os.open(part, flags, dir_fd=directory)
|
|
os.close(directory)
|
|
directory = nested
|
|
return directory
|
|
except BaseException:
|
|
os.close(directory)
|
|
raise
|
|
|
|
def scan(directory, relative, depth):
|
|
nonlocal entries, result_bytes
|
|
check_budget()
|
|
if depth > MANIFEST_MAX_DEPTH:
|
|
raise AgentError("Manifest scan exceeded its directory depth limit")
|
|
before = os.fstat(directory)
|
|
with os.scandir(directory) as children:
|
|
for child in children:
|
|
check_budget()
|
|
entries += 1
|
|
if entries > MANIFEST_MAX_ENTRIES:
|
|
raise AgentError("Manifest scan exceeded its entry limit")
|
|
if child.name in ignored:
|
|
continue
|
|
info = os.stat(child.name, dir_fd=directory, follow_symlinks=False)
|
|
if stat_module.S_ISLNK(info.st_mode):
|
|
continue
|
|
path = relative + "/" + child.name
|
|
if stat_module.S_ISDIR(info.st_mode):
|
|
nested = os.open(child.name, flags, dir_fd=directory)
|
|
try:
|
|
if identity(info) != identity(os.fstat(nested)):
|
|
raise AgentError("Manifest directory changed during the scan")
|
|
scan(nested, path, depth + 1)
|
|
if identity(os.fstat(nested)) != identity(os.stat(child.name, dir_fd=directory, follow_symlinks=False)):
|
|
raise AgentError("Manifest directory changed during the scan")
|
|
finally:
|
|
os.close(nested)
|
|
elif stat_module.S_ISREG(info.st_mode):
|
|
descriptor = os.open(child.name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory)
|
|
try:
|
|
if identity(info) != identity(os.fstat(descriptor)):
|
|
raise AgentError("Manifest file changed during the scan")
|
|
sha = hashlib.sha256()
|
|
size = 0
|
|
with os.fdopen(descriptor, "rb", closefd=False) as stream:
|
|
while True:
|
|
check_budget()
|
|
block = stream.read(1024 * 1024)
|
|
if not block:
|
|
break
|
|
sha.update(block)
|
|
size += len(block)
|
|
if size != info.st_size or identity(info) != identity(os.fstat(descriptor)) or identity(info) != identity(os.stat(child.name, dir_fd=directory, follow_symlinks=False)):
|
|
raise AgentError("Manifest file changed during the scan")
|
|
finally:
|
|
os.close(descriptor)
|
|
item = {"path": path, "size": size, "sha256": sha.hexdigest()}
|
|
result_bytes += len(json.dumps(item).encode()) + (2 if files else 0)
|
|
if result_bytes > MANIFEST_MAX_BYTES:
|
|
raise AgentError("Manifest scan exceeded its result size limit")
|
|
files.append(item)
|
|
else:
|
|
raise AgentError("Manifest scan encountered a non-regular managed file")
|
|
if identity(before) != identity(os.fstat(directory)):
|
|
raise AgentError("Manifest directory changed during the scan")
|
|
|
|
try:
|
|
app = os.open(root, flags)
|
|
try:
|
|
app_identity = os.fstat(app)
|
|
validate_paths()
|
|
for relative in roots:
|
|
check_budget()
|
|
try:
|
|
directory = open_directory(app, relative)
|
|
except FileNotFoundError:
|
|
continue # A destination directory need not exist yet.
|
|
try:
|
|
scan(directory, relative, len(PurePosixPath(relative).parts))
|
|
current_directory = open_directory(app, relative)
|
|
try:
|
|
if identity(os.fstat(directory)) != identity(os.fstat(current_directory)):
|
|
raise AgentError("Manifest directory changed during the scan")
|
|
finally:
|
|
os.close(current_directory)
|
|
finally:
|
|
os.close(directory)
|
|
validate_paths()
|
|
current = os.stat(root, follow_symlinks=False)
|
|
if (current.st_dev, current.st_ino, current.st_mode) != (app_identity.st_dev, app_identity.st_ino, app_identity.st_mode):
|
|
raise AgentError("Manifest managed root changed during the scan")
|
|
check_budget()
|
|
finally:
|
|
os.close(app)
|
|
except OSError:
|
|
raise AgentError("Manifest scan could not read the complete managed tree") from None
|
|
return {"files": sorted(files, key=lambda item: item["path"])}
|
|
|
|
|
|
def managed_write(root, value, content, uid=1000, gid=1000):
|
|
if content is None:
|
|
try:
|
|
with managed_parent(root, value) as (parent, name):
|
|
with contextlib.suppress(FileNotFoundError):
|
|
os.unlink(name, dir_fd=parent)
|
|
os.fsync(parent)
|
|
except FileNotFoundError:
|
|
pass
|
|
return
|
|
with managed_parent(root, value, create=True, uid=uid, gid=gid) as (parent, name):
|
|
prior = None
|
|
try:
|
|
prior = os.stat(name, dir_fd=parent, follow_symlinks=False)
|
|
if not stat_module.S_ISREG(prior.st_mode):
|
|
raise Rejected("Managed target is not a regular file")
|
|
except FileNotFoundError:
|
|
pass
|
|
temp_name = ".ec-" + uuid.uuid4().hex
|
|
fd = os.open(temp_name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=parent)
|
|
try:
|
|
os.fchmod(fd, prior.st_mode & 0o777 if prior else 0o644)
|
|
if os.geteuid() == 0:
|
|
os.fchown(fd, prior.st_uid if prior else uid, prior.st_gid if prior else gid)
|
|
with os.fdopen(fd, "wb", closefd=False) as stream:
|
|
stream.write(content)
|
|
stream.flush()
|
|
os.fsync(fd)
|
|
os.replace(temp_name, name, src_dir_fd=parent, dst_dir_fd=parent)
|
|
os.fsync(parent)
|
|
finally:
|
|
os.close(fd)
|
|
with contextlib.suppress(FileNotFoundError):
|
|
os.unlink(temp_name, dir_fd=parent)
|
|
|
|
|
|
def managed_copy(root, value, source, uid=1000, gid=1000):
|
|
"""Copy a private file, using Linux reflinks when supported, never hardlinks."""
|
|
with managed_open(Path(source).parent, Path(source).name) as incoming:
|
|
with managed_parent(root, value, create=True, uid=uid, gid=gid) as (parent, name):
|
|
temp_name = ".ec-" + uuid.uuid4().hex
|
|
fd = os.open(temp_name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=parent)
|
|
try:
|
|
os.fchmod(fd, 0o644)
|
|
if os.geteuid() == 0:
|
|
os.fchown(fd, uid, gid)
|
|
try:
|
|
if not sys.platform.startswith("linux"):
|
|
raise OSError("Reflinks unavailable")
|
|
fcntl.ioctl(fd, 0x40049409, incoming.fileno()) # FICLONE
|
|
except OSError:
|
|
os.ftruncate(fd, 0)
|
|
with os.fdopen(fd, "wb", closefd=False) as outgoing:
|
|
shutil.copyfileobj(incoming, outgoing, 1024 * 1024)
|
|
outgoing.flush()
|
|
os.fsync(fd)
|
|
os.replace(temp_name, name, src_dir_fd=parent, dst_dir_fd=parent)
|
|
os.fsync(parent)
|
|
finally:
|
|
os.close(fd)
|
|
with contextlib.suppress(FileNotFoundError):
|
|
os.unlink(temp_name, dir_fd=parent)
|
|
|
|
|
|
class ReadWriteGate:
|
|
"""Let observations run together; global mutations wait for existing readers."""
|
|
def __init__(self):
|
|
self.condition = threading.Condition()
|
|
self.readers = 0
|
|
self.writers = 0
|
|
|
|
@contextlib.contextmanager
|
|
def read(self):
|
|
with self.condition:
|
|
while self.writers:
|
|
self.condition.wait()
|
|
self.readers += 1
|
|
try:
|
|
yield
|
|
finally:
|
|
with self.condition:
|
|
self.readers -= 1
|
|
self.condition.notify_all()
|
|
|
|
@contextlib.contextmanager
|
|
def write(self):
|
|
# The mutation lane is serial, so there is only one writer.
|
|
with self.condition:
|
|
self.writers += 1
|
|
while self.readers:
|
|
self.condition.wait()
|
|
try:
|
|
yield
|
|
finally:
|
|
with self.condition:
|
|
self.writers -= 1
|
|
self.condition.notify_all()
|
|
|
|
|
|
def read_operation(operation):
|
|
if not isinstance(operation, dict):
|
|
return False
|
|
if operation.get("kind") in ("host.inspect", "instance.inspect", "rcon.status-batch", "files.read", "files.list", "files.stat", "files.manifest", "logs.read"):
|
|
return True
|
|
payload = operation.get("payload")
|
|
return operation.get("kind") == "rcon.execute" and isinstance(payload, dict) and payload.get("command") in ("status", "getstatus", "version")
|
|
|
|
|
|
def valid_observation_keep_days(value):
|
|
return type(value) is int and 0 <= value <= 36500
|
|
|
|
|
|
class Journal:
|
|
def __init__(self, path, recover=True):
|
|
self.db = sqlite3.connect(path)
|
|
self.db.row_factory = sqlite3.Row
|
|
if not recover:
|
|
return
|
|
self.db.execute("PRAGMA journal_mode=WAL")
|
|
self.db.execute("PRAGMA synchronous=FULL")
|
|
self.db.execute("CREATE TABLE IF NOT EXISTS operations (id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, request TEXT NOT NULL, state TEXT NOT NULL, result TEXT, error TEXT, updated REAL NOT NULL)")
|
|
columns = {row[1] for row in self.db.execute("PRAGMA table_info(operations)")}
|
|
if "checkpoint" not in columns:
|
|
self.db.execute("ALTER TABLE operations ADD COLUMN checkpoint TEXT")
|
|
if "reported_at" not in columns:
|
|
self.db.execute("ALTER TABLE operations ADD COLUMN reported_at REAL")
|
|
if "retention_eligible" not in columns:
|
|
self.db.execute("ALTER TABLE operations ADD COLUMN retention_eligible INTEGER NOT NULL DEFAULT 0")
|
|
self.db.execute("CREATE INDEX IF NOT EXISTS operations_observation_retention ON operations(updated,id) WHERE state='completed' AND reported_at IS NOT NULL AND retention_eligible=1")
|
|
self.db.execute("UPDATE operations SET state='unknown', error='Agent restarted during execution; inspect before reconciliation', updated=? WHERE state='running'", (time.time(),))
|
|
self.db.commit()
|
|
|
|
def accept(self, request):
|
|
op_id = identifier(request.get("id"))
|
|
immutable = {key: request.get(key) for key in ("id", "kind", "instanceId", "generation", "payload")}
|
|
canonical = json.dumps(immutable, sort_keys=True, separators=(",", ":"))
|
|
fingerprint = digest(canonical.encode())
|
|
row = self.db.execute("SELECT * FROM operations WHERE id=?", (op_id,)).fetchone()
|
|
if row:
|
|
if row["fingerprint"] != fingerprint:
|
|
raise AgentError("Operation ID was reused with different content")
|
|
return dict(row), False
|
|
self.db.execute("INSERT INTO operations(id,fingerprint,request,state,updated) VALUES(?,?,?,'accepted',?)", (op_id, fingerprint, canonical, time.time()))
|
|
self.db.commit()
|
|
return self.get(op_id), True
|
|
|
|
def get(self, op_id):
|
|
row = self.db.execute("SELECT * FROM operations WHERE id=?", (op_id,)).fetchone()
|
|
if row is None:
|
|
raise AgentError("Unknown operation")
|
|
return dict(row)
|
|
|
|
def update(self, op_id, state, result=None, error=None):
|
|
self.db.execute("UPDATE operations SET state=?, result=?, error=?, updated=?, reported_at=NULL, retention_eligible=0 WHERE id=?", (state, json.dumps(result) if result is not None else None, error, time.time(), op_id))
|
|
self.db.commit()
|
|
return self.get(op_id)
|
|
|
|
def confirm_completed(self, row):
|
|
"""Only an acknowledged, unchanged completion can become disposable."""
|
|
if row["state"] != "completed":
|
|
return
|
|
eligible = False
|
|
try:
|
|
result = json.loads(row["result"]) if row.get("result") is not None else None
|
|
eligible = read_operation(json.loads(row["request"])) and not row.get("error") and not (
|
|
isinstance(result, dict) and (result.get("ok") is False or result.get("healthy") is False or result.get("error") not in (None, ""))
|
|
)
|
|
except (ValueError, TypeError, KeyError):
|
|
pass # Malformed historical records are retained for inspection.
|
|
self.db.execute("""UPDATE operations SET reported_at=COALESCE(reported_at,?), retention_eligible=?
|
|
WHERE id=? AND state='completed' AND updated=? AND result IS ? AND error IS ?""",
|
|
(time.time(), int(bool(eligible)), row["id"], row["updated"], row.get("result"), row.get("error")))
|
|
self.db.commit()
|
|
|
|
def prune_observations(self, keep_days):
|
|
if not valid_observation_keep_days(keep_days) or keep_days == 0:
|
|
return 0
|
|
cutoff = time.time() - keep_days * 86400
|
|
removed = self.db.execute("""DELETE FROM operations WHERE id IN (
|
|
SELECT id FROM operations WHERE state='completed' AND reported_at IS NOT NULL AND retention_eligible=1
|
|
AND updated<? ORDER BY updated,id LIMIT 500)""", (cutoff,)).rowcount
|
|
self.db.commit()
|
|
return removed
|
|
|
|
def close(self):
|
|
self.db.close()
|
|
|
|
def checkpoint(self, op_id, value):
|
|
self.db.execute("UPDATE operations SET checkpoint=?, updated=? WHERE id=?", (json.dumps(value), time.time(), op_id))
|
|
self.db.commit()
|
|
|
|
|
|
class Docker:
|
|
def call(self, *args, timeout=120, tolerate_missing=False, merge_stderr=False):
|
|
try:
|
|
result = subprocess.run(["docker", *args], stdout=subprocess.PIPE, stderr=subprocess.STDOUT if merge_stderr else subprocess.PIPE, timeout=timeout, check=False)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
raise AgentError("Docker command unavailable or timed out") from None
|
|
if result.returncode:
|
|
# Never include arbitrary Docker stderr: create errors may echo secrets.
|
|
if tolerate_missing and any(message in (result.stderr or b"") for message in (b"No such object", b"No such container", b"No such image")):
|
|
return None
|
|
raise AgentError("Docker rejected the requested operation")
|
|
return result.stdout.decode("utf-8", "replace")
|
|
|
|
def inspect(self, name):
|
|
raw = self.call("container", "inspect", name, tolerate_missing=True)
|
|
return json.loads(raw)[0] if raw is not None else None
|
|
|
|
|
|
class Uninstaller:
|
|
"""A separate systemd service owns cleanup after stopping the host agent.
|
|
|
|
Its root-only request and executable stay on disk until the controller
|
|
acknowledges data removal. Completion is reported only after removing those
|
|
final files. A lost final response can be retried from memory; a power loss
|
|
in that last window deliberately leaves controller status unconfirmed.
|
|
"""
|
|
|
|
def __init__(self, request_path, docker=None):
|
|
if Path(request_path) != UNINSTALL_REQUEST:
|
|
raise AgentError("Invalid host uninstall request location")
|
|
protected_file(UNINSTALL_REQUEST)
|
|
self.job = json.loads(UNINSTALL_REQUEST.read_text())
|
|
self.docker = docker or Docker()
|
|
self.root = uninstall_root(self.job.get("root"), self.job.get("rootIdentity"))
|
|
host_id = identifier(self.job.get("hostId"))
|
|
operation_id = identifier(self.job.get("operationId"))
|
|
parsed = urllib.parse.urlsplit(self.job.get("controller", ""))
|
|
if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password or parsed.query or parsed.fragment:
|
|
raise AgentError("Host uninstall controller must use HTTPS")
|
|
if self.job.get("receiptUrl") != "/api/agent/decommission/" + operation_id or not isinstance(self.job.get("receiptToken"), str) or not self.job["receiptToken"]:
|
|
raise AgentError("Invalid host uninstall receipt")
|
|
if not isinstance(self.job.get("rootIdentity"), list) or len(self.job["rootIdentity"]) != 2 or not all(isinstance(value, int) and not isinstance(value, bool) for value in self.job["rootIdentity"]):
|
|
raise AgentError("Invalid host data ownership proof")
|
|
if self.root.exists():
|
|
# The authorized device/inode is persisted before deletion so a
|
|
# crash halfway through rmtree does not require a deleted marker.
|
|
if not self.job.get("removalStarted"):
|
|
uninstall_ownership({"root": str(self.root), "controller": self.job["controller"], "hostId": host_id})
|
|
|
|
def save(self, **changes):
|
|
self.job.update(changes)
|
|
atomic_json(UNINSTALL_REQUEST, self.job)
|
|
|
|
def receipt(self, stage, error=None):
|
|
payload = {"stage": stage}
|
|
if error:
|
|
payload["error"] = error
|
|
request = urllib.request.Request(self.job["controller"].rstrip("/") + self.job["receiptUrl"], data=json.dumps(payload).encode(), headers={"Content-Type": "application/json", "Authorization": "Bearer " + self.job["receiptToken"], "User-Agent": "electric-cherry-agent/" + VERSION}, method="POST")
|
|
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
def redirect_request(self, req, fp, code, msg, hdrs, newurl):
|
|
raise AgentError("Controller redirects are not allowed")
|
|
try:
|
|
with urllib.request.build_opener(NoRedirect()).open(request, timeout=35) as response:
|
|
if json.load(response).get("acknowledged") is not True:
|
|
raise AgentError("Controller has not acknowledged host removal")
|
|
except (OSError, ValueError):
|
|
raise AgentError("Host removal receipt is waiting for controller contact") from None
|
|
|
|
def containers(self):
|
|
ids = self.docker.call("container", "ls", "--all", "--quiet", "--no-trunc").splitlines()
|
|
values = []
|
|
for container_id in ids:
|
|
info = self.docker.inspect(container_id)
|
|
if info:
|
|
values.append(info)
|
|
return values
|
|
|
|
def owned_container(self, info):
|
|
labels = info.get("Config", {}).get("Labels", {}) or {}
|
|
if labels.get(LABEL + ".managed") != "true":
|
|
return False
|
|
if labels.get(LABEL + ".host") not in (None, self.job["hostId"]):
|
|
return False
|
|
try:
|
|
instance_id, generation = instance_identity(labels.get(LABEL + ".instance"), int(labels.get(LABEL + ".generation", "")))
|
|
except (AgentError, ValueError, TypeError):
|
|
return False
|
|
expected = str(self.root / "instances" / instance_id / str(generation) / "app")
|
|
# Legacy agents did not label the host; its exact managed bind mount
|
|
# proves ownership without touching another installation's containers.
|
|
return any(mount.get("Type") == "bind" and mount.get("Source") == expected and mount.get("Destination") == "/home/plutainer/app" for mount in info.get("Mounts", []))
|
|
|
|
def uses_root(self, info):
|
|
for mount in info.get("Mounts", []):
|
|
if mount.get("Type") != "bind":
|
|
continue
|
|
source = Path(mount.get("Source", "/")).resolve()
|
|
if source == self.root or self.root in source.parents:
|
|
return True
|
|
return False
|
|
|
|
def remove_docker_resources(self):
|
|
containers = self.containers()
|
|
if any(self.uses_root(info) and not self.owned_container(info) for info in containers):
|
|
raise AgentError("An unrelated container uses the host data directory; detach it before retrying deletion")
|
|
owned = [info for info in containers if self.owned_container(info)]
|
|
if any(info.get("State", {}).get("Running") is not False or info.get("State", {}).get("Status") not in ("created", "exited", "dead") for info in owned):
|
|
raise AgentError("A managed game container is still running or its state is unknown; retire it before deleting this host")
|
|
for info in owned:
|
|
# Do not force removal: Docker must also reject a container that
|
|
# starts again after inspection rather than interrupting players.
|
|
self.docker.call("container", "rm", "--volumes", info["Id"], timeout=120, tolerate_missing=True)
|
|
remaining = self.containers()
|
|
if any(self.uses_root(info) for info in remaining):
|
|
raise AgentError("Containers still use the host data directory")
|
|
# Never prune Docker globally. Only remove this agent's recorded image
|
|
# references, and preserve images used by any unrelated container.
|
|
used_images = {info.get("Image") for info in remaining}
|
|
images = self.job.get("runtimeImages", {})
|
|
if not isinstance(images, dict):
|
|
raise AgentError("Managed runtime image ownership is unavailable")
|
|
for reference, expected_id in images.items():
|
|
if not isinstance(reference, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._/:@-]{0,511}", reference) or not isinstance(expected_id, str):
|
|
raise AgentError("Managed runtime image ownership is invalid")
|
|
raw = self.docker.call("image", "inspect", reference, tolerate_missing=True)
|
|
if raw is None:
|
|
continue
|
|
found = json.loads(raw)
|
|
if not found or found[0].get("Id") != expected_id or expected_id in used_images:
|
|
continue
|
|
self.docker.call("image", "rm", reference, tolerate_missing=True)
|
|
|
|
def remove_data(self):
|
|
protected_path(AGENT_CONFIG.parent, missing=True)
|
|
# Prevent a scheduled update from recreating agent files during removal.
|
|
# Stopping the worker is safe here: the installation and any backups are
|
|
# being removed together, and the durable decommission request blocks
|
|
# future update attempts.
|
|
if not self.job.get("updaterStopped"):
|
|
for unit in (UPDATE_TIMER, UPDATE_UNIT):
|
|
protected_file(unit, missing=True)
|
|
if unit.exists():
|
|
if unit == UPDATE_TIMER:
|
|
systemctl("disable", "--now", unit.name)
|
|
else:
|
|
systemctl("stop", unit.name)
|
|
self.save(updaterStopped=True)
|
|
if not self.job.get("agentStopped"):
|
|
systemctl("disable", "--now", AGENT_UNIT.name)
|
|
self.save(agentStopped=True)
|
|
self.remove_docker_resources()
|
|
root = uninstall_root(str(self.root), self.job["rootIdentity"])
|
|
# This checkpoint also authorizes safe continuation if a power loss
|
|
# removes the ownership marker before the rest of the tree.
|
|
self.save(removalStarted=True)
|
|
if root.exists():
|
|
if not shutil.rmtree.avoids_symlink_attacks:
|
|
raise AgentError("This platform cannot safely remove managed host files")
|
|
shutil.rmtree(root)
|
|
sync_parent(root)
|
|
for path in (AGENT_CONFIG, AGENT_EXECUTABLE, AGENT_UNIT, UPDATE_EXECUTABLE, UPDATE_UNIT, UPDATE_TIMER, AGENT_EXECUTABLE.with_suffix(".py.update"), UNINSTALL_EXECUTABLE.with_suffix(".py.update")):
|
|
protected_file(path, missing=True)
|
|
path.unlink(missing_ok=True)
|
|
sync_parent(path)
|
|
systemctl("daemon-reload")
|
|
self.save(dataRemoved=True)
|
|
|
|
def remove_helper(self):
|
|
# Disable does not stop this process: it must outlive its own unit and
|
|
# executable to deliver the final receipt after the files are gone.
|
|
protected_file(UNINSTALL_UNIT, missing=True)
|
|
if UNINSTALL_UNIT.exists():
|
|
systemctl("disable", UNINSTALL_UNIT.name)
|
|
UNINSTALL_UNIT.unlink()
|
|
sync_parent(UNINSTALL_UNIT)
|
|
systemctl("daemon-reload")
|
|
protected_file(UNINSTALL_EXECUTABLE, missing=True)
|
|
UNINSTALL_EXECUTABLE.unlink(missing_ok=True)
|
|
sync_parent(UNINSTALL_EXECUTABLE)
|
|
protected_path(UNINSTALL_REQUEST.parent, missing=True)
|
|
if UNINSTALL_REQUEST.parent.exists():
|
|
shutil.rmtree(UNINSTALL_REQUEST.parent)
|
|
sync_parent(UNINSTALL_REQUEST.parent)
|
|
|
|
def run(self):
|
|
if not self.job.get("dataRemoved"):
|
|
try:
|
|
self.remove_data()
|
|
except (AgentError, OSError, ValueError, KeyError) as error:
|
|
with contextlib.suppress(AgentError):
|
|
self.receipt("failed", str(error) if isinstance(error, AgentError) else "Host cleanup could not finish. Check the uninstall service and resolve filesystem errors; cleanup will retry.")
|
|
raise
|
|
self.receipt("cleaned")
|
|
while True:
|
|
try:
|
|
self.remove_helper()
|
|
break
|
|
except (AgentError, OSError, ValueError):
|
|
# Once its unit/executable has been unlinked systemd can no
|
|
# longer restart this worker. Finish idempotently in memory.
|
|
with contextlib.suppress(AgentError):
|
|
self.receipt("failed", "Host files were removed, but removing the uninstall service is still retrying")
|
|
time.sleep(15)
|
|
# The controller never marks a host deleted on the earlier receipt.
|
|
# No credentials or retry state are left on disk at this point.
|
|
while True:
|
|
try:
|
|
self.receipt("completed")
|
|
return
|
|
except AgentError:
|
|
time.sleep(15)
|
|
|
|
|
|
class LibraryTransfer:
|
|
"""Durable phase progress; throughput counts only work in this attempt."""
|
|
|
|
def __init__(self, progress, marker, previous=None):
|
|
self.progress = progress
|
|
self.marker = marker
|
|
self.lock = threading.RLock()
|
|
prior = (previous or {}).get("transfer") or {}
|
|
self.prior_phase = prior.get("phase")
|
|
self.prior_phase_elapsed = prior.get("phaseElapsedSeconds", 0)
|
|
self.started_at = prior.get("startedAt") or time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
self.elapsed = prior.get("elapsedSeconds", 0)
|
|
self.phase_elapsed = prior.get("phaseElapsedSeconds", 0) if prior.get("phase") == progress["status"] else 0
|
|
self.started = self.phase_started = time.monotonic()
|
|
self.phase = progress["status"]
|
|
self.work = 0
|
|
self.finished = False
|
|
self.failed_bytes_downloaded = None
|
|
self.persisted_at = self.started
|
|
self.persist(force=True)
|
|
|
|
def snapshot(self):
|
|
with self.lock:
|
|
if not self.finished:
|
|
now = time.monotonic()
|
|
sample_seconds = max(0, now - self.phase_started)
|
|
rate = self.work / sample_seconds if sample_seconds >= 1 and self.work else None
|
|
done = self.progress["bytesDownloaded"] if self.phase == "downloading" else self.progress["bytesVerified"]
|
|
self.progress["transfer"] = {
|
|
"phase": self.phase, "startedAt": self.started_at,
|
|
"elapsedSeconds": self.elapsed + max(0, now - self.started),
|
|
"phaseElapsedSeconds": self.phase_elapsed + sample_seconds,
|
|
"bytesPerSecond": rate,
|
|
"etaSeconds": max(0, self.progress["bytesTotal"] - done) / rate if rate else None,
|
|
}
|
|
return {**self.progress, "transfer": self.progress["transfer"].copy()}
|
|
|
|
def persist(self, force=False):
|
|
with self.lock:
|
|
now = time.monotonic()
|
|
self.snapshot()
|
|
if force or now - self.persisted_at >= 5:
|
|
atomic_json(self.marker, self.progress)
|
|
self.persisted_at = now
|
|
|
|
def advance(self, key, count, work):
|
|
with self.lock:
|
|
self.progress[key] += count
|
|
self.work += work
|
|
self.persist()
|
|
|
|
def begin_verification(self):
|
|
with self.lock:
|
|
self.progress.update(status="verifying", bytesVerified=0)
|
|
if self.phase != "verifying":
|
|
self.phase_elapsed = self.prior_phase_elapsed if self.prior_phase == "verifying" else 0
|
|
self.phase = "verifying"
|
|
self.phase_started = time.monotonic()
|
|
self.work = 0
|
|
self.persist(force=True)
|
|
|
|
def finish(self, status, error=None):
|
|
with self.lock:
|
|
self.snapshot()
|
|
self.finished = True
|
|
self.progress["status"] = status
|
|
if status == "failed" and self.failed_bytes_downloaded is not None:
|
|
self.progress["bytesDownloaded"] = self.failed_bytes_downloaded
|
|
self.progress["transfer"]["etaSeconds"] = 0 if status == "ready" else None
|
|
if error:
|
|
self.progress["error"] = error
|
|
self.persist(force=True)
|
|
|
|
|
|
class Agent:
|
|
def __init__(self, config, docker=None):
|
|
self.config = config
|
|
self.root = Path(config.get("root", "/var/lib/electric-cherry")).resolve()
|
|
for directory in ("agent", "instances", "libraries"):
|
|
(self.root / directory).mkdir(parents=True, exist_ok=True)
|
|
os.chmod(self.root / "agent", 0o700)
|
|
self.journal = Journal(self.root / "agent" / "operations.sqlite3")
|
|
self.docker = docker or Docker()
|
|
self.stopping = threading.Event()
|
|
self.current_operation = None
|
|
self.cpu_sample_lock = threading.Lock()
|
|
self.cpu_sample = None
|
|
self.instance_locks = {}
|
|
self.read_gate = ReadWriteGate()
|
|
self.read_claim_lock = threading.Lock()
|
|
self.read_claims = set()
|
|
self.health_probe_gate = threading.BoundedSemaphore(2)
|
|
self.cancelled_creations = frozenset()
|
|
self.defer_plutonium_preparation = False
|
|
self.observation_keep_days = 0 # Older controllers do not authorize cleanup.
|
|
self.library_progress = {}
|
|
self.library_transfers = {}
|
|
self.inventory_manifest_cache = {}
|
|
self.inventory_manifest_lock = threading.Lock()
|
|
self.plutonium_lock = threading.RLock()
|
|
self.config_path = config.get("configPath", "/etc/electric-cherry/agent.json")
|
|
self.executing_id = None
|
|
self.operation_phase = None
|
|
self.lane_lock = threading.Lock()
|
|
self.lanes = {name: {"thread": None, "lastPollAt": None, "polled": time.monotonic(), "busy": False}
|
|
for name in ("health-1", "health-2", "read", "write")}
|
|
|
|
def lane_started(self, name):
|
|
with self.lane_lock:
|
|
self.lanes[name]["thread"] = threading.current_thread()
|
|
|
|
def lane_polled(self, name):
|
|
with self.lane_lock:
|
|
self.lanes[name].update(lastPollAt=datetime.now(timezone.utc).isoformat(timespec="milliseconds"), polled=time.monotonic())
|
|
|
|
def lane_busy(self, name, busy):
|
|
with self.lane_lock:
|
|
self.lanes[name]["busy"] = busy
|
|
|
|
def lane_inventory(self):
|
|
with self.lane_lock:
|
|
return [{"name": name, "alive": lane["thread"] is None or lane["thread"].is_alive(),
|
|
"busy": lane["busy"], "lastPollAt": lane["lastPollAt"],
|
|
"pollAgeSeconds": max(0, time.monotonic() - lane["polled"])}
|
|
for name, lane in self.lanes.items()]
|
|
|
|
def can_uninstall(self):
|
|
try:
|
|
if Path(self.config_path) != AGENT_CONFIG:
|
|
return False
|
|
uninstall_ownership(self.config)
|
|
protected_file(UNINSTALL_EXECUTABLE)
|
|
protected_file(UNINSTALL_UNIT)
|
|
return True
|
|
except (AgentError, OSError, ValueError):
|
|
return False
|
|
|
|
def can_update(self):
|
|
try:
|
|
if Path(self.config_path) != AGENT_CONFIG:
|
|
return False
|
|
uninstall_ownership(self.config)
|
|
for path in (UPDATE_EXECUTABLE, UPDATE_UNIT, UPDATE_TIMER):
|
|
protected_file(path)
|
|
protected_path(UPDATE_TIMER_ENABLED.parent)
|
|
return UPDATE_TIMER_ENABLED.is_symlink() and UPDATE_TIMER_ENABLED.resolve() == UPDATE_TIMER
|
|
except (AgentError, OSError, ValueError):
|
|
return False
|
|
|
|
def update_inventory(self):
|
|
result = {"enabled": self.can_update(), "state": "current", "currentVersion": VERSION}
|
|
if Path(self.config_path) != AGENT_CONFIG:
|
|
return result
|
|
try:
|
|
protected_file(UPDATE_STATUS, missing=True)
|
|
if not UPDATE_STATUS.exists():
|
|
return result
|
|
if UPDATE_STATUS.stat().st_size > 16384:
|
|
raise ValueError("Invalid update status")
|
|
saved = json.loads(UPDATE_STATUS.read_text())
|
|
if saved.get("state") in ("current", "checking", "deferred", "updating", "updated", "failed"):
|
|
result["state"] = saved["state"]
|
|
for key in ("targetVersion", "error"):
|
|
if isinstance(saved.get(key), str):
|
|
result[key] = saved[key][:2000 if key == "error" else 80]
|
|
checked_at = saved.get("checkedAt")
|
|
if isinstance(checked_at, (int, float)) and not isinstance(checked_at, bool):
|
|
result["checkedAt"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(checked_at))
|
|
elif isinstance(checked_at, str):
|
|
result["checkedAt"] = checked_at[:80]
|
|
except (AgentError, OSError, ValueError, TypeError, AttributeError, OverflowError):
|
|
result.update(state="failed", error="Agent update status is unavailable; inspect the update service.")
|
|
return result
|
|
|
|
def update_pending(self):
|
|
# A persisted transaction survives crashes and is cleared only by the
|
|
# independent worker after health confirmation or a completed rollback.
|
|
return Path(self.config_path) == AGENT_CONFIG and (UPDATE_JOURNAL.exists() or UPDATE_JOURNAL.is_symlink())
|
|
|
|
@contextlib.contextmanager
|
|
def activity(self, read_only=False):
|
|
path = self.root / "agent" / "activity.lock"
|
|
descriptor = os.open(path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
|
mutation_descriptor = None
|
|
try:
|
|
try:
|
|
# All work excludes the updater. Mutations additionally exclude
|
|
# other mutation batches while read lanes may continue.
|
|
fcntl.flock(descriptor, fcntl.LOCK_SH | fcntl.LOCK_NB)
|
|
if not read_only:
|
|
mutation_descriptor = os.open(self.root / "agent" / "mutation.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
|
fcntl.flock(mutation_descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except BlockingIOError:
|
|
yield False
|
|
return
|
|
yield True
|
|
finally:
|
|
if mutation_descriptor is not None:
|
|
os.close(mutation_descriptor)
|
|
os.close(descriptor)
|
|
|
|
def record_health(self):
|
|
if Path(self.config_path) != AGENT_CONFIG or not self.can_update():
|
|
return
|
|
value = {"version": VERSION, "pid": os.getpid(), "checkedAt": time.time()}
|
|
protected_file(AGENT_HEALTH, missing=True)
|
|
if self.update_pending():
|
|
protected_file(UPDATE_JOURNAL)
|
|
journal = json.loads(UPDATE_JOURNAL.read_text())
|
|
if isinstance(journal.get("id"), str):
|
|
value["updateId"] = journal["id"]
|
|
atomic_json(AGENT_HEALTH, value)
|
|
|
|
def decommission(self, operation):
|
|
payload = operation.get("payload", {})
|
|
if payload.get("hostId") != self.config.get("hostId") or payload.get("operationId") != operation["id"]:
|
|
raise Rejected("Host removal identity does not match this agent")
|
|
if payload.get("receiptUrl") != "/api/agent/decommission/" + identifier(operation["id"]) or not isinstance(payload.get("receiptToken"), str) or not payload["receiptToken"]:
|
|
raise Rejected("Invalid host removal receipt")
|
|
if not self.can_uninstall():
|
|
raise Rejected("Reinstall the agent to enable safe removal of its dedicated host files")
|
|
root = uninstall_ownership(self.config)
|
|
info = root.stat()
|
|
protected_file(UNINSTALL_REQUEST, missing=True)
|
|
aliases_path = root / "agent" / "runtime-images.json"
|
|
protected_file(aliases_path, missing=True)
|
|
aliases = json.loads(aliases_path.read_text()) if aliases_path.exists() else {}
|
|
job = {"hostId": payload["hostId"], "operationId": operation["id"], "receiptUrl": payload["receiptUrl"], "receiptToken": payload["receiptToken"], "controller": self.config["controller"].rstrip("/"), "root": str(root), "rootIdentity": [info.st_dev, info.st_ino], "runtimeImages": aliases}
|
|
if UNINSTALL_REQUEST.exists():
|
|
previous = json.loads(UNINSTALL_REQUEST.read_text())
|
|
if any(previous.get(key) != job[key] for key in ("hostId", "operationId", "receiptUrl", "receiptToken", "controller", "root", "rootIdentity")):
|
|
raise Rejected("A different host removal is already pending")
|
|
self.checkpoint({"phase": "uninstall-service"})
|
|
if not UNINSTALL_REQUEST.exists():
|
|
atomic_json(UNINSTALL_REQUEST, job)
|
|
systemctl("enable", "--now", "--no-block", UNINSTALL_UNIT.name)
|
|
self.stopping.set()
|
|
return {"decommissioning": True, "message": "The uninstall service will report completion after host files and the agent are removed"}
|
|
|
|
def instance_dir(self, instance_id, generation):
|
|
instance_id, generation = instance_identity(instance_id, generation)
|
|
return self.root / "instances" / instance_id / str(generation)
|
|
|
|
def container_name(self, instance_id, generation):
|
|
instance_id, generation = instance_identity(instance_id, generation)
|
|
return f"ec-{instance_id}-g{generation}"
|
|
|
|
def inspect(self, instance_id, generation):
|
|
name = self.container_name(instance_id, generation)
|
|
info = self.docker.inspect(name)
|
|
if info is None:
|
|
return {"exists": False, "running": False, "healthy": False, "state": "missing", "instanceId": instance_id, "generation": generation}
|
|
labels = info.get("Config", {}).get("Labels", {}) or {}
|
|
if labels.get(LABEL + ".managed") != "true" or labels.get(LABEL + ".instance") != instance_id or labels.get(LABEL + ".generation") != str(generation):
|
|
raise AgentError("Container ownership or generation does not match")
|
|
state = info.get("State", {})
|
|
status = state.get("Status", "unknown")
|
|
healthy = state.get("Status") == "running" and state.get("Running") is True and state.get("Health", {}).get("Status", "healthy") == "healthy"
|
|
if status == "running" and not healthy:
|
|
status = state.get("Health", {}).get("Status", "unknown")
|
|
return {"exists": True, "running": state.get("Running") is True, "healthy": healthy, "state": status, "containerId": info.get("Id"), "instanceId": instance_id, "generation": generation, "startedAt": state.get("StartedAt"), "creationOperation": labels.get(LABEL + ".operation"), "specSha256": labels.get(LABEL + ".spec-sha256")}
|
|
|
|
def checkpoint(self, value):
|
|
if self.executing_id:
|
|
self.journal.checkpoint(self.executing_id, value)
|
|
|
|
def mark_removed(self, instance_id, generation):
|
|
marker = self.instance_dir(instance_id, generation).parent / "current.json"
|
|
if not marker.exists() or json.loads(marker.read_text())["generation"] <= generation:
|
|
atomic_json(marker, {"generation": generation, "removed": True})
|
|
|
|
def make_writable(self, path, directory=False):
|
|
if os.geteuid() == 0:
|
|
os.chown(path, int(self.config.get("runtimeUid", 1000)), int(self.config.get("runtimeGid", 1000)))
|
|
os.chmod(path, 0o755 if directory else 0o644)
|
|
elif directory:
|
|
os.chmod(path, 0o777)
|
|
|
|
def spec(self, instance_id, generation):
|
|
path = self.instance_dir(instance_id, generation) / "spec.json"
|
|
try:
|
|
return json.loads(path.read_text())
|
|
except (OSError, ValueError):
|
|
raise AgentError("Managed instance metadata is unavailable") from None
|
|
|
|
def request(self, method, path, payload=None, credential=True):
|
|
url = self.config["controller"].rstrip("/") + path
|
|
if urllib.parse.urlsplit(url).scheme != "https" and not self.config.get("allowInsecureLocalhost", False):
|
|
raise AgentError("Controller URL must use HTTPS")
|
|
if urllib.parse.urlsplit(url).scheme != "https" and urllib.parse.urlsplit(url).hostname not in ("127.0.0.1", "localhost", "::1"):
|
|
raise AgentError("Insecure development transport is limited to loopback")
|
|
headers = {"Content-Type": "application/json", "User-Agent": "electric-cherry-agent/" + VERSION}
|
|
if credential:
|
|
headers["Authorization"] = "Bearer " + self.config["credential"]
|
|
data = None if payload is None else json.dumps(payload).encode()
|
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
try:
|
|
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
def redirect_request(self, req, fp, code, msg, hdrs, newurl):
|
|
raise AgentError("Controller redirects are not allowed")
|
|
with urllib.request.build_opener(NoRedirect()).open(req, timeout=35) as response:
|
|
return json.load(response)
|
|
except (OSError, ValueError, http.client.HTTPException):
|
|
raise AgentError("Controller request failed") from None
|
|
|
|
def cpu_usage_percent(self):
|
|
"""Host-wide busy time since the previous inventory sample, without waiting."""
|
|
with self.cpu_sample_lock:
|
|
previous = self.cpu_sample
|
|
self.cpu_sample = None
|
|
try:
|
|
with Path("/proc/stat").open() as source:
|
|
fields = source.readline().split()
|
|
if not fields or fields[0] != "cpu" or len(fields) < 9:
|
|
return None
|
|
# Guest time is already included in user/nice. Idle and I/O
|
|
# wait are not busy; steal counts as unavailable CPU capacity.
|
|
counters = tuple(int(value) for value in fields[1:9])
|
|
if any(value < 0 for value in counters):
|
|
return None
|
|
except (OSError, ValueError):
|
|
return None
|
|
self.cpu_sample = counters
|
|
if previous is None:
|
|
return None
|
|
deltas = [current - old for current, old in zip(counters, previous)]
|
|
total = sum(deltas)
|
|
# Counters can reset and Linux can revise I/O wait downwards.
|
|
# Rebase rather than reporting a misleading utilization value.
|
|
if total <= 0 or any(delta < 0 for delta in deltas):
|
|
return None
|
|
return round(100 * (total - deltas[3] - deltas[4]) / total, 1)
|
|
|
|
def instance_resources(self, observations=None):
|
|
"""One bounded Docker stats batch; unavailable samples are never zero load."""
|
|
ids = self.docker.call("container", "ls", "--all", "--filter", "label=" + LABEL + ".managed=true",
|
|
"--no-trunc", "--format", "{{.ID}}", timeout=10).splitlines()[:1000]
|
|
if not ids:
|
|
return []
|
|
observed_at = datetime.now(timezone.utc).isoformat(timespec="milliseconds")
|
|
containers = json.loads(self.docker.call("container", "inspect", *ids, timeout=10))
|
|
owned = {}
|
|
for info in containers:
|
|
try:
|
|
labels = info.get("Config", {}).get("Labels", {}) or {}
|
|
if labels.get(LABEL + ".managed") != "true":
|
|
continue
|
|
instance_id, generation = instance_identity(labels.get(LABEL + ".instance"), int(labels.get(LABEL + ".generation", "")))
|
|
name = self.container_name(instance_id, generation)
|
|
if info.get("Name", "").lstrip("/") != name:
|
|
continue
|
|
expected = str(self.instance_dir(instance_id, generation) / "app")
|
|
if not any(mount.get("Type") == "bind" and mount.get("Source") == expected and mount.get("Destination") == "/home/plutainer/app" for mount in info.get("Mounts", [])):
|
|
continue
|
|
state = info.get("State", {})
|
|
healthy = state.get("Status") == "running" and state.get("Running") is True and state.get("Health", {}).get("Status", "healthy") == "healthy"
|
|
status = state.get("Status", "unknown")
|
|
if status == "running" and not healthy:
|
|
status = state.get("Health", {}).get("Status", "unknown")
|
|
if observations is not None:
|
|
observations.append({"instanceId": instance_id, "generation": generation,
|
|
"state": status, "healthy": healthy, "observedAt": observed_at})
|
|
if not state.get("Running"):
|
|
continue
|
|
started = datetime.fromisoformat(info["State"]["StartedAt"].replace("Z", "+00:00"))
|
|
uptime = max(0, int((datetime.now(timezone.utc) - started).total_seconds()))
|
|
owned[name] = {"instanceId": instance_id, "generation": generation, "uptimeSeconds": uptime}
|
|
except (AgentError, ValueError, KeyError, TypeError):
|
|
continue
|
|
if not owned:
|
|
return []
|
|
rows = self.docker.call("stats", "--no-stream", "--no-trunc", "--format", "{{json .}}", *owned, timeout=15)
|
|
samples = []
|
|
units = {"B": 1, "KiB": 1024, "MiB": 1024 ** 2, "GiB": 1024 ** 3,
|
|
"TiB": 1024 ** 4, "kB": 1000, "MB": 1000 ** 2, "GB": 1000 ** 3}
|
|
for line in rows.splitlines():
|
|
try:
|
|
row = json.loads(line)
|
|
identity = owned.get(row.get("Name"))
|
|
if not identity or int(row["PIDs"]) <= 0:
|
|
continue
|
|
# Docker Linux CPU percent is core-based: 150% means 1.5 cores.
|
|
percent = row["CPUPerc"]
|
|
if not percent.endswith("%"):
|
|
continue
|
|
cores = float(percent[:-1]) / 100
|
|
used = re.fullmatch(r"([0-9]+(?:\.[0-9]+)?)\s*([A-Za-z]+)", row["MemUsage"].split("/", 1)[0].strip())
|
|
if not used or used[2] not in units or not math.isfinite(cores) or cores < 0:
|
|
continue
|
|
memory = math.ceil(float(used[1]) * units[used[2]])
|
|
if memory > 0:
|
|
samples.append({**identity, "cpuCores": cores, "memoryBytes": memory})
|
|
except (ValueError, KeyError, TypeError, AttributeError, OverflowError):
|
|
continue
|
|
return samples
|
|
|
|
def inventory_manifest(self, marker):
|
|
# Inventory only: launch/download verification must still read disk.
|
|
# Include file identity so atomic replacements invalidate even when
|
|
# their size and modification time match the previous manifest.
|
|
with self.inventory_manifest_lock:
|
|
try:
|
|
metadata = marker.stat()
|
|
signature = (metadata.st_mtime_ns, metadata.st_size, metadata.st_dev, metadata.st_ino)
|
|
cached = self.inventory_manifest_cache.get(marker)
|
|
if cached is not None and cached[0] == signature:
|
|
return cached[1]
|
|
self.inventory_manifest_cache.pop(marker, None)
|
|
value = json.loads(marker.read_text())
|
|
self.inventory_manifest_cache[marker] = (signature, value)
|
|
return value
|
|
except (OSError, ValueError):
|
|
self.inventory_manifest_cache.pop(marker, None)
|
|
raise
|
|
|
|
def inventory(self):
|
|
cpu_usage = self.cpu_usage_percent()
|
|
usage = shutil.disk_usage(self.root)
|
|
memory = {}
|
|
try:
|
|
for line in Path("/proc/meminfo").read_text().splitlines():
|
|
key, value = line.split(":", 1)
|
|
memory[key] = int(value.strip().split()[0]) * 1024
|
|
except (OSError, ValueError):
|
|
pass
|
|
docker_version, docker_running = "", False
|
|
try:
|
|
info = json.loads(self.docker.call("info", "--format", "{{json .}}", timeout=15))
|
|
docker_version, docker_running = info.get("ServerVersion", ""), True
|
|
except (AgentError, ValueError):
|
|
pass
|
|
libraries, caches = [], []
|
|
progress = {key: value.copy() for key, value in list(self.library_progress.items())}
|
|
progress.update({key: transfer.snapshot() for key, transfer in list(self.library_transfers.items())})
|
|
manifests = list((self.root / "libraries").glob("*/*/manifest.json"))
|
|
partial_manifests = list((self.root / "libraries").glob("*/.*.partial/source.json"))
|
|
with self.inventory_manifest_lock:
|
|
for marker in self.inventory_manifest_cache.keys() - set(manifests + partial_manifests):
|
|
del self.inventory_manifest_cache[marker]
|
|
for marker in manifests:
|
|
# A crash between writing a manifest and its atomic promotion must
|
|
# never expose the staging directory as a ready library.
|
|
if marker.parent.name.startswith("."):
|
|
continue
|
|
try:
|
|
value = self.inventory_manifest(marker)
|
|
# Rechecking an existing cache must revoke readiness until its
|
|
# entire contents have passed verification again.
|
|
if value["libraryId"] not in progress and (marker.parent / "transfer.json").exists():
|
|
saved_progress = json.loads((marker.parent / "transfer.json").read_text())
|
|
if saved_progress.get("status") != "ready":
|
|
progress[value["libraryId"]] = {**saved_progress, "status": "failed" if saved_progress.get("status") == "failed" else "missing"}
|
|
if value["libraryId"] in progress:
|
|
continue
|
|
libraries.append({"id": value["libraryId"], "state": "ready"})
|
|
cache = {"libraryVersionId": value["libraryId"], "status": "ready", "bytesDownloaded": value["size"], "bytesVerified": value["size"], "bytesTotal": value["size"], "manifestSha256": value.get("manifestSha256")}
|
|
if value.get("transfer"):
|
|
cache["transfer"] = value["transfer"]
|
|
caches.append(cache)
|
|
except (OSError, ValueError, KeyError):
|
|
continue
|
|
ready = {item["id"] for item in libraries}
|
|
for marker in partial_manifests:
|
|
try:
|
|
value = self.inventory_manifest(marker)
|
|
library_id = identifier(value["libraryId"])
|
|
if library_id in ready or library_id in progress:
|
|
continue
|
|
expected = self.library_dir(value["game"], library_id).with_name("." + library_id + ".partial")
|
|
if marker.parent != expected or marker.is_symlink() or marker.parent.is_symlink():
|
|
continue
|
|
files = value["files"]
|
|
canonical = json.dumps([{ "path": item["path"], "size": item["size"], "sha256": item["sha256"] } for item in files], separators=(",", ":"), ensure_ascii=False)
|
|
if digest(canonical.encode("utf-8")) != value["manifestSha256"]:
|
|
continue
|
|
retained, size, paths = 0, 0, set()
|
|
for item in files:
|
|
if item["path"] in paths or type(item["size"]) is not int or item["size"] < 0:
|
|
raise AgentError("Invalid partial library manifest")
|
|
paths.add(item["path"])
|
|
target = scoped(marker.parent / "content", item["path"])
|
|
size += item["size"]
|
|
if target.exists() and stat_module.S_ISREG(target.stat().st_mode):
|
|
retained += min(target.stat().st_size, item["size"])
|
|
# These are resumable bytes, not verified content. Reporting
|
|
# them survives restarts without requiring space for two copies.
|
|
cache = {"libraryVersionId": library_id, "status": "missing", "bytesDownloaded": retained, "bytesVerified": 0, "bytesTotal": size, "manifestSha256": value["manifestSha256"]}
|
|
transfer_marker = marker.parent / "transfer.json"
|
|
if transfer_marker.exists():
|
|
saved_progress = json.loads(transfer_marker.read_text())
|
|
if saved_progress.get("manifestSha256") == value["manifestSha256"]:
|
|
cache.update(bytesVerified=saved_progress.get("bytesVerified", 0), transfer=saved_progress.get("transfer"))
|
|
if saved_progress.get("status") == "failed":
|
|
cache.update(status="failed", error=saved_progress.get("error"))
|
|
progress[library_id] = cache
|
|
except (AgentError, OSError, ValueError, KeyError, TypeError):
|
|
continue
|
|
progress = list(progress.values())
|
|
libraries.extend({"id": item["libraryVersionId"], "state": item["status"]} for item in progress)
|
|
caches.extend(progress)
|
|
images, prepared_images = [], []
|
|
if docker_running:
|
|
with contextlib.suppress(AgentError):
|
|
lines = self.docker.call("image", "ls", "--no-trunc", "--digests", "--format", "{{.Repository}}|{{.Tag}}|{{.Digest}}|{{.ID}}", timeout=15).splitlines()
|
|
image_ids = set()
|
|
for line in lines:
|
|
parts = line.split("|")
|
|
if len(parts) != 4:
|
|
continue
|
|
repository, tag, image_digest, image_id = parts
|
|
image_ids.add(image_id)
|
|
if repository != "<none>" and tag != "<none>":
|
|
images.append(repository + ":" + tag)
|
|
if repository != "<none>" and image_digest != "<none>":
|
|
images.append(repository + "@" + image_digest)
|
|
aliases = self.root / "agent" / "runtime-images.json"
|
|
if aliases.exists():
|
|
with contextlib.suppress(OSError, ValueError):
|
|
saved = json.loads(aliases.read_text())
|
|
if isinstance(saved, dict):
|
|
# Only runtime.pull records administrator-prepared
|
|
# references. Other Docker cache entries are useful
|
|
# diagnostics, but do not grant launch permission.
|
|
prepared_images = [alias for alias, image_id in saved.items() if isinstance(image_id, str) and image_id in image_ids]
|
|
images.extend(prepared_images)
|
|
resources = {}
|
|
if docker_running:
|
|
observations = []
|
|
try:
|
|
resources["instanceResources"] = self.instance_resources(observations)
|
|
except (AgentError, ValueError, TypeError, KeyError):
|
|
resources["instanceResourcesError"] = "Container resource sampling failed. Check Docker access and stats availability on this host."
|
|
# A failed stats call does not discard a successful inspection.
|
|
# Missing entries remain unknown; they are never inferred stopped.
|
|
resources["instanceStates"] = observations
|
|
os_name = platform.system()
|
|
with contextlib.suppress(OSError):
|
|
for line in Path("/etc/os-release").read_text().splitlines():
|
|
if line.startswith("PRETTY_NAME="):
|
|
os_name = line.split("=", 1)[1].strip('"')
|
|
update = self.update_inventory()
|
|
capabilities = ["library.remove", "plutonium.cache", "instance.cancel-create", "instance.safety-override", "instance.unhealthy-recovery", "instance.autoscaling-remove", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest", "files.blobs", "logs.read-offset", "bank.settlement"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
|
|
plutonium_cache = self.plutonium_cache_inventory()
|
|
return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "agentLanes": self.lane_inventory(), "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)), "preparedRuntimeImages": sorted(set(prepared_images)), **({"plutoniumCache": plutonium_cache} if plutonium_cache else {}), "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, *, allow_unhealthy=False):
|
|
health = self.inspect(instance_id, generation)
|
|
if health["state"] in ("missing", "stopped", "exited", "created", "dead"):
|
|
return
|
|
# Provisioning recovery may treat explicitly unhealthy containers as
|
|
# empty. Reinspect here so recovered containers still require occupancy.
|
|
if allow_unhealthy and health["state"] == "unhealthy":
|
|
return
|
|
if not health["running"]:
|
|
raise Held("Container state is unknown; disruption held")
|
|
result = self.rcon(instance_id, generation, "status")
|
|
if not result.get("ok") or result.get("players") is None:
|
|
# Keep diagnostics bounded and do not include raw replies: they may
|
|
# contain player addresses or echoed credentials.
|
|
reason = {
|
|
"unconfigured": "local RCON password is not configured",
|
|
"unreachable": "local RCON endpoint is unreachable",
|
|
"invalid-reply": "local RCON reply has an invalid packet prefix",
|
|
"timeout": "local RCON status query timed out",
|
|
"authentication": "local RCON authentication failed",
|
|
}.get(result.get("reason"), "local RCON status reply has no recognized player count" if result.get("ok") else "local RCON query failed")
|
|
raise Held(f"Fresh local player occupancy is unavailable: {reason}; disruption held")
|
|
if result["players"] != 0:
|
|
raise Held("Players are present; disruption held")
|
|
|
|
def rcon_status_batch(self, operation):
|
|
targets = operation.get("payload", {}).get("instances")
|
|
if not isinstance(targets, list) or not 1 <= len(targets) <= 1000 or any(not isinstance(target, dict) for target in targets):
|
|
raise AgentError("Invalid status batch")
|
|
identities = [instance_identity(target.get("instanceId"), target.get("generation")) for target in targets]
|
|
if len(set(identities)) != len(identities):
|
|
raise AgentError("Duplicate status batch target")
|
|
deadline = time.monotonic() + 20
|
|
|
|
def probe(identity):
|
|
instance_id, generation = identity
|
|
target = {"instanceId": instance_id, "generation": generation}
|
|
reply = {"ok": False, "reason": "deadline", "text": ""}
|
|
# A batch does not hold every instance lock while one server waits.
|
|
# Bound concurrency across overlapping batches as well as within one.
|
|
lock = self.instance_lock(target)
|
|
# Use the same lock order as individual health-lane operations.
|
|
if lock.acquire(timeout=max(0, deadline - time.monotonic())):
|
|
try:
|
|
if self.health_probe_gate.acquire(timeout=max(0, deadline - time.monotonic())):
|
|
try:
|
|
marker = self.instance_dir(instance_id, generation).parent / "current.json"
|
|
current = json.loads(marker.read_text()) if marker.exists() else {}
|
|
if current.get("generation", generation) != generation or current.get("removed"):
|
|
reply = {"ok": False, "reason": "superseded", "text": ""}
|
|
elif time.monotonic() < deadline:
|
|
reply = self.rcon(instance_id, generation, "status", deadline=deadline, status_attempts=1)
|
|
except (AgentError, OSError, ValueError, KeyError):
|
|
reply = {"ok": False, "reason": "unreachable", "text": ""}
|
|
finally:
|
|
self.health_probe_gate.release()
|
|
finally:
|
|
lock.release()
|
|
return {**target, "reply": reply}
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
|
results = list(executor.map(probe, identities))
|
|
# Completion describes the batch; individual failures remain in each reply.
|
|
return {"ok": True, "results": results}
|
|
|
|
def rcon(self, instance_id, generation, command, deadline=None, status_attempts=3):
|
|
if not isinstance(command, str) or not command or len(command) > 2048 or any(ch in command for ch in ("\x00", "\r", "\n")):
|
|
raise AgentError("Invalid RCON command")
|
|
saved = self.spec(instance_id, generation)
|
|
password = saved.get("rconPassword") or saved.get("env", {}).get("PLUTAINER_RCON_PASSWORD")
|
|
if not password:
|
|
return {"ok": False, "reason": "unconfigured", "players": None, "text": ""}
|
|
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
|
|
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.
|
|
attempts = status_attempts if command == "status" else 1
|
|
for attempt in range(attempts):
|
|
if deadline is not None and time.monotonic() >= deadline:
|
|
return {"ok": False, "reason": "deadline" if attempt == 0 else "timeout", "players": None, "text": ""}
|
|
result = self.rcon_attempt(family, host, int(saved["port"]), password, command, **({"deadline": deadline} if deadline is not None else {}))
|
|
if result["ok"] or result.get("reason") not in ("timeout", "unreachable") or attempt == attempts - 1:
|
|
return result
|
|
time.sleep(min(0.25 * 2 ** attempt, max(0, deadline - time.monotonic())) if deadline is not None else 0.25 * 2 ** attempt)
|
|
|
|
def rcon_attempt(self, family, host, port, password, command, deadline=None):
|
|
chunks = []
|
|
with socket.socket(family, socket.SOCK_DGRAM) as sock:
|
|
sock.settimeout(max(0.001, min(3, deadline - time.monotonic())) if deadline is not None else 3)
|
|
try:
|
|
sock.connect((host, port))
|
|
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:
|
|
if deadline is not None and time.monotonic() >= deadline:
|
|
break
|
|
chunks.append(sock.recv(65535))
|
|
sock.settimeout(max(0.001, min(0.15, deadline - time.monotonic())) if deadline is not None else 0.15)
|
|
except socket.timeout:
|
|
pass
|
|
except OSError:
|
|
return {"ok": False, "reason": "unreachable", "players": None, "text": ""}
|
|
if any(not chunk.startswith(b"\xff\xff\xff\xff") for chunk in chunks):
|
|
return {"ok": False, "reason": "invalid-reply", "players": None, "text": ""}
|
|
text = "\n".join(chunk[4:].decode("utf-8", "replace").removeprefix("print\n") for chunk in chunks)
|
|
if not text or re.search(r"(bad|invalid|wrong).{0,20}(password|rcon)", text, re.I):
|
|
return {"ok": False, "reason": "timeout" if not text else "authentication", "players": None, "text": text}
|
|
players = status_players(text) if command == "status" else None
|
|
return {"ok": True, "text": text, "players": players}
|
|
|
|
def validate_spec(self, payload):
|
|
game = payload.get("game")
|
|
if game not in ("t5", "t6"):
|
|
raise AgentError("Only T5 and T6 are supported")
|
|
library = self.library_dir(game, payload.get("libraryId"))
|
|
self.verify_library(library)
|
|
image = payload.get("image", "")
|
|
if not isinstance(image, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._/:@-]{0,511}", image):
|
|
raise AgentError("Invalid runtime image")
|
|
port = payload.get("port")
|
|
allowed = self.config.get("portRange", {"min": 28960, "max": 29060})
|
|
if isinstance(port, bool) or not isinstance(port, int) or not allowed["min"] <= port <= allowed["max"]:
|
|
raise AgentError("Port is outside the configured host range")
|
|
bind = payload.get("bindAddress", "0.0.0.0")
|
|
try:
|
|
import ipaddress
|
|
ipaddress.ip_address(bind)
|
|
except (ValueError, TypeError):
|
|
raise AgentError("Invalid local bind address") from None
|
|
env = dict(payload.get("env", {}))
|
|
if payload.get("rconPassword"):
|
|
env["PLUTAINER_RCON_PASSWORD"] = payload["rconPassword"]
|
|
if not isinstance(env, dict) or any(not re.fullmatch(r"[A-Z][A-Z0-9_]*", key) or not isinstance(value, str) or "\x00" in value or "\n" in value for key, value in env.items()):
|
|
raise AgentError("Invalid runtime environment")
|
|
# Runtime download is a separate preparation operation. A deployment
|
|
# uses the already prepared image even during a registry outage.
|
|
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 = self.initial_files(payload)
|
|
if not isinstance(files, list) or len(files) > 10000:
|
|
raise Rejected("Invalid initial file list")
|
|
total, paths = 0, set()
|
|
for item in files:
|
|
scoped(self.root / "agent" / "validation", item["path"])
|
|
if item["path"] in paths:
|
|
raise Rejected("Duplicate initial file path")
|
|
paths.add(item["path"])
|
|
total += len(self.decode_file(item))
|
|
if total > 64 * 1024 * 1024:
|
|
raise Rejected("Initial files exceed the 64 MiB operation limit")
|
|
return game, library, image, image_id, port, bind, env
|
|
|
|
@staticmethod
|
|
def plutonium_manifest(data):
|
|
"""Validate the official updater format before using any paths or URLs."""
|
|
if len(data) > PLUTONIUM_MANIFEST_LIMIT:
|
|
raise AgentError("Plutonium manifest is too large")
|
|
try:
|
|
manifest = json.loads(data)
|
|
except (ValueError, UnicodeError):
|
|
raise AgentError("Invalid Plutonium manifest") from None
|
|
if not isinstance(manifest, dict) or manifest.get("product") != "plutonium-core-prod":
|
|
raise AgentError("Invalid Plutonium production manifest")
|
|
revision = manifest.get("revision")
|
|
if type(revision) is not int or not 1 <= revision <= 2147483647:
|
|
raise AgentError("Invalid Plutonium revision")
|
|
base = manifest.get("baseUrl")
|
|
if not isinstance(base, str) or not re.fullmatch(r"https://cdn\.plutoniummod\.com/updater/prod/(?:[A-Za-z0-9_-]+/)*", base):
|
|
raise AgentError("Plutonium downloads must use the official production CDN")
|
|
entries = manifest.get("files")
|
|
if not isinstance(entries, list) or not 1 <= len(entries) <= 4096:
|
|
raise AgentError("Invalid Plutonium file list")
|
|
names, total = set(), 0
|
|
for entry in entries:
|
|
if not isinstance(entry, dict):
|
|
raise AgentError("Invalid Plutonium file entry")
|
|
name, size, checksum = entry.get("name"), entry.get("size"), entry.get("hash")
|
|
if not isinstance(name, str) or len(name) > 512 or re.search(r'[\x00-\x1f\x7f\\:*?"<>|]', name):
|
|
raise AgentError("Invalid Plutonium file path")
|
|
path = PurePosixPath(name)
|
|
if not path.parts or path.is_absolute() or str(path) != name or any(part in (".", "..") or part.endswith((".", " ")) for part in path.parts) or name.lower() == "cdn_info.json":
|
|
raise AgentError("Invalid Plutonium file path")
|
|
if name.lower() in names:
|
|
raise AgentError("Duplicate Plutonium file path")
|
|
names.add(name.lower())
|
|
if type(size) is not int or not 0 <= size <= PLUTONIUM_FILE_LIMIT:
|
|
raise AgentError("Invalid Plutonium file size")
|
|
if not isinstance(checksum, str) or not re.fullmatch(r"[a-f0-9]{40}", checksum):
|
|
raise AgentError("Invalid Plutonium file hash")
|
|
total += size
|
|
if total > PLUTONIUM_CACHE_LIMIT:
|
|
raise AgentError("Plutonium cache exceeds the size limit")
|
|
for name in names:
|
|
if any(str(parent) in names for parent in PurePosixPath(name).parents if str(parent) != "."):
|
|
raise AgentError("Conflicting Plutonium file paths")
|
|
if "bin/plutonium-bootstrapper-win32.exe" not in {entry["name"] for entry in entries}:
|
|
raise AgentError("Plutonium manifest is missing the server bootstrapper")
|
|
# Match plutonium-updater's default: launcher assets are optional.
|
|
return manifest, [entry for entry in entries if not entry["name"].startswith("launcher")]
|
|
|
|
@staticmethod
|
|
def plutonium_file_matches(root, entry):
|
|
try:
|
|
with managed_open(root, entry["name"]) as stream:
|
|
if os.fstat(stream.fileno()).st_size != entry["size"]:
|
|
return False
|
|
checksum = hashlib.sha1()
|
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
checksum.update(chunk)
|
|
return checksum.hexdigest() == entry["hash"]
|
|
except (OSError, AgentError):
|
|
return False
|
|
|
|
def plutonium_open(self, url):
|
|
if not isinstance(url, str) or not re.fullmatch(r"https://cdn\.plutoniummod\.com/updater/prod/[A-Za-z0-9_./-]+", url):
|
|
raise AgentError("Plutonium downloads must use the official production CDN")
|
|
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
def redirect_request(self, req, fp, code, msg, hdrs, newurl):
|
|
raise AgentError("Plutonium download redirects are not allowed")
|
|
# Never send the controller's host credentials to the public CDN.
|
|
return urllib.request.build_opener(NoRedirect()).open(url, timeout=30)
|
|
|
|
def plutonium_snapshot(self):
|
|
root = scoped(self.root / "agent", "plutonium-cache")
|
|
pointer = json.loads(managed_read(root, "current.json") or b"null")
|
|
if not isinstance(pointer, dict) or not isinstance(pointer.get("key"), str) or not re.fullmatch(r"[a-f0-9]{64}", pointer["key"]):
|
|
raise AgentError("Plutonium cache is not prepared")
|
|
content = scoped(root, pointer["key"] + "/content")
|
|
raw = managed_read(content, "cdn_info.json")
|
|
if raw is None or digest(raw) != pointer["key"]:
|
|
raise AgentError("Plutonium cache manifest is invalid")
|
|
manifest, files = self.plutonium_manifest(raw)
|
|
if pointer.get("revision") != manifest["revision"] or pointer.get("bytes") != sum(entry["size"] for entry in files):
|
|
raise AgentError("Plutonium cache metadata is invalid")
|
|
# Heartbeat checks are cheap. Every file is hashed before it is seeded.
|
|
for entry in files:
|
|
info = scoped(content, entry["name"]).lstat()
|
|
if not stat_module.S_ISREG(info.st_mode) or info.st_size != entry["size"]:
|
|
raise AgentError("Plutonium cache files are incomplete")
|
|
return pointer, content, raw, files
|
|
|
|
def plutonium_cache_inventory(self):
|
|
with self.plutonium_lock:
|
|
try:
|
|
pointer, _, _, _ = self.plutonium_snapshot()
|
|
return {"revision": pointer["revision"], "bytes": pointer["bytes"]}
|
|
except (OSError, ValueError, AgentError):
|
|
return None
|
|
|
|
def prepare_plutonium(self, payload):
|
|
target, reserve = payload.get("revision", 0), payload.get("minimumFreeBytes", 0)
|
|
if type(target) is not int or not 0 <= target <= 2147483647 or type(reserve) is not int or reserve < 0:
|
|
raise Rejected("Invalid Plutonium preparation request")
|
|
root = scoped(self.root / "agent", "plutonium-cache")
|
|
root.mkdir(mode=0o700, exist_ok=True)
|
|
os.chmod(root, 0o700)
|
|
deadline = time.monotonic() + 1800
|
|
def active():
|
|
if self.stopping.is_set() or time.monotonic() >= deadline:
|
|
raise AgentError("Plutonium cache preparation was interrupted or timed out")
|
|
if self.defer_plutonium_preparation:
|
|
raise Held("Plutonium cache preparation is waiting for other host operations")
|
|
active()
|
|
try:
|
|
with self.plutonium_open(PLUTONIUM_MANIFEST_URL) as response:
|
|
raw = response.read(PLUTONIUM_MANIFEST_LIMIT + 1)
|
|
manifest, files = self.plutonium_manifest(raw)
|
|
if manifest["revision"] < target:
|
|
raise AgentError("Plutonium CDN has not reached the requested revision")
|
|
key = digest(raw)
|
|
previous = None
|
|
with self.plutonium_lock:
|
|
with contextlib.suppress(OSError, ValueError, AgentError):
|
|
_, previous, _, _ = self.plutonium_snapshot()
|
|
if previous is not None and previous.parent.name == key and all(self.plutonium_file_matches(previous, entry) for entry in files):
|
|
return {"revision": manifest["revision"], "bytes": sum(entry["size"] for entry in files)}
|
|
staging = scoped(root, key + ".partial/content")
|
|
staging.mkdir(parents=True, exist_ok=True)
|
|
expected = {entry["name"] for entry in files}
|
|
# SIGKILL/power loss bypasses download/copy finally blocks. Remove
|
|
# abandoned temporary files before counting space or publishing.
|
|
for directory, subdirs, filenames in os.walk(staging, followlinks=False):
|
|
for name in subdirs + filenames:
|
|
path = Path(directory) / name
|
|
if path.is_symlink() or (name in filenames and path.relative_to(staging).as_posix() not in expected):
|
|
path.unlink()
|
|
# Keep at most the current snapshot and this resumable preparation.
|
|
current_key = previous.parent.name if previous is not None else None
|
|
for directory in root.iterdir():
|
|
if re.fullmatch(r"[a-f0-9]{64}(?:\.partial)?", directory.name) and directory.name not in (current_key, key + ".partial"):
|
|
if directory.is_symlink():
|
|
directory.unlink()
|
|
else:
|
|
shutil.rmtree(directory)
|
|
missing = sum(entry["size"] for entry in files if not self.plutonium_file_matches(staging, entry))
|
|
if shutil.disk_usage(root).free < reserve + missing + len(raw):
|
|
raise AgentError("Insufficient free storage for the Plutonium cache and host reserve")
|
|
for entry in files:
|
|
active()
|
|
if self.plutonium_file_matches(staging, entry):
|
|
continue
|
|
if previous is not None and self.plutonium_file_matches(previous, entry):
|
|
managed_copy(staging, entry["name"], scoped(previous, entry["name"]), os.geteuid(), os.getegid())
|
|
continue
|
|
destination = scoped(staging, entry["name"])
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = destination.with_name(".download-" + uuid.uuid4().hex)
|
|
try:
|
|
with self.plutonium_open(manifest["baseUrl"] + entry["hash"]) as incoming, temporary.open("xb") as outgoing:
|
|
size, checksum = 0, hashlib.sha1()
|
|
while True:
|
|
active()
|
|
chunk = incoming.read(min(1024 * 1024, entry["size"] - size + 1))
|
|
if not chunk:
|
|
break
|
|
size += len(chunk)
|
|
if size > entry["size"]:
|
|
raise AgentError("Plutonium download exceeds its expected size")
|
|
checksum.update(chunk)
|
|
outgoing.write(chunk)
|
|
if size != entry["size"] or checksum.hexdigest() != entry["hash"]:
|
|
raise AgentError("Plutonium download failed size or checksum verification")
|
|
outgoing.flush()
|
|
os.fsync(outgoing.fileno())
|
|
os.replace(temporary, destination)
|
|
sync_parent(destination)
|
|
finally:
|
|
with contextlib.suppress(FileNotFoundError):
|
|
temporary.unlink()
|
|
# Publish metadata last. Interrupted transfers can never claim ready.
|
|
active()
|
|
atomic_bytes(staging / "cdn_info.json", raw)
|
|
pointer = {"key": key, "revision": manifest["revision"], "bytes": sum(entry["size"] for entry in files)}
|
|
with self.plutonium_lock:
|
|
final = scoped(root, key)
|
|
if final.exists():
|
|
# Invalidate before replacing a damaged/same-revision cache.
|
|
if current_key == key:
|
|
(root / "current.json").unlink(missing_ok=True)
|
|
shutil.rmtree(final)
|
|
os.replace(staging.parent, final)
|
|
sync_parent(final)
|
|
atomic_json(root / "current.json", pointer)
|
|
if previous is not None and current_key != key:
|
|
shutil.rmtree(previous.parent)
|
|
return {"revision": pointer["revision"], "bytes": pointer["bytes"]}
|
|
except (urllib.error.URLError, TimeoutError, http.client.HTTPException):
|
|
raise AgentError("Plutonium CDN download is unavailable; the previous cache is retained") from None
|
|
|
|
def seed_plutonium(self, app, image):
|
|
if not re.fullmatch(r"ghcr\.io/ayymoss/plutainer(?::[A-Za-z0-9_.-]+)?(?:@sha256:[a-f0-9]{64})?", image):
|
|
return False
|
|
with self.plutonium_lock:
|
|
try:
|
|
_, content, raw, files = self.plutonium_snapshot()
|
|
if not all(self.plutonium_file_matches(content, entry) for entry in files):
|
|
scoped(self.root / "agent", "plutonium-cache/current.json").unlink(missing_ok=True)
|
|
raise AgentError("Plutonium cache checksum verification failed")
|
|
except (OSError, ValueError, AgentError):
|
|
return False
|
|
uid, gid = int(self.config.get("runtimeUid", 1000)), int(self.config.get("runtimeGid", 1000))
|
|
marker = "runtime/plutonium/cdn_info.json"
|
|
managed_write(app, marker, None, uid, gid)
|
|
for entry in files:
|
|
self.check_creation_cancellation()
|
|
managed_copy(app, "runtime/plutonium/" + entry["name"], scoped(content, entry["name"]), uid, gid)
|
|
managed_write(app, marker, raw, uid, gid)
|
|
return True
|
|
|
|
def create(self, instance_id, generation, payload):
|
|
self.operation_phase = "checking-instance"
|
|
self.check_creation_cancellation()
|
|
directory = self.instance_dir(instance_id, generation)
|
|
current_marker = directory.parent / "current.json"
|
|
if current_marker.exists():
|
|
current = json.loads(current_marker.read_text())
|
|
if current["generation"] > generation or current["generation"] == generation and current.get("removed"):
|
|
raise AgentError("Instance generation has been superseded or removed")
|
|
existing = self.inspect(instance_id, generation)
|
|
if existing["exists"]:
|
|
if self.spec(instance_id, generation) != payload:
|
|
raise AgentError("Instance already exists with a different specification")
|
|
return existing
|
|
self.operation_phase = "validating-create-spec"
|
|
game, library, image, image_id, port, bind, env = self.validate_spec(payload)
|
|
app = directory / "app"
|
|
self.operation_phase = "preparing-create-directory"
|
|
app.mkdir(parents=True, exist_ok=True)
|
|
self.make_writable(app, directory=True)
|
|
self.operation_phase = "seeding-runtime"
|
|
self.seed_plutonium(app, image)
|
|
self.operation_phase = "writing-create-files"
|
|
self.write_initial_files(app, self.initial_files(payload))
|
|
self.operation_phase = "persisting-create-metadata"
|
|
atomic_json(directory / "spec.json", payload)
|
|
env_path = directory / "environment"
|
|
atomic_bytes(env_path, "".join(f"{key}={value}\n" for key, value in env.items()).encode())
|
|
name = self.container_name(instance_id, generation)
|
|
spec_sha = digest(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode())
|
|
# Use the validated immutable ID so retagging after validation cannot
|
|
# substitute another image. A deleted image must not trigger a pull.
|
|
args = ["container", "create", "--pull=never", "--name", name, "--restart", "unless-stopped", "--label", LABEL + ".managed=true", "--label", LABEL + ".instance=" + instance_id, "--label", LABEL + ".generation=" + str(generation), "--label", LABEL + ".operation=" + (self.executing_id or "direct"), "--label", LABEL + ".spec-sha256=" + spec_sha, "--mount", f"type=bind,src={app},dst=/home/plutainer/app", "--mount", f"type=bind,src={library / 'content'},dst=/home/plutainer/gamefiles,readonly", "--env-file", str(env_path)]
|
|
if self.config.get("hostId"):
|
|
args.extend(["--label", LABEL + ".host=" + identifier(self.config["hostId"])])
|
|
bind_arg = "[" + bind + "]" if ":" in bind else bind
|
|
for protocol in ("udp", "tcp"):
|
|
args.extend(["--publish", f"{bind_arg}:{port}:{port}/{protocol}"])
|
|
args.append(image_id)
|
|
self.check_creation_cancellation()
|
|
self.operation_phase = "creating-container"
|
|
self.checkpoint({"phase": "creating", "generation": generation, "specSha256": spec_sha})
|
|
self.docker.call(*args, timeout=120)
|
|
self.check_creation_cancellation()
|
|
self.operation_phase = "starting-container"
|
|
self.docker.call("container", "start", name)
|
|
self.check_creation_cancellation()
|
|
self.operation_phase = "recording-created-instance"
|
|
atomic_json(current_marker, {"generation": generation})
|
|
self.operation_phase = "inspecting-created-instance"
|
|
return self.inspect(instance_id, generation)
|
|
|
|
def check_creation_cancellation(self):
|
|
if self.executing_id and self.executing_id in self.cancelled_creations:
|
|
raise AgentError("Creation cancelled; waiting for instance cleanup")
|
|
|
|
def initial_files(self, payload):
|
|
files = payload.get("files", [])
|
|
if not isinstance(files, list):
|
|
raise Rejected("Invalid initial file list")
|
|
bank = payload.get("bank")
|
|
if bank is None:
|
|
return files
|
|
if not isinstance(bank, dict) or not isinstance(bank.get("token"), str) or not re.fullmatch(r"ec_bank_[A-Za-z0-9_-]{32,128}", bank["token"]):
|
|
raise Rejected("Invalid instance bank credential")
|
|
path = bank.get("configPath")
|
|
scoped(self.root / "agent" / "validation", path)
|
|
if PurePosixPath(path).name != "electric_cherry_bank.cfg":
|
|
raise Rejected("Invalid private bank config path")
|
|
controller = self.config["controller"].rstrip("/")
|
|
if any(char in controller for char in ('"', "\\", "\n", "\r", "\x00")):
|
|
raise Rejected("Invalid bank controller URL")
|
|
content = f'set tws_bank_token "{bank["token"]}"\nset tws_bank_url "{controller}/api/bank"\n'.encode()
|
|
return files + [{"path": path, "contentBase64": base64.b64encode(content).decode()}]
|
|
|
|
def write_initial_files(self, root, files):
|
|
if not isinstance(files, list) or len(files) > 10000:
|
|
raise AgentError("Invalid file list")
|
|
for item in files:
|
|
content = self.decode_file(item)
|
|
managed_write(root, item["path"], content, int(self.config.get("runtimeUid", 1000)), int(self.config.get("runtimeGid", 1000)))
|
|
|
|
def decode_file(self, item):
|
|
if "contentSha256" in item:
|
|
sha = item["contentSha256"]
|
|
if not isinstance(sha, str) or not re.fullmatch(r"[a-f0-9]{64}", sha):
|
|
raise AgentError("Invalid file content hash")
|
|
url = self.config["controller"].rstrip("/") + "/api/agent/blobs/" + sha
|
|
with self.artifact_open("GET", url) as response:
|
|
content = response.read(MAX_FILE + 1)
|
|
if len(content) > MAX_FILE:
|
|
raise AgentError("File exceeds staging size limit")
|
|
if digest(content) != sha or item.get("sha256", sha) != sha:
|
|
raise AgentError("Staged file checksum mismatch")
|
|
return content
|
|
try:
|
|
content = base64.b64decode(item["contentBase64"], validate=True)
|
|
except (ValueError, KeyError, TypeError):
|
|
raise AgentError("Invalid encoded file content") from None
|
|
if len(content) > MAX_FILE:
|
|
raise AgentError("File exceeds staging size limit")
|
|
if item.get("sha256") and item["sha256"] != digest(content):
|
|
raise AgentError("Staged file checksum mismatch")
|
|
return content
|
|
|
|
def authorize_autoscaling_removal(self, instance_id, generation, payload):
|
|
authorization = payload.get("autoscaling")
|
|
if not isinstance(authorization, dict) or not self.executing_id or payload.get("safetyOverride") or payload.get("stageId"):
|
|
raise AutoscalingInvalidated("Invalid autoscaling removal authorization")
|
|
started = time.monotonic()
|
|
try:
|
|
response = self.request("POST", "/api/agent/operations/" + urllib.parse.quote(self.executing_id, safe="") + "/authorize-removal", authorization)
|
|
except AgentError:
|
|
raise AutoscalingInvalidated("Autoscaling removal authorization unavailable; request a new decision") from None
|
|
ttl = response.get("validForMs") if isinstance(response, dict) else None
|
|
if not isinstance(response, dict) or response.get("authorized") is not True or isinstance(ttl, bool) or not isinstance(ttl, (int, float)) or not 0 < ttl <= 5000:
|
|
raise AutoscalingInvalidated("Autoscaling removal authorization was withdrawn or expired")
|
|
# Occupancy can change during the controller round trip. Check again
|
|
# within the short claim window, immediately before the durable checkpoint.
|
|
self.safe_to_disrupt(instance_id, generation)
|
|
if (time.monotonic() - started) * 1000 >= ttl:
|
|
raise AutoscalingInvalidated("Autoscaling removal authorization expired before mutation")
|
|
|
|
def settle_bank(self, instance_id, generation, operation_id=None):
|
|
"""Drain stopped runtime journals before discarding any generation's storage.
|
|
|
|
Use only our configured controller and host credential. Never follow an
|
|
endpoint or symlink supplied by a game journal. Lost replies are retried
|
|
with the original request identity; files remain until teardown succeeds.
|
|
"""
|
|
app = scoped(self.instance_dir(instance_id, generation), "app")
|
|
if not app.exists():
|
|
return
|
|
if self.inspect(instance_id, generation)["running"]:
|
|
raise AgentError("Bank settlement requires a stopped instance")
|
|
operation_id = operation_id or self.executing_id
|
|
visited = 0
|
|
deadline = time.monotonic() + 30
|
|
journals = []
|
|
|
|
def unreadable(_error):
|
|
raise AgentError("Bank journal discovery failed; runtime storage retained")
|
|
|
|
for directory, directories, files in os.walk(app, followlinks=False, onerror=unreadable):
|
|
relative = Path(directory).relative_to(app)
|
|
inside_bank = "tws-bank" in relative.parts
|
|
visited += 1
|
|
if visited > 100000 or time.monotonic() > deadline:
|
|
raise AgentError("Bank journal discovery limit exceeded; runtime storage retained")
|
|
for name in directories + files:
|
|
if (inside_bank or name == "tws-bank") and (Path(directory) / name).is_symlink():
|
|
raise AgentError("Bank journal paths must not be symlinks")
|
|
if not inside_bank:
|
|
continue
|
|
for name in files:
|
|
if name.endswith(".json"):
|
|
journals.append((relative / name).as_posix())
|
|
if len(journals) > 4096:
|
|
raise AgentError("Too many bank journals; runtime storage retained")
|
|
for path in sorted(journals):
|
|
try:
|
|
raw = managed_read(app, path)
|
|
except Rejected:
|
|
raise AgentError("Bank journal cannot be read; runtime storage retained") from None
|
|
if raw is None or len(raw) > 16 * 1024 * 1024:
|
|
raise AgentError("Bank journal is unavailable or oversized")
|
|
try:
|
|
journal = json.loads(raw)
|
|
reports = journal["reports"]
|
|
if journal["endpoint"].rstrip("/") != self.config["controller"].rstrip("/") + "/api/bank":
|
|
raise AgentError("Bank journal belongs to a different controller; runtime storage retained")
|
|
if type(journal["version"]) is not int or journal["version"] != 1 or not isinstance(reports, list) or len(reports) > 4096:
|
|
raise ValueError()
|
|
# Controller independently binds this operation to the instance/server.
|
|
for report in reports:
|
|
if not isinstance(report, str) or len(report.encode()) > 20000:
|
|
raise ValueError()
|
|
transfer = json.loads(report)
|
|
action = transfer["decision"]
|
|
if action not in ("commit", "cancel") or transfer["server"] != journal["server"] or not operation_id:
|
|
raise ValueError()
|
|
response = self.request("POST", "/api/agent/bank/settle", {
|
|
"operationId": operation_id, "action": action, "transfer": transfer})
|
|
expected = ("committed",) if action == "commit" else ("cancelled", "rejected")
|
|
if not isinstance(response, dict) or response.get("request_id") != transfer["request_id"] or response.get("state") not in expected:
|
|
raise AgentError("Bank settlement was not acknowledged; runtime storage retained")
|
|
except (ValueError, TypeError, KeyError, AttributeError):
|
|
raise AgentError("Bank journal is invalid; runtime storage retained") from None
|
|
|
|
def lifecycle(self, kind, instance_id, generation, payload):
|
|
unhealthy_recovery = payload.get("unhealthyRecovery") is True
|
|
if unhealthy_recovery and (kind not in ("instance.restart", "instance.rebuild") or payload.get("stageId") or "autoscaling" in payload):
|
|
raise AgentError("Unhealthy recovery only permits restart or rebuild without file activation or autoscaling")
|
|
if kind == "instance.cancel-create":
|
|
return self.cancel_creation(instance_id, generation, payload)
|
|
if kind == "instance.create":
|
|
return self.create(instance_id, generation, payload)
|
|
if kind == "instance.inspect":
|
|
return self.inspect(instance_id, generation)
|
|
health = self.inspect(instance_id, generation)
|
|
name = self.container_name(instance_id, generation)
|
|
before = []
|
|
if kind == "instance.start":
|
|
if not health["exists"]:
|
|
raise AgentError("Instance does not exist")
|
|
if payload.get("stageId"):
|
|
self.safe_to_disrupt(instance_id, generation)
|
|
self.checkpoint({"phase": "activating", "containerId": health.get("containerId"), "startedAt": health.get("startedAt"), "restartRequired": health["running"]})
|
|
before = self.activate(instance_id, generation, payload["stageId"])
|
|
health = self.inspect(instance_id, generation)
|
|
self.safe_to_disrupt(instance_id, generation)
|
|
restart_required = bool(payload.get("stageId") and health["running"])
|
|
self.checkpoint({"phase": "starting", "containerId": health.get("containerId"), "startedAt": health.get("startedAt"), "restartRequired": restart_required})
|
|
if restart_required:
|
|
self.docker.call("container", "restart", "--time", "30", name)
|
|
else:
|
|
self.docker.call("container", "start", name)
|
|
elif kind in ("instance.stop", "instance.restart", "instance.remove", "instance.rebuild"):
|
|
# The authenticated controller records explicit administrator approval
|
|
# for this immutable operation. Ownership and generation checks still apply.
|
|
override_safety = payload.get("safetyOverride") is True
|
|
if "autoscaling" in payload and (kind != "instance.remove" or override_safety):
|
|
raise AutoscalingInvalidated("Autoscaling only permits removal with player safety checks")
|
|
if kind == "instance.rebuild":
|
|
next_generation = payload.get("nextGeneration", payload.get("newGeneration"))
|
|
if isinstance(next_generation, bool) or not isinstance(next_generation, int) or next_generation <= generation:
|
|
raise AgentError("Rebuild must use a newer immutable generation")
|
|
spec = payload.get("spec") or {key: value for key, value in payload.items() if key not in ("nextGeneration", "newGeneration", "unhealthyRecovery")}
|
|
self.validate_spec(spec)
|
|
if not override_safety:
|
|
self.safe_to_disrupt(instance_id, generation, allow_unhealthy=unhealthy_recovery)
|
|
if "autoscaling" in payload:
|
|
self.authorize_autoscaling_removal(instance_id, generation, payload)
|
|
self.checkpoint({"phase": "disrupting", "containerId": health.get("containerId"), "startedAt": health.get("startedAt")})
|
|
if payload.get("stageId") and kind == "instance.restart":
|
|
before = self.activate(instance_id, generation, payload["stageId"], override_safety=override_safety)
|
|
if kind in ("instance.remove", "instance.rebuild"):
|
|
if health["exists"]:
|
|
self.docker.call("container", "stop", "--time", "30", name)
|
|
self.docker.call("container", "rm", name)
|
|
self.settle_bank(instance_id, generation)
|
|
if kind == "instance.rebuild":
|
|
result = self.create(instance_id, next_generation, spec)
|
|
self.operation_phase = "removing-old-instance"
|
|
old = self.instance_dir(instance_id, generation)
|
|
if old.exists():
|
|
shutil.rmtree(old, ignore_errors=False)
|
|
return result
|
|
directory = self.instance_dir(instance_id, generation)
|
|
if directory.exists():
|
|
shutil.rmtree(directory)
|
|
self.mark_removed(instance_id, generation)
|
|
elif health["exists"]:
|
|
if kind == "instance.restart" and payload.get("stageId") and not override_safety:
|
|
self.safe_to_disrupt(instance_id, generation)
|
|
self.docker.call("container", "stop" if kind.endswith("stop") else "restart", "--time", "30", name)
|
|
else:
|
|
raise AgentError("Unsupported instance operation")
|
|
return {**self.inspect(instance_id, generation), "before": before}
|
|
|
|
def cancel_creation(self, instance_id, generation, payload, operation_id=None):
|
|
"""Explicit launch cancellation, fenced to its immutable creation identity.
|
|
|
|
The operation loop serializes this with create. Once delivered, removal
|
|
needs no RCON response and a durable tombstone prevents a delayed create.
|
|
Repeating this cleanup after an ambiguous Docker result is safe.
|
|
"""
|
|
create_id = identifier(payload.get("createOperationId"))
|
|
targets = [(generation, create_id)]
|
|
if "recoveryRebuildOperationId" in payload or "recoveryGeneration" in payload:
|
|
rebuild_id = identifier(payload.get("recoveryRebuildOperationId"))
|
|
recovery_generation = payload.get("recoveryGeneration")
|
|
if isinstance(recovery_generation, bool) or not isinstance(recovery_generation, int) or recovery_generation != generation + 1:
|
|
raise Rejected("Cancellation recovery must target the next generation")
|
|
saved = self.journal.db.execute("SELECT request FROM operations WHERE id=?", (rebuild_id,)).fetchone()
|
|
if saved:
|
|
rebuild = json.loads(saved["request"])
|
|
rebuild_payload = rebuild.get("payload", {})
|
|
if rebuild["kind"] != "instance.rebuild" or rebuild["instanceId"] != instance_id or rebuild["generation"] != generation or rebuild_payload.get("nextGeneration", rebuild_payload.get("newGeneration")) != recovery_generation or rebuild_payload.get("unhealthyRecovery") is not True:
|
|
raise Rejected("Cancellation does not match the creation recovery rebuild")
|
|
targets.append((recovery_generation, rebuild_id))
|
|
marker = self.instance_dir(instance_id, generation).parent / "current.json"
|
|
if marker.exists() and json.loads(marker.read_text())["generation"] > targets[-1][0]:
|
|
raise Rejected("Cancelled creation was superseded by a newer generation")
|
|
saved = self.journal.db.execute("SELECT request FROM operations WHERE id=?", (create_id,)).fetchone()
|
|
if saved:
|
|
original = json.loads(saved["request"])
|
|
if original["kind"] != "instance.create" or original["instanceId"] != instance_id or original["generation"] != generation:
|
|
raise Rejected("Cancellation does not match the original creation")
|
|
health_by_generation = {}
|
|
for target_generation, operation_id in targets:
|
|
health = self.inspect(instance_id, target_generation)
|
|
if health["exists"] and health.get("creationOperation") != operation_id:
|
|
raise Rejected("Container belongs to a different creation operation")
|
|
health_by_generation[target_generation] = health
|
|
self.checkpoint({"phase": "cancelling-creation", "createOperationId": create_id})
|
|
# Fence both generations before deleting: delayed create/rebuild work
|
|
# must never recreate either target after cancellation or a crash.
|
|
self.mark_removed(instance_id, targets[-1][0])
|
|
for target_generation, _ in targets:
|
|
if health_by_generation[target_generation]["exists"]:
|
|
self.docker.call("container", "rm", "--force", "--volumes", self.container_name(instance_id, target_generation))
|
|
if self.inspect(instance_id, target_generation)["exists"]:
|
|
raise AgentError("Cancelled creation container still exists")
|
|
self.settle_bank(instance_id, target_generation, operation_id)
|
|
directory = self.instance_dir(instance_id, target_generation)
|
|
if directory.exists():
|
|
shutil.rmtree(directory)
|
|
return {**self.inspect(instance_id, generation), "cancelledCreation": create_id}
|
|
|
|
def stage(self, instance_id, generation, payload, operation_id):
|
|
directory = self.instance_dir(instance_id, generation)
|
|
self.spec(instance_id, generation)
|
|
stage_id = identifier(payload.get("stageId", operation_id))
|
|
stage = directory / "stages" / stage_id
|
|
files = payload.get("files")
|
|
if not isinstance(files, list) or not files or len(files) > 10000:
|
|
raise AgentError("Invalid staged file list")
|
|
planned = []
|
|
paths = set()
|
|
total_size = 0
|
|
for item in files:
|
|
scoped(directory / "app", item["path"])
|
|
if item["path"] in paths:
|
|
raise AgentError("Duplicate staged file path")
|
|
paths.add(item["path"])
|
|
deleting = item.get("delete") is True or (item.get("contentBase64") is None and "contentSha256" not in item)
|
|
content = None if deleting else self.decode_file(item)
|
|
total_size += len(content) if content is not None else 0
|
|
if total_size > 64 * 1024 * 1024:
|
|
raise Rejected("Staged file content exceeds the 64 MiB operation limit")
|
|
planned.append({"id": item.get("id"), "path": item["path"], "delete": deleting, "sha256": None if deleting else digest(content), "checkBefore": item.get("checkBefore", "expectedSha256" in item), "expectedBeforeSha": item.get("expectedBeforeSha", item.get("expectedSha256")), "contentBase64": None if deleting else base64.b64encode(content).decode()})
|
|
manifest = {"stageId": stage_id, "files": planned, "state": "staged"}
|
|
marker = stage / "manifest.json"
|
|
if marker.exists():
|
|
existing = json.loads(marker.read_text())
|
|
if existing["files"] != planned:
|
|
raise AgentError("Stage ID already contains different files")
|
|
return {"stageId": stage_id, "files": len(planned), "state": existing["state"]}
|
|
atomic_json(marker, manifest)
|
|
return {"stageId": stage_id, "files": len(planned), "state": "staged"}
|
|
|
|
def activate(self, instance_id, generation, stage_id, *, override_safety=False):
|
|
directory = self.instance_dir(instance_id, generation)
|
|
stage = directory / "stages" / identifier(stage_id)
|
|
marker = stage / "manifest.json"
|
|
saved = json.loads(marker.read_text())
|
|
if saved["state"] == "activated":
|
|
return saved["before"]
|
|
if saved["state"] != "staged":
|
|
raise AgentError("Interrupted activation requires explicit rollback")
|
|
before = []
|
|
for item in saved["files"]:
|
|
content = managed_read(directory / "app", item["path"])
|
|
actual_sha = None if content is None else digest(content)
|
|
if item.get("checkBefore") and actual_sha != item.get("expectedBeforeSha"):
|
|
raise Rejected("Target changed after campaign review")
|
|
before.append({"id": item.get("id"), "path": item["path"], "contentBase64": None if content is None else base64.b64encode(content).decode()})
|
|
# Snapshot preparation may take time. Occupancy is rechecked at the
|
|
# local activation boundary, after all file I/O and integrity checks.
|
|
if not override_safety:
|
|
self.safe_to_disrupt(instance_id, generation)
|
|
saved.update(state="activating", before=before)
|
|
atomic_json(marker, saved)
|
|
try:
|
|
for item in saved["files"]:
|
|
managed_write(directory / "app", item["path"], None if item["delete"] else self.decode_file(item), int(self.config.get("runtimeUid", 1000)), int(self.config.get("runtimeGid", 1000)))
|
|
saved["state"] = "activated"
|
|
atomic_json(marker, saved)
|
|
except Exception:
|
|
self.restore_files(directory / "app", before)
|
|
saved["state"] = "rolled-back"
|
|
atomic_json(marker, saved)
|
|
raise
|
|
return before
|
|
|
|
def restore_files(self, app, before):
|
|
for item in before:
|
|
managed_write(app, item["path"], None if item["contentBase64"] is None else self.decode_file(item), int(self.config.get("runtimeUid", 1000)), int(self.config.get("runtimeGid", 1000)))
|
|
|
|
def file_operation(self, kind, instance_id, generation, payload, operation_id):
|
|
directory = self.instance_dir(instance_id, generation)
|
|
app = directory / "app"
|
|
if kind == "files.stage":
|
|
return self.stage(instance_id, generation, payload, operation_id)
|
|
if kind in ("files.activate", "files.rollback"):
|
|
self.safe_to_disrupt(instance_id, generation)
|
|
stage_id = identifier(payload["stageId"])
|
|
health = self.inspect(instance_id, generation)
|
|
self.checkpoint({"phase": "activating" if kind == "files.activate" else "rolling-back", "containerId": health.get("containerId"), "startedAt": health.get("startedAt")})
|
|
if kind == "files.activate":
|
|
before = self.activate(instance_id, generation, stage_id)
|
|
result = {"activated": True, "before": before}
|
|
else:
|
|
marker = directory / "stages" / stage_id / "manifest.json"
|
|
saved = json.loads(marker.read_text())
|
|
if saved["state"] not in ("activated", "activating", "rolled-back"):
|
|
raise AgentError("Stage has no activation to roll back")
|
|
if saved["state"] != "rolled-back":
|
|
self.restore_files(app, saved["before"])
|
|
saved["state"] = "rolled-back"
|
|
atomic_json(marker, saved)
|
|
result = {"rolledBack": True}
|
|
if payload.get("restart", True) and self.inspect(instance_id, generation)["exists"]:
|
|
self.safe_to_disrupt(instance_id, generation)
|
|
self.docker.call("container", "restart", "--time", "30", self.container_name(instance_id, generation))
|
|
return result
|
|
if kind == "logs.read":
|
|
return self.read_log(instance_id, generation, payload)
|
|
if kind == "files.manifest":
|
|
return managed_manifest(app, payload, self.stopping)
|
|
path = scoped(app, payload.get("path", ""), allow_root=kind in ("files.list", "files.stat"))
|
|
if kind == "files.read":
|
|
content = managed_read(app, payload["path"])
|
|
if content is None:
|
|
return {"contentBase64": None}
|
|
return {"contentBase64": base64.b64encode(content).decode(), "sha256": digest(content), "size": len(content)}
|
|
if kind == "files.stat":
|
|
if not path.exists():
|
|
return None
|
|
stat = path.stat()
|
|
return {"size": stat.st_size, "mtime": stat.st_mtime, "isDirectory": path.is_dir(), "isFile": path.is_file()}
|
|
if kind == "files.list":
|
|
if not path.exists():
|
|
return {"entries": []}
|
|
return {"entries": [{"name": child.name, "path": child.relative_to(app).as_posix(), "isDirectory": child.is_dir(), "size": child.stat().st_size} for child in sorted(path.iterdir()) if not child.is_symlink()][:10000]}
|
|
raise AgentError("Unsupported file operation")
|
|
|
|
def read_log(self, instance_id, generation, payload):
|
|
app = self.instance_dir(instance_id, generation) / "app"
|
|
value = payload["path"]
|
|
count = min(2000, max(1, int(payload.get("lines", 200))))
|
|
# Validate the original relative spelling independently of symlinks.
|
|
scoped(self.root / "agent" / "log-validation", value)
|
|
try:
|
|
resolved = (app / value).resolve(strict=True)
|
|
relative = resolved.relative_to(app.resolve(strict=True)).as_posix()
|
|
except FileNotFoundError:
|
|
if value != "logs/console.log" or not self.inspect(instance_id, generation)["exists"]:
|
|
return None
|
|
text = self.docker.call("container", "logs", "--tail", str(count), "--timestamps", self.container_name(instance_id, generation), timeout=30, merge_stderr=True)
|
|
lines = text.splitlines()
|
|
return {"lines": lines[-count:], "size": len(text.encode()), "mtime": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "truncated": len(lines) >= count, "source": "container"}
|
|
except ValueError:
|
|
raise AgentError("Log symlink resolves outside the managed instance") from None
|
|
# Reopen the resolved relative target through no-follow directory FDs;
|
|
# swapping either the original link or a target component cannot escape.
|
|
with managed_open(app, relative) as stream:
|
|
stat = os.fstat(stream.fileno())
|
|
if payload.get("mode") == "incremental":
|
|
return self.read_log_chunk(stream, stat, payload.get("cursor"))
|
|
start = max(0, stat.st_size - 256 * 1024)
|
|
stream.seek(start)
|
|
lines = stream.read(256 * 1024).decode("utf-8", "replace").splitlines()
|
|
if start:
|
|
lines = lines[1:]
|
|
return {"lines": lines[-count:], "size": stat.st_size, "mtime": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(stat.st_mtime)), "truncated": start > 0}
|
|
|
|
@staticmethod
|
|
def read_log_chunk(stream, stat, cursor):
|
|
file_id, modified = f"{stat.st_dev}:{stat.st_ino}", f"{stat.st_mtime_ns}:{stat.st_ctime_ns}"
|
|
size = stat.st_size
|
|
mtime = datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat()
|
|
if cursor is not None and (not isinstance(cursor, dict)
|
|
or any(type(cursor.get(key)) is not int or cursor[key] < 0 for key in ("offset", "size", "anchorLength"))
|
|
or cursor["anchorLength"] > min(256, cursor["offset"])
|
|
or any(not isinstance(cursor.get(key), str) for key in ("fileId", "modified", "anchorHash"))):
|
|
raise AgentError("Invalid log cursor")
|
|
if cursor and cursor["fileId"] == file_id and cursor["modified"] == modified and cursor["size"] == size and cursor["offset"] == size:
|
|
return {"contentBase64": "", "size": size, "mtime": mtime, "truncated": size > 0, "cursor": cursor, "reset": False}
|
|
reset = not cursor or cursor["fileId"] != file_id or cursor["offset"] > size
|
|
if not reset:
|
|
stream.seek(cursor["offset"] - cursor["anchorLength"])
|
|
anchor = stream.read(cursor["anchorLength"])
|
|
reset = len(anchor) != cursor["anchorLength"] or digest(anchor) != cursor["anchorHash"]
|
|
start = max(0, size - 256 * 1024) if reset else cursor["offset"]
|
|
stream.seek(start)
|
|
content = stream.read(min(256 * 1024, size - start))
|
|
offset = start + len(content)
|
|
stream.seek(max(0, offset - 256))
|
|
anchor = stream.read(min(256, offset))
|
|
return {"contentBase64": base64.b64encode(content).decode(), "size": size, "mtime": mtime, "truncated": start > 0, "reset": reset,
|
|
"cursor": {"fileId": file_id, "modified": modified, "size": size, "offset": offset, "anchorLength": len(anchor), "anchorHash": digest(anchor)}}
|
|
|
|
def library_dir(self, game, library_id):
|
|
if game not in ("t5", "t6"):
|
|
raise AgentError("Unsupported library game")
|
|
return self.root / "libraries" / game / identifier(library_id)
|
|
|
|
def verify_library(self, directory):
|
|
marker = directory / "manifest.json"
|
|
if not marker.exists():
|
|
raise AgentError("Required game library is not ready")
|
|
manifest = json.loads(marker.read_text())
|
|
for item in manifest["files"]:
|
|
path = scoped(directory / "content", item["path"])
|
|
if not path.is_file() or path.stat().st_size != item["size"] or file_digest(path) != item["sha256"]:
|
|
raise AgentError("Game library integrity verification failed")
|
|
return manifest
|
|
|
|
def artifact_url(self, value):
|
|
controller = self.config["controller"].rstrip("/")
|
|
url = urllib.parse.urljoin(controller + "/", value)
|
|
parsed = urllib.parse.urlsplit(url)
|
|
origin = urllib.parse.urlsplit(controller)
|
|
if (parsed.scheme, parsed.netloc) != (origin.scheme, origin.netloc) or parsed.username or parsed.password:
|
|
raise AgentError("Library transport must remain on the authenticated controller origin")
|
|
if parsed.scheme != "https" and not (self.config.get("allowInsecureLocalhost") and parsed.hostname in ("127.0.0.1", "localhost", "::1")):
|
|
raise AgentError("Library transport must use HTTPS")
|
|
return url
|
|
|
|
def artifact_open(self, method, path, data=None, extra_headers=None, timeout=60):
|
|
headers = {"Authorization": "Bearer " + self.config["credential"], **(extra_headers or {})}
|
|
req = urllib.request.Request(self.artifact_url(path), data=data, method=method, headers=headers)
|
|
# Redirects cannot forward host credentials to another origin.
|
|
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
def redirect_request(self, req, fp, code, msg, hdrs, newurl):
|
|
raise AgentError("Artifact redirects are not allowed")
|
|
return urllib.request.build_opener(NoRedirect()).open(req, timeout=timeout)
|
|
|
|
def download_library_file(self, item, target, files_url, update_progress, aborted):
|
|
def check_active():
|
|
if aborted.is_set():
|
|
raise AgentError("Library download stopped after a transfer failed")
|
|
|
|
check_active()
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
offset = target.stat().st_size if target.exists() else 0
|
|
if offset > item["size"]:
|
|
target.unlink()
|
|
offset = 0
|
|
# Saved prefixes and complete files are intentionally not read here.
|
|
# The second phase verifies every byte after all transfers have ended.
|
|
if offset == item["size"] and target.exists():
|
|
update_progress(item["path"], offset)
|
|
return
|
|
update_progress(item["path"], offset)
|
|
check_active()
|
|
url = files_url.rstrip("/") + "/" + urllib.parse.quote(item["path"], safe="/")
|
|
with self.artifact_open("GET", url, extra_headers={"Range": f"bytes={offset}-"} if offset else {}) as response:
|
|
if response.status == 206:
|
|
expected = f"bytes {offset}-{item['size'] - 1}/{item['size']}"
|
|
if response.headers.get("Content-Range") != expected:
|
|
raise AgentError("Invalid resumed download response")
|
|
elif response.status != 200:
|
|
raise AgentError("Invalid library download response")
|
|
elif offset:
|
|
# A server may ignore Range and send the whole file instead.
|
|
offset = 0
|
|
update_progress(item["path"], 0)
|
|
with target.open("ab" if offset else "wb") as output:
|
|
received = offset
|
|
while True:
|
|
check_active()
|
|
chunk = response.read(1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
received += len(chunk)
|
|
if received > item["size"]:
|
|
raise AgentError("Library download exceeded its manifest size")
|
|
output.write(chunk)
|
|
update_progress(item["path"], received)
|
|
output.flush()
|
|
os.fsync(output.fileno())
|
|
if target.stat().st_size != item["size"]:
|
|
raise AgentError("Downloaded library file size failed integrity verification")
|
|
|
|
def verify_downloaded_files(self, files, targets, transfer, repair=False):
|
|
invalid = False
|
|
retained = transfer.progress["bytesDownloaded"]
|
|
for item in files:
|
|
target = targets[item["path"]]
|
|
checksum = hashlib.sha256()
|
|
actual_size = target.stat().st_size if target.is_file() else 0
|
|
valid_size = target.is_file() and actual_size == item["size"]
|
|
if valid_size:
|
|
with target.open("rb") as stream:
|
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
checksum.update(chunk)
|
|
transfer.advance("bytesVerified", len(chunk), len(chunk))
|
|
if not valid_size or checksum.hexdigest() != item["sha256"]:
|
|
invalid = True
|
|
if repair and target.exists():
|
|
# A corrupt complete file (including a corrupt saved
|
|
# prefix) must not be resumed indefinitely on every retry.
|
|
target.unlink()
|
|
actual_size = 0
|
|
retained -= item["size"] - min(actual_size, item["size"])
|
|
if invalid:
|
|
# Verification describes a completed download phase. Keep that
|
|
# total stable while other files are checked, then publish retained
|
|
# bytes atomically with failure (including removed corrupt files).
|
|
transfer.failed_bytes_downloaded = retained
|
|
raise AgentError("Downloaded library file failed integrity verification")
|
|
|
|
def publish_library_progress(self, payload, transfer):
|
|
if payload.get("statusUrl"):
|
|
# Phase boundaries are useful even when shorter than the heartbeat
|
|
# interval. Telemetry failure must never undo a verified download.
|
|
with contextlib.suppress(AgentError, OSError, ValueError):
|
|
with self.artifact_open("POST", payload["statusUrl"], json.dumps(transfer.snapshot()).encode(), {"Content-Type": "application/json"}, timeout=5) as response:
|
|
response.read()
|
|
|
|
def library_download(self, payload):
|
|
library_id = identifier(payload.get("libraryVersionId", payload.get("libraryId")))
|
|
with self.artifact_open("GET", payload["manifestUrl"]) as response:
|
|
manifest = json.load(response)
|
|
if manifest.get("id") != library_id or not isinstance(manifest.get("files"), list) or not manifest["files"]:
|
|
raise AgentError("Library manifest identity mismatch")
|
|
files = manifest["files"]
|
|
canonical = json.dumps([{ "path": item["path"], "size": item["size"], "sha256": item["sha256"] } for item in files], separators=(",", ":"), ensure_ascii=False)
|
|
manifest_sha = manifest.get("manifestSha256")
|
|
if not isinstance(manifest_sha, str) or not re.fullmatch("[0-9a-f]{64}", manifest_sha):
|
|
raise AgentError("Library manifest checksum is missing")
|
|
if digest(canonical.encode("utf-8")) != manifest_sha:
|
|
raise AgentError("Library manifest checksum mismatch")
|
|
if payload.get("manifestSha256") and payload["manifestSha256"] != manifest_sha:
|
|
raise AgentError("Library manifest differs from the requested immutable version")
|
|
directory = self.library_dir(manifest["game"], library_id)
|
|
existing = directory.exists()
|
|
if existing:
|
|
saved = json.loads((directory / "manifest.json").read_text())
|
|
if saved.get("manifestSha256") != manifest_sha:
|
|
raise AgentError("Published library changed unexpectedly")
|
|
staging = directory if existing else directory.with_name("." + library_id + ".partial")
|
|
staging.mkdir(parents=True, exist_ok=True)
|
|
source_marker = staging / "source.json"
|
|
if source_marker.exists() and json.loads(source_marker.read_text()).get("manifestSha256") != manifest_sha:
|
|
raise Rejected("Partial library download belongs to a different manifest")
|
|
size = sum(item["size"] for item in files)
|
|
atomic_json(source_marker, {"libraryId": library_id, "game": manifest["game"], "size": size, "files": files, "manifestSha256": manifest_sha})
|
|
targets = {}
|
|
progress = {"libraryVersionId": library_id, "status": "verifying" if existing else "downloading", "bytesDownloaded": size if existing else 0, "bytesVerified": 0, "bytesTotal": size, "manifestSha256": manifest_sha}
|
|
self.library_progress[library_id] = progress
|
|
marker = staging / "transfer.json"
|
|
previous = json.loads(marker.read_text()) if marker.exists() else None
|
|
if previous and (previous.get("manifestSha256") != manifest_sha or previous.get("status") == "ready"):
|
|
previous = None
|
|
timing = LibraryTransfer(progress, marker, previous)
|
|
self.library_transfers[library_id] = timing
|
|
try:
|
|
# Validate every path before starting concurrent writes. In addition
|
|
# to duplicates, a manifest cannot treat a file as a parent folder.
|
|
for item in files:
|
|
if item["path"] in targets or type(item["size"]) is not int or item["size"] < 0 or not re.fullmatch("[0-9a-f]{64}", item["sha256"]):
|
|
raise AgentError("Invalid library file manifest")
|
|
targets[item["path"]] = scoped(staging / "content", item["path"])
|
|
for path in targets:
|
|
if any(str(parent) in targets for parent in PurePosixPath(path).parents):
|
|
raise AgentError("Library file paths overlap")
|
|
progress_lock = threading.Lock()
|
|
offsets = {item["path"]: min(targets[item["path"]].stat().st_size, item["size"]) if targets[item["path"]].exists() else 0 for item in files}
|
|
if not existing:
|
|
progress["bytesDownloaded"] = sum(offsets.values())
|
|
if not existing and shutil.disk_usage(staging).free < size - progress["bytesDownloaded"]:
|
|
raise AgentError("Insufficient library storage")
|
|
timing.persist(force=True)
|
|
self.publish_library_progress(payload, timing)
|
|
aborted = threading.Event()
|
|
|
|
def update_progress(path, offset):
|
|
with progress_lock:
|
|
delta = offset - offsets.get(path, 0)
|
|
offsets[path] = offset
|
|
timing.advance("bytesDownloaded", delta, max(0, delta))
|
|
|
|
def transfer(item):
|
|
try:
|
|
self.download_library_file(item, targets[item["path"]], payload["filesBaseUrl"], update_progress, aborted)
|
|
except Exception:
|
|
aborted.set()
|
|
raise
|
|
|
|
# Bound sockets, memory and disk pressure while allowing other files
|
|
# to progress during network round trips and per-file fsyncs.
|
|
if not existing:
|
|
# A crash after setting read-only permissions but before the
|
|
# atomic rename leaves a valid, resumable staging directory.
|
|
content_root = staging / "content"
|
|
if content_root.exists():
|
|
os.chmod(content_root, 0o755)
|
|
for path in content_root.rglob("*"):
|
|
if path.is_symlink():
|
|
raise AgentError("Symlinks are not allowed in library content")
|
|
os.chmod(path, 0o755 if path.is_dir() else 0o644)
|
|
with ThreadPoolExecutor(max_workers=min(LIBRARY_DOWNLOAD_WORKERS, len(files))) as pool:
|
|
futures = [pool.submit(transfer, item) for item in files]
|
|
for future in as_completed(futures):
|
|
future.result()
|
|
timing.begin_verification()
|
|
self.publish_library_progress(payload, timing)
|
|
self.verify_downloaded_files(files, targets, timing, repair=not existing)
|
|
saved = {"libraryId": library_id, "game": manifest["game"], "size": size, "files": files, "manifestSha256": manifest_sha}
|
|
atomic_json(staging / "manifest.json", saved)
|
|
if not existing:
|
|
for path in (staging / "content").rglob("*"):
|
|
if path.is_symlink():
|
|
raise AgentError("Symlinks are not allowed in library content")
|
|
os.chmod(path, 0o555 if path.is_dir() else 0o444)
|
|
os.chmod(staging / "content", 0o555)
|
|
os.replace(staging, directory)
|
|
timing.marker = directory / "transfer.json"
|
|
timing.finish("ready")
|
|
saved["transfer"] = timing.snapshot()["transfer"]
|
|
saved["bytesVerified"] = size
|
|
atomic_json(directory / "manifest.json", saved)
|
|
self.publish_library_progress(payload, timing)
|
|
self.library_progress.pop(library_id, None)
|
|
self.library_transfers.pop(library_id, None)
|
|
return {"libraryId": library_id, "state": "ready", "size": size, "manifestSha256": manifest_sha}
|
|
except Exception:
|
|
timing.finish("failed", "Library download or integrity verification failed")
|
|
self.publish_library_progress(payload, timing)
|
|
raise
|
|
|
|
def library_remove(self, payload):
|
|
library_id = identifier(payload.get("libraryVersionId"))
|
|
directory = self.library_dir(payload.get("game"), library_id)
|
|
staging = directory.with_name("." + library_id + ".partial")
|
|
# Never follow substituted storage parents outside the managed tree.
|
|
for path in (self.root / "libraries", directory.parent, directory, staging):
|
|
if path.is_symlink():
|
|
raise Rejected("Refusing library cleanup through a symlink")
|
|
# Include stopped and unmanaged containers: any bind mount pins the files.
|
|
# Docker failures must stop cleanup, never masquerade as an empty host.
|
|
for container_id in self.docker.call("container", "ls", "--all", "--quiet").splitlines():
|
|
container = self.docker.inspect(container_id)
|
|
if container is None:
|
|
raise Rejected("Container inventory changed during library cleanup")
|
|
for mount in container["Mounts"]:
|
|
if not mount.get("Source"): # tmpfs has no host filesystem source.
|
|
continue
|
|
source = Path(mount["Source"]).resolve()
|
|
for target in (directory.resolve(), staging.resolve()):
|
|
if source == target or target in source.parents or source in target.parents:
|
|
raise Rejected("Game files are still mounted by a container")
|
|
self.checkpoint({"phase": "removing-library"})
|
|
for path in (directory, staging):
|
|
if path.exists():
|
|
shutil.rmtree(path)
|
|
self.library_progress.pop(library_id, None)
|
|
self.library_transfers.pop(library_id, None)
|
|
return {"libraryVersionId": library_id, "removed": True}
|
|
|
|
def library_import(self, payload):
|
|
game = payload.get("game")
|
|
volume = payload.get("sourceVolume")
|
|
if game not in ("t5", "t6") or volume != game + "_game_files":
|
|
raise AgentError("Only the explicitly approved game-file Docker volumes can be imported")
|
|
raw = self.docker.call("volume", "inspect", volume)
|
|
source = Path(json.loads(raw)[0]["Mountpoint"])
|
|
if not source.is_dir() or source.is_symlink():
|
|
raise AgentError("Import volume is unavailable")
|
|
files = []
|
|
for path in sorted(source.rglob("*")):
|
|
if path.is_symlink():
|
|
raise AgentError("Import source contains a symlink")
|
|
if path.is_file():
|
|
files.append({"path": path.relative_to(source).as_posix(), "size": path.stat().st_size, "sha256": file_digest(path)})
|
|
elif not path.is_dir():
|
|
raise AgentError("Import source contains a special file")
|
|
if not files:
|
|
raise AgentError("Import volume is empty")
|
|
with self.artifact_open("PUT", payload["manifestUrl"], json.dumps({"files": files}).encode(), {"Content-Type": "application/json"}) as response:
|
|
response.read()
|
|
for item in files:
|
|
path = scoped(source, item["path"])
|
|
if path.stat().st_size != item["size"] or file_digest(path) != item["sha256"]:
|
|
raise AgentError("Import source changed while publishing")
|
|
url = payload["uploadBaseUrl"].rstrip("/") + "/" + urllib.parse.quote(item["path"], safe="/")
|
|
with path.open("rb") as stream, self.artifact_open("PUT", url, stream, {"Content-Type": "application/octet-stream", "Content-Length": str(item["size"])}) as response:
|
|
response.read()
|
|
self.checkpoint({"phase": "publishing-import"})
|
|
return self.complete_import(payload, start=True)
|
|
|
|
def complete_import(self, payload, start=False):
|
|
method = "POST" if start else "GET"
|
|
while not self.stopping.is_set():
|
|
with self.artifact_open(method, payload["completeUrl"], b"{}" if method == "POST" else None, {"Content-Type": "application/json"}) as response:
|
|
result = json.load(response)
|
|
if result.get("status") == "published":
|
|
return result
|
|
if result.get("status") == "failed":
|
|
raise Rejected("Controller rejected the game-file import after integrity verification")
|
|
if result.get("status") not in ("pending", "verifying"):
|
|
raise AgentError("Controller returned an invalid import publication state")
|
|
method = "POST" if result["status"] == "pending" else "GET"
|
|
self.stopping.wait(3)
|
|
raise AgentError("Agent stopped while the library import was being published")
|
|
|
|
def dispatch(self, operation):
|
|
kind, payload = operation.get("kind"), operation.get("payload", {})
|
|
if not isinstance(kind, str) or not isinstance(payload, dict):
|
|
raise AgentError("Invalid operation")
|
|
if kind == "host.decommission":
|
|
return self.decommission(operation)
|
|
if kind == "credentials.rotate":
|
|
pending = "ec_host_" + base64.urlsafe_b64encode(os.urandom(32)).rstrip(b"=").decode()
|
|
self.config["pendingCredential"] = pending
|
|
atomic_json(self.config_path, self.config)
|
|
self.checkpoint({"phase": "rotating"})
|
|
try:
|
|
self.request("POST", "/api/agent/credentials", {"credential": pending})
|
|
except AgentError:
|
|
if not self.recover_credential():
|
|
raise
|
|
self.config["credential"] = pending
|
|
self.config.pop("pendingCredential", None)
|
|
atomic_json(self.config_path, self.config)
|
|
return {"rotated": True}
|
|
if kind == "runtime.pull":
|
|
image = payload.get("image")
|
|
if not isinstance(image, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._/:@-]{0,511}", image):
|
|
raise AgentError("Invalid runtime image")
|
|
self.docker.call("image", "pull", image, timeout=3600)
|
|
image_id = self.docker.call("image", "inspect", "--format", "{{.Id}}", image).strip()
|
|
aliases_file = self.root / "agent" / "runtime-images.json"
|
|
aliases = json.loads(aliases_file.read_text()) if aliases_file.exists() else {}
|
|
aliases[image] = image_id
|
|
atomic_json(aliases_file, aliases)
|
|
return {"image": image, "ready": True}
|
|
if kind == "host.inspect":
|
|
return self.inventory()
|
|
if kind == "plutonium.prepare":
|
|
return self.prepare_plutonium(payload)
|
|
if kind == "rcon.status-batch":
|
|
return self.rcon_status_batch(operation)
|
|
if kind == "library.download":
|
|
return self.library_download(payload)
|
|
if kind == "library.remove":
|
|
return self.library_remove(payload)
|
|
if kind == "library.import":
|
|
return self.library_import(payload)
|
|
if kind == "library.inspect":
|
|
return self.verify_library(self.library_dir(payload["game"], payload["libraryId"]))
|
|
instance_id, generation = instance_identity(operation.get("instanceId"), operation.get("generation"))
|
|
current_marker = self.instance_dir(instance_id, generation).parent / "current.json"
|
|
if current_marker.exists():
|
|
current = json.loads(current_marker.read_text())
|
|
if kind != "instance.cancel-create" and (current["generation"] > generation or current["generation"] == generation and current.get("removed") and kind not in ("instance.inspect", "instance.remove")):
|
|
raise AgentError("Instance generation has been superseded or removed")
|
|
if kind.startswith("instance."):
|
|
return self.lifecycle(kind, instance_id, generation, payload)
|
|
if kind.startswith("files.") or kind == "logs.read":
|
|
return self.file_operation(kind, instance_id, generation, payload, operation["id"])
|
|
if kind == "rcon.execute":
|
|
if payload["command"] not in ("status", "getstatus", "version"):
|
|
self.checkpoint({"phase": "rcon-command"})
|
|
else:
|
|
with self.health_probe_gate:
|
|
return self.rcon(instance_id, generation, payload["command"])
|
|
return self.rcon(instance_id, generation, payload["command"])
|
|
raise AgentError("Unsupported operation kind")
|
|
|
|
def execute(self, operation, report_running=False):
|
|
row, _ = self.journal.accept(operation)
|
|
if row["state"] == "unknown":
|
|
return self.reconcile(operation, row)
|
|
if row["state"] != "accepted":
|
|
return row
|
|
running = self.journal.update(operation["id"], "running")
|
|
if report_running:
|
|
try:
|
|
self.report(running)
|
|
except AgentError:
|
|
return self.journal.update(operation["id"], "accepted", {"blocked": "controller-unavailable", "message": "Waiting for controller contact before executing the operation"})
|
|
self.executing_id = operation["id"]
|
|
self.operation_phase = "executing"
|
|
try:
|
|
result = self.dispatch(operation)
|
|
except Held as err:
|
|
if operation.get("kind") == "instance.remove" and "autoscaling" in operation.get("payload", {}):
|
|
return self.journal.update(operation["id"], "failed", {"autoscalingInvalidated": True}, error=str(err))
|
|
return self.journal.update(operation["id"], "accepted", {"blocked": "occupied" if "Players" in str(err) else "unknown", "message": str(err)})
|
|
except AutoscalingInvalidated as err:
|
|
return self.journal.update(operation["id"], "failed", {"autoscalingInvalidated": True}, error=str(err))
|
|
except Exception as err:
|
|
# The command may already have changed Docker or files. Never infer
|
|
# failure means nothing happened, and never retry without inspection.
|
|
diagnostic = None
|
|
if isinstance(err, AgentError):
|
|
message = str(err)
|
|
else:
|
|
message, details = unexpected_operation_error(err, self.operation_phase)
|
|
diagnostic = {"unexpectedError": details}
|
|
checkpoint = self.journal.get(operation["id"]).get("checkpoint")
|
|
return self.journal.update(operation["id"], "failed" if isinstance(err, Rejected) or not checkpoint else "unknown", diagnostic, error=message)
|
|
finally:
|
|
self.executing_id = None
|
|
self.operation_phase = None
|
|
return self.journal.update(operation["id"], "running" if operation["kind"] == "host.decommission" else "completed", result)
|
|
|
|
def recover_credential(self):
|
|
pending = self.config.get("pendingCredential")
|
|
if not pending:
|
|
return False
|
|
previous = self.config["credential"]
|
|
self.config["credential"] = pending
|
|
try:
|
|
self.request("POST", "/api/agent/credentials", {"credential": pending})
|
|
except AgentError:
|
|
self.config["credential"] = previous
|
|
try:
|
|
self.request("POST", "/api/agent/credentials", {"credential": pending})
|
|
except AgentError:
|
|
return False
|
|
self.config["credential"] = pending
|
|
self.config.pop("pendingCredential", None)
|
|
atomic_json(self.config_path, self.config)
|
|
return True
|
|
|
|
def reconcile_uncreated_rebuild(self, operation, row):
|
|
"""Settle removal only when the replacement create was never submitted.
|
|
|
|
The write lane and instance lock exclude the original Python execution.
|
|
A terminal journal receipt prevents any later replay of that command.
|
|
"""
|
|
if row["state"] != "unknown":
|
|
return row
|
|
instance_id, generation = instance_identity(operation.get("instanceId"), operation.get("generation"))
|
|
payload = operation.get("payload", {})
|
|
next_generation = payload.get("nextGeneration", payload.get("newGeneration"))
|
|
if type(next_generation) is not int or next_generation <= generation:
|
|
return row
|
|
previous = json.loads(row["result"]) if row.get("result") else {}
|
|
if not isinstance(previous, dict):
|
|
return row
|
|
directory = scoped(self.root, f"instances/{instance_id}/{next_generation}")
|
|
|
|
def unchanged_generation():
|
|
try:
|
|
marker = scoped(self.root, f"instances/{instance_id}/current.json")
|
|
current = json.loads(marker.read_text())
|
|
except FileNotFoundError:
|
|
return True
|
|
return isinstance(current, dict) and type(current.get("generation")) is int and current["generation"] == generation and current.get("removed", False) is False
|
|
|
|
def both_missing():
|
|
for target in (generation, next_generation):
|
|
health = self.inspect(instance_id, target)
|
|
if health.get("exists") is not False or health.get("running") is not False or health.get("state") != "missing":
|
|
return False
|
|
return True
|
|
|
|
if not unchanged_generation() or not both_missing():
|
|
return row
|
|
self.settle_bank(instance_id, generation, operation["id"])
|
|
# Preparation can leave runtime/configuration files before create's
|
|
# checkpoint. A fresh operation must not inherit those partial files.
|
|
expected_spec = payload.get("spec") or {key: value for key, value in payload.items() if key not in ("nextGeneration", "newGeneration", "unhealthyRecovery")}
|
|
try:
|
|
saved_spec = json.loads(scoped(directory, "spec.json").read_text())
|
|
except FileNotFoundError:
|
|
pass
|
|
else:
|
|
if saved_spec != expected_spec:
|
|
return row
|
|
try:
|
|
metadata = directory.lstat()
|
|
except FileNotFoundError:
|
|
pass
|
|
else:
|
|
if not stat_module.S_ISDIR(metadata.st_mode):
|
|
return row
|
|
shutil.rmtree(directory)
|
|
# Also sync when a previous reconciliation removed the directory but
|
|
# crashed before recording the terminal receipt.
|
|
try:
|
|
sync_parent(directory)
|
|
except FileNotFoundError:
|
|
pass
|
|
if not unchanged_generation() or not both_missing():
|
|
return row
|
|
original_error = row.get("error")
|
|
result = {**previous, "reconciled": True, "originalError": original_error,
|
|
"rebuildFailure": {"phase": "disrupting", "generation": generation, "nextGeneration": next_generation,
|
|
"oldContainerMissing": True, "replacementContainerMissing": True, "replacementFilesRemoved": True}}
|
|
message = "Old container removed; replacement creation failed."
|
|
if original_error:
|
|
message += " " + original_error
|
|
return self.journal.update(operation["id"], "failed", result, error=message)
|
|
|
|
def reconcile(self, operation, row):
|
|
"""Prove desired state with immutable ownership; never resubmit a command."""
|
|
kind, payload = operation["kind"], operation.get("payload", {})
|
|
instance_id, generation = operation.get("instanceId"), operation.get("generation")
|
|
checkpoint = json.loads(row["checkpoint"]) if row.get("checkpoint") else {}
|
|
try:
|
|
if kind == "instance.rebuild" and checkpoint.get("phase") == "disrupting":
|
|
return self.reconcile_uncreated_rebuild(operation, row)
|
|
if kind == "instance.remove" and "autoscaling" in payload and not checkpoint:
|
|
return self.journal.update(operation["id"], "failed", {"autoscalingInvalidated": True}, error="Interrupted autoscaling removal requires a new decision")
|
|
if kind == "instance.cancel-create":
|
|
return self.journal.update(operation["id"], "completed", self.cancel_creation(instance_id, generation, payload, operation["id"]))
|
|
if kind == "instance.create":
|
|
marker = self.instance_dir(instance_id, generation).parent / "current.json"
|
|
if marker.exists():
|
|
current = json.loads(marker.read_text())
|
|
if current["generation"] > generation or current["generation"] == generation and current.get("removed"):
|
|
return self.journal.update(operation["id"], "failed", error="Creation was cancelled or superseded")
|
|
if kind == "host.decommission":
|
|
self.executing_id = operation["id"]
|
|
try:
|
|
result = self.decommission(operation)
|
|
return self.journal.update(operation["id"], "running", result)
|
|
finally:
|
|
self.executing_id = None
|
|
if kind == "credentials.rotate":
|
|
if not self.config.get("pendingCredential") or self.recover_credential():
|
|
return self.journal.update(operation["id"], "completed", {"rotated": True, "reconciled": True})
|
|
return row
|
|
if kind in ("runtime.pull", "plutonium.prepare"):
|
|
# Docker resumes already downloaded layers. Pull again under
|
|
# the same operation ID: an existing tag can still refer to an
|
|
# older image and cannot prove an interrupted pull completed.
|
|
self.journal.update(operation["id"], "running")
|
|
try:
|
|
result = self.dispatch(operation)
|
|
return self.journal.update(operation["id"], "completed", {**result, "reconciled": True})
|
|
except Held as err:
|
|
return self.journal.update(operation["id"], "accepted", {"blocked": "host-busy", "message": str(err)})
|
|
except Exception:
|
|
return self.journal.update(operation["id"], "failed", error="Interrupted runtime download could not be resumed")
|
|
if kind == "library.import":
|
|
try:
|
|
with self.artifact_open("GET", payload["completeUrl"]) as response:
|
|
status = json.load(response)
|
|
if status.get("status") == "published":
|
|
return self.journal.update(operation["id"], "completed", status)
|
|
if checkpoint.get("phase") == "publishing-import" or status.get("status") == "verifying":
|
|
return self.journal.update(operation["id"], "completed", self.complete_import(payload))
|
|
# Import uploads use an immutable import ID and per-file
|
|
# checksums. Reconcile their staged files after interruption.
|
|
self.journal.update(operation["id"], "running")
|
|
self.executing_id = operation["id"]
|
|
return self.journal.update(operation["id"], "completed", self.library_import(payload))
|
|
except Rejected as err:
|
|
return self.journal.update(operation["id"], "failed", error=str(err))
|
|
except Exception:
|
|
return self.journal.update(operation["id"], "unknown", error="Import reconciliation is waiting for controller contact")
|
|
finally:
|
|
self.executing_id = None
|
|
if kind == "library.remove":
|
|
return self.journal.update(operation["id"], "completed", self.library_remove(payload))
|
|
if kind == "library.download":
|
|
# This is a resumable immutable transfer, not a repeated
|
|
# container command. Full files skip transfer; verification
|
|
# always starts again so interrupted reads cannot prove ready.
|
|
self.journal.update(operation["id"], "running")
|
|
try:
|
|
return self.journal.update(operation["id"], "completed", self.library_download(payload))
|
|
except Exception:
|
|
return self.journal.update(operation["id"], "failed", error="Interrupted library download could not be resumed")
|
|
if kind == "files.stage":
|
|
return self.journal.update(operation["id"], "completed", self.stage(instance_id, generation, payload, operation["id"]))
|
|
if self.read_operation(operation):
|
|
return self.journal.update(operation["id"], "completed", self.dispatch(operation))
|
|
if kind in ("files.activate", "files.rollback") and checkpoint:
|
|
stage = self.instance_dir(instance_id, generation) / "stages" / identifier(payload["stageId"]) / "manifest.json"
|
|
saved = json.loads(stage.read_text())
|
|
expected = "activated" if kind == "files.activate" else "rolled-back"
|
|
if saved["state"] == expected:
|
|
health = self.inspect(instance_id, generation)
|
|
restarted = health["running"] and health.get("containerId") == checkpoint.get("containerId") and bool(checkpoint.get("startedAt")) and health.get("startedAt") != checkpoint["startedAt"]
|
|
if not payload.get("restart", True) or restarted:
|
|
return self.journal.update(operation["id"], "completed", {"activated": expected == "activated", "rolledBack": expected == "rolled-back", "before": saved["before"], "reconciled": True})
|
|
return row
|
|
if not kind.startswith("instance.") or not checkpoint:
|
|
return row
|
|
observed_generation = payload.get("nextGeneration", payload.get("newGeneration")) if kind == "instance.rebuild" else generation
|
|
health = self.inspect(instance_id, observed_generation)
|
|
before = []
|
|
if payload.get("stageId"):
|
|
stage = self.instance_dir(instance_id, generation) / "stages" / identifier(payload["stageId"]) / "manifest.json"
|
|
saved = json.loads(stage.read_text())
|
|
if saved["state"] != "activated":
|
|
return row
|
|
before = saved["before"]
|
|
proven = False
|
|
if kind in ("instance.create", "instance.rebuild"):
|
|
proven = health["running"] and health.get("creationOperation") == operation["id"] and health.get("specSha256") == checkpoint.get("specSha256")
|
|
if proven:
|
|
directory = self.instance_dir(instance_id, observed_generation)
|
|
atomic_json(directory.parent / "current.json", {"generation": observed_generation})
|
|
if kind == "instance.rebuild" and not self.inspect(instance_id, generation)["exists"]:
|
|
self.settle_bank(instance_id, generation, operation["id"])
|
|
old = self.instance_dir(instance_id, generation)
|
|
if old.exists():
|
|
shutil.rmtree(old)
|
|
elif kind in ("instance.start", "instance.stop"):
|
|
proven = health.get("containerId") == checkpoint.get("containerId") and (health["running"] if kind == "instance.start" else health["state"] in ("stopped", "exited", "created", "dead"))
|
|
if kind == "instance.start":
|
|
proven = proven and checkpoint.get("phase") == "starting"
|
|
if checkpoint.get("restartRequired"):
|
|
proven = proven and bool(checkpoint.get("startedAt")) and bool(health.get("startedAt")) and health["startedAt"] != checkpoint["startedAt"]
|
|
elif kind == "instance.restart":
|
|
proven = health["running"] and health.get("containerId") == checkpoint.get("containerId") and bool(checkpoint.get("startedAt")) and bool(health.get("startedAt")) and health["startedAt"] != checkpoint["startedAt"]
|
|
elif kind == "instance.remove":
|
|
proven = not health["exists"]
|
|
if proven:
|
|
self.settle_bank(instance_id, generation, operation["id"])
|
|
directory = self.instance_dir(instance_id, generation)
|
|
if directory.exists():
|
|
shutil.rmtree(directory)
|
|
self.mark_removed(instance_id, generation)
|
|
if proven:
|
|
return self.journal.update(operation["id"], "completed", {**health, "before": before, "reconciled": True})
|
|
except (AgentError, OSError, ValueError, KeyError):
|
|
pass
|
|
return row
|
|
|
|
def report(self, row, journal=None):
|
|
payload = {"state": row["state"]}
|
|
if row.get("result"):
|
|
payload["result"] = json.loads(row["result"])
|
|
if row["state"] == "accepted" and isinstance(payload["result"], dict) and payload["result"].get("blocked"):
|
|
# The controller sees a durable running/waiting operation;
|
|
# locally accepted means its mutation gate can safely retry.
|
|
payload["state"] = "running"
|
|
if row.get("error"):
|
|
payload["error"] = row["error"]
|
|
response = self.request("POST", "/api/agent/operations/" + urllib.parse.quote(row["id"], safe=""), payload)
|
|
confirmed = response.get("operation") if isinstance(response, dict) else None
|
|
if row["state"] == "completed" and isinstance(confirmed, dict) and confirmed.get("id") == row["id"] and confirmed.get("state") == "completed":
|
|
(journal if journal is not None else self.journal).confirm_completed(row)
|
|
return response
|
|
|
|
def heartbeat(self):
|
|
response = self.request("POST", "/api/agent/heartbeat", {"inventory": self.inventory(), "operationId": self.current_operation})
|
|
# Only the heartbeat thread replaces this immutable snapshot; it never
|
|
# mutates the operation journal or races Docker cleanup against create.
|
|
self.cancelled_creations = frozenset(value for value in response.get("cancelledCreations", []) if isinstance(value, str))
|
|
self.defer_plutonium_preparation = response.get("deferPlutoniumPreparation") is True
|
|
keep_days = response.get("observationKeepDays")
|
|
self.observation_keep_days = keep_days if valid_observation_keep_days(keep_days) else 0
|
|
host = response.get("host", {})
|
|
if isinstance(host.get("portMin"), int) and isinstance(host.get("portMax"), int):
|
|
self.config["portRange"] = {"min": host["portMin"], "max": host["portMax"]}
|
|
# Written only after the controller accepted this process's authenticated
|
|
# heartbeat. Service liveness alone cannot prove an update works.
|
|
self.record_health()
|
|
|
|
def heartbeat_loop(self):
|
|
while not self.stopping.wait(15):
|
|
try:
|
|
self.heartbeat()
|
|
except (AgentError, OSError, ValueError):
|
|
pass
|
|
except Exception as err:
|
|
print(f"Heartbeat recovered from {type(err).__name__}", file=sys.stderr)
|
|
|
|
@staticmethod
|
|
def read_operation(operation):
|
|
return read_operation(operation)
|
|
|
|
def instance_lock(self, operation):
|
|
key = operation.get("instanceId")
|
|
return self.instance_locks.setdefault(key, threading.RLock()) if key else contextlib.nullcontext()
|
|
|
|
@contextlib.contextmanager
|
|
def observation_lock(self, operation, health=False):
|
|
lock = self.instance_lock(operation)
|
|
if not health or not operation.get("instanceId"):
|
|
with lock:
|
|
yield
|
|
return
|
|
if not lock.acquire(timeout=HEALTH_LOCK_TIMEOUT_SECONDS):
|
|
raise AgentError("Health check timed out waiting for the instance operation")
|
|
try:
|
|
yield
|
|
finally:
|
|
lock.release()
|
|
|
|
@staticmethod
|
|
def health_operation(operation):
|
|
return operation.get("kind") in ("instance.inspect", "rcon.status-batch") or (operation.get("kind") == "rcon.execute" and operation.get("payload", {}).get("command") in ("status", "getstatus", "version"))
|
|
|
|
def read_loop(self, lane="read", lane_name=None):
|
|
# Each lane owns its SQLite connection and never writes executing_id,
|
|
# checkpoints, or current_operation used by the mutation state machine.
|
|
lane_name = lane_name or ("health-1" if lane == "health" else "read")
|
|
self.lane_started(lane_name)
|
|
journal = Journal(self.root / "agent" / "operations.sqlite3", recover=False)
|
|
next_prune = time.monotonic()
|
|
try:
|
|
while not self.stopping.is_set():
|
|
try:
|
|
if self.update_pending():
|
|
self.stopping.wait(3)
|
|
continue
|
|
if lane == "read" and time.monotonic() >= next_prune:
|
|
next_prune = time.monotonic() + 60
|
|
with self.activity(read_only=True) as acquired:
|
|
if acquired and not self.update_pending():
|
|
if journal.prune_observations(self.observation_keep_days) == 500:
|
|
next_prune = time.monotonic() + 15
|
|
response = self.request("GET", f"/api/agent/operations?lane={lane}&wait=20")
|
|
self.lane_polled(lane_name)
|
|
handled = False
|
|
self.lane_busy(lane_name, bool(response.get("operations")))
|
|
for operation in response.get("operations", []):
|
|
if self.stopping.is_set() or not self.read_operation(operation):
|
|
continue
|
|
if lane == "health" and not self.health_operation(operation):
|
|
continue
|
|
with self.read_claim_lock:
|
|
if operation["id"] in self.read_claims:
|
|
continue
|
|
self.read_claims.add(operation["id"])
|
|
try:
|
|
with self.activity(read_only=True) as acquired:
|
|
if not acquired or self.update_pending():
|
|
continue
|
|
row, _ = journal.accept(operation)
|
|
if row["state"] in ("accepted", "unknown", "running"):
|
|
self.report(journal.update(operation["id"], "running"), journal=journal)
|
|
try:
|
|
with self.read_gate.read(), self.observation_lock(operation, health=lane == "health"):
|
|
result = self.dispatch(operation)
|
|
row = journal.update(operation["id"], "completed", result)
|
|
except Exception as err:
|
|
row = journal.update(operation["id"], "failed", error=str(err) if isinstance(err, AgentError) else "Observation unavailable")
|
|
# Receipt delivery can take seconds; it must not
|
|
# hold the instance lock needed by health checks.
|
|
self.report(row, journal=journal)
|
|
handled = True
|
|
finally:
|
|
with self.read_claim_lock:
|
|
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):
|
|
self.stopping.wait(3)
|
|
except Exception as err:
|
|
print(f"{lane} lane recovered from {type(err).__name__}", file=sys.stderr)
|
|
self.stopping.wait(3)
|
|
finally:
|
|
self.lane_busy(lane_name, False)
|
|
finally:
|
|
journal.close()
|
|
|
|
def poll_operations(self):
|
|
if self.stopping.is_set() or self.update_pending():
|
|
return
|
|
# Check both update and mutation locks before delivery, release them
|
|
# during the long poll, and recheck immediately before accepting work.
|
|
with self.activity() as acquired:
|
|
if not acquired:
|
|
return
|
|
# Never hold the updater lock while waiting for delivery.
|
|
response = self.request("GET", "/api/agent/operations?lane=write&wait=20")
|
|
self.lane_polled("write")
|
|
self.lane_busy("write", bool(response.get("operations")))
|
|
with self.activity() as acquired:
|
|
if not acquired or self.stopping.is_set() or self.update_pending():
|
|
return
|
|
if self.config.get("pendingCredential"):
|
|
self.recover_credential()
|
|
completed = True
|
|
for operation in response.get("operations", []):
|
|
if self.stopping.is_set():
|
|
break
|
|
if self.read_operation(operation):
|
|
continue
|
|
self.current_operation = operation["id"]
|
|
# Global changes wait for observations; instance changes only
|
|
# block observations for their own immutable instance identity.
|
|
guard = self.read_gate.write() if operation["kind"] in ("host.decommission", "credentials.rotate") else contextlib.nullcontext()
|
|
try:
|
|
with guard, self.instance_lock(operation):
|
|
accepted, _ = self.journal.accept(operation)
|
|
self.report(accepted)
|
|
result = self.execute(operation, report_running=True)
|
|
self.report(result)
|
|
completed = completed and result["state"] in ("completed", "failed")
|
|
finally:
|
|
self.current_operation = None
|
|
return completed
|
|
|
|
def run(self):
|
|
self.lane_started("write")
|
|
# Obtain the configured host port range before accepting any deployment.
|
|
# Enrollment's default values must never race the first heartbeat.
|
|
while not self.stopping.is_set():
|
|
try:
|
|
with self.activity() as acquired:
|
|
if not acquired:
|
|
self.stopping.wait(3)
|
|
continue
|
|
if self.config.get("pendingCredential"):
|
|
self.recover_credential()
|
|
self.heartbeat()
|
|
break
|
|
except (AgentError, OSError, ValueError):
|
|
self.stopping.wait(3)
|
|
except Exception as err:
|
|
print(f"Initial heartbeat recovered from {type(err).__name__}", file=sys.stderr)
|
|
self.stopping.wait(3)
|
|
heartbeats = threading.Thread(target=self.heartbeat_loop, daemon=True)
|
|
heartbeats.start()
|
|
# Preserve two concurrent health checks even while files are being read.
|
|
readers = [threading.Thread(target=self.read_loop, args=(lane, name), daemon=True)
|
|
for name, lane in (("health-1", "health"), ("health-2", "health"), ("read", "read"))]
|
|
for reader in readers:
|
|
reader.start()
|
|
while not self.stopping.is_set():
|
|
completed = False
|
|
try:
|
|
completed = self.poll_operations()
|
|
except (AgentError, OSError, ValueError, KeyError):
|
|
# Logs deliberately omit URLs, tokens, payloads and raw errors.
|
|
print("Host controller contact unavailable; existing containers continue running", file=sys.stderr)
|
|
except Exception as err:
|
|
print(f"Write lane recovered from {type(err).__name__}", file=sys.stderr)
|
|
finally:
|
|
self.lane_busy("write", False)
|
|
self.stopping.wait(0.25 if completed else 3)
|
|
for reader in readers:
|
|
reader.join(timeout=2)
|
|
heartbeats.join(timeout=2)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--config", default="/etc/electric-cherry/agent.json")
|
|
parser.add_argument("--enroll", action="store_true")
|
|
parser.add_argument("--controller")
|
|
parser.add_argument("--token-file")
|
|
parser.add_argument("--root", default="/var/lib/electric-cherry")
|
|
parser.add_argument("--force-enroll", action="store_true")
|
|
parser.add_argument("--uninstall", metavar="REQUEST", help="Run the installed host removal service")
|
|
parser.add_argument("--version", action="version", version=VERSION)
|
|
args = parser.parse_args()
|
|
if args.uninstall:
|
|
if os.geteuid() != 0 or args.enroll:
|
|
raise AgentError("Host uninstall must run as the dedicated root service")
|
|
Uninstaller(args.uninstall).run()
|
|
return
|
|
if args.enroll:
|
|
if not args.controller or not args.token_file:
|
|
parser.error("Enrollment needs --controller and --token-file")
|
|
if Path(args.config).exists() and not args.force_enroll:
|
|
existing = json.loads(Path(args.config).read_text())
|
|
if existing.get("credential"):
|
|
if existing.get("controller", "").rstrip("/") != args.controller.rstrip("/") or Path(existing.get("root", "")).resolve() != Path(args.root).resolve():
|
|
raise AgentError("Existing enrollment uses a different controller or data directory")
|
|
print("Host is already enrolled; retaining existing credentials")
|
|
return
|
|
agent = Agent({"controller": args.controller.rstrip("/"), "root": args.root})
|
|
token = Path(args.token_file).read_text().strip()
|
|
enrolled = agent.request("POST", "/api/agent/enroll", {"token": token, "inventory": agent.inventory()}, credential=False)
|
|
if not enrolled.get("hostId") or not enrolled.get("credential"):
|
|
raise AgentError("Controller returned invalid enrollment credentials")
|
|
atomic_json(args.config, {"controller": args.controller.rstrip("/"), "root": args.root, "hostId": enrolled["hostId"], "credential": enrolled["credential"], "portRange": enrolled.get("portRange", {"min": 28960, "max": 29060})})
|
|
agent.journal.close()
|
|
print("Host enrollment completed")
|
|
return
|
|
config = json.loads(Path(args.config).read_text())
|
|
config["configPath"] = args.config
|
|
lock_root = Path(config.get("root", "/var/lib/electric-cherry")) / "agent"
|
|
lock_root.mkdir(parents=True, exist_ok=True)
|
|
lock = open(lock_root / "process.lock", "a")
|
|
try:
|
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except BlockingIOError:
|
|
raise AgentError("Another host agent already owns this data directory") from None
|
|
agent = Agent(config)
|
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
signal.signal(sig, lambda *_: agent.stopping.set())
|
|
try:
|
|
agent.run()
|
|
finally:
|
|
agent.journal.close()
|
|
lock.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
main()
|
|
except (AgentError, OSError, ValueError, KeyError):
|
|
print("Electric Cherry host agent failed; check configuration and controller availability", file=sys.stderr)
|
|
sys.exit(1)
|