Add automatic agent updates with verified rollback

This commit is contained in:
2026-10-04 10:40:45 -04:00
parent 020894b816
commit 8d1331c67d
26 changed files with 2568 additions and 110 deletions
+124 -19
View File
@@ -29,7 +29,7 @@ import urllib.parse
import urllib.request
import uuid
VERSION = "1.1.0"
VERSION = "1.2.0"
LABEL = "io.electric-cherry"
MAX_FILE = 32 * 1024 * 1024
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$")
@@ -39,6 +39,13 @@ 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"
@@ -105,7 +112,7 @@ def uninstall_root(value, identity=None):
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):
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)
@@ -499,6 +506,19 @@ class Uninstaller:
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)
@@ -512,7 +532,7 @@ class Uninstaller:
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):
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)
@@ -591,6 +611,74 @@ class Agent:
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):
path = self.root / "agent" / "activity.lock"
descriptor = os.open(path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
try:
try:
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
yield False
return
yield True
finally:
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"]:
@@ -733,7 +821,9 @@ class Agent:
for line in Path("/etc/os-release").read_text().splitlines():
if line.startswith("PRETTY_NAME="):
os_name = line.split("=", 1)[1].strip('"')
return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": ["host.decommission"] if self.can_uninstall() else [], "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})}
update = self.update_inventory()
capabilities = (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})}
def safe_to_disrupt(self, instance_id, generation):
health = self.inspect(instance_id, generation)
@@ -1462,40 +1552,55 @@ class Agent:
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:
except (AgentError, OSError, ValueError):
pass
def poll_operations(self):
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()
response = self.request("GET", "/api/agent/operations")
for operation in response.get("operations", []):
if self.stopping.is_set():
break
self.current_operation = operation["id"]
try:
accepted, _ = self.journal.accept(operation)
self.report(accepted)
self.report(self.execute(operation, report_running=True))
finally:
self.current_operation = None
def run(self):
if self.config.get("pendingCredential"):
self.recover_credential()
# 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:
except (AgentError, OSError, ValueError):
self.stopping.wait(3)
heartbeats = threading.Thread(target=self.heartbeat_loop, daemon=True)
heartbeats.start()
while not self.stopping.is_set():
try:
if self.config.get("pendingCredential"):
self.recover_credential()
response = self.request("GET", "/api/agent/operations")
for operation in response.get("operations", []):
if self.stopping.is_set():
break
self.current_operation = operation["id"]
accepted, _ = self.journal.accept(operation)
self.report(accepted)
self.report(self.execute(operation, report_running=True))
self.current_operation = None
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)
+479
View File
@@ -0,0 +1,479 @@
#!/usr/bin/env python3
"""Stable Electric Cherry agent updater; only verified agent code is replaced.
The installer owns this bootstrap and its systemd timer. Updates never execute
downloaded code before installing it, alter game containers, or edit enrollment
credentials. A durable journal makes an interrupted switch roll back safely.
"""
import ast
import contextlib
import fcntl
import hashlib
import http.client
import json
import math
import os
from pathlib import Path
import re
import stat
import subprocess
import sys
import time
import urllib.parse
import urllib.request
import uuid
UPDATER_PROTOCOL_VERSION = 1
AGENT_CONFIG = Path("/etc/electric-cherry/agent.json")
AGENT_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-agent.py")
UNINSTALL_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-uninstall.py")
UNINSTALL_REQUEST = Path("/etc/electric-cherry/decommission.json")
UPDATE_LOCK = Path("/etc/electric-cherry/update.lock")
UPDATE_DIR = Path("/etc/electric-cherry/update")
UPDATE_JOURNAL = UPDATE_DIR / "journal.json"
UPDATE_STATUS = Path("/etc/electric-cherry/update-status.json")
AGENT_HEALTH = Path("/etc/electric-cherry/agent-health.json")
AGENT_SERVICE = "electric-cherry-agent.service"
MAX_MANIFEST = 16 * 1024
MAX_SOURCE = 8 * 1024 * 1024
HEALTH_TIMEOUT = 90
VERSION_RE = re.compile(r"^(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})$")
DIGEST_RE = re.compile(r"^[0-9a-f]{64}$")
class UpdateError(Exception):
pass
class IntegrityError(UpdateError):
pass
class ContactError(UpdateError):
pass
class Deferred(UpdateError):
pass
def trusted(path, missing=False, directory=False):
path = Path(path)
for current in (*reversed(path.parents), path):
try:
info = current.lstat()
except FileNotFoundError:
if missing:
continue
raise UpdateError("Agent update files are unavailable; repair the installation") from None
expected_type = stat.S_ISDIR if current != path or directory else stat.S_ISREG
if not expected_type(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
raise UpdateError("Agent update paths must be regular root-owned files and directories that other users cannot modify")
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 atomic_bytes(path, value, mode=0o600):
path = trusted(path, missing=True)
trusted(path.parent, directory=True)
# Code staging uses known names so recovery and host removal can also
# remove a file left behind by a power loss during the atomic write.
temporary = path.with_name(path.name + ".update") if path in (AGENT_EXECUTABLE, UNINSTALL_EXECUTABLE) else path.with_name(path.name + "." + uuid.uuid4().hex + ".tmp")
try:
if temporary.exists() or temporary.is_symlink():
trusted(temporary)
temporary.unlink()
sync_parent(temporary)
descriptor = os.open(temporary, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, mode)
with os.fdopen(descriptor, "wb") as stream:
stream.write(value)
stream.flush()
os.fsync(stream.fileno())
trusted(path, missing=True)
os.replace(temporary, path)
sync_parent(path)
finally:
temporary.unlink(missing_ok=True)
def atomic_json(path, value):
atomic_bytes(path, json.dumps(value, sort_keys=True, allow_nan=False).encode())
def read_bytes(path, limit):
trusted(path)
with open(path, "rb") as stream:
value = stream.read(limit + 1)
if len(value) > limit:
raise UpdateError("An agent update file exceeds its size limit")
return value
def read_json(path, limit=MAX_MANIFEST):
try:
value = json.loads(read_bytes(path, limit))
except (ValueError, UnicodeError):
raise UpdateError("Agent update metadata is invalid") from None
if not isinstance(value, dict):
raise UpdateError("Agent update metadata is invalid")
return value
def sha256(value):
return hashlib.sha256(value).hexdigest()
def version(value):
if not isinstance(value, str) or not VERSION_RE.fullmatch(value):
raise IntegrityError("Agent releases must use a numeric major.minor.patch version")
return tuple(int(part) for part in value.split("."))
def source_version(source):
try:
tree = ast.parse(source, filename="electric-cherry-agent.py")
compile(tree, "electric-cherry-agent.py", "exec")
except (SyntaxError, ValueError, TypeError, RecursionError, MemoryError):
raise IntegrityError("The agent release is not valid Python source") from None
declarations = [node.value.value for node in tree.body if isinstance(node, ast.Assign) and any(isinstance(target, ast.Name) and target.id == "VERSION" for target in node.targets) and isinstance(node.value, ast.Constant)]
if len(declarations) != 1:
raise IntegrityError("The agent release must declare one explicit VERSION")
version(declarations[0])
return declarations[0]
@contextlib.contextmanager
def exclusive_lock(path):
trusted(path, missing=True)
trusted(Path(path).parent, directory=True)
descriptor = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_CLOEXEC, 0o600)
with os.fdopen(descriptor, "r+") as stream:
try:
fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
yield None
return
try:
yield stream
finally:
fcntl.flock(stream, fcntl.LOCK_UN)
def systemctl(*args):
try:
result = subprocess.run(["systemctl", *args], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=60, check=False)
except (OSError, subprocess.TimeoutExpired):
raise UpdateError("Agent service management is unavailable") from None
if result.returncode:
raise UpdateError("Agent service management did not complete")
return result.stdout.decode("utf-8", "replace")
def service_state():
output = systemctl("show", AGENT_SERVICE, "--property=ActiveState", "--property=MainPID")
fields = dict(line.split("=", 1) for line in output.splitlines() if "=" in line)
try:
return fields.get("ActiveState") == "active", int(fields.get("MainPID", "0"))
except ValueError:
return False, 0
class Updater:
def __init__(self):
self.installation = None
self.current_version = None
self.target = None
def load_installation(self):
config = read_json(AGENT_CONFIG, 64 * 1024)
controller = config.get("controller")
root_value = config.get("root")
host_id = config.get("hostId")
if not isinstance(controller, str) or not isinstance(root_value, str) or not isinstance(host_id, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,127}", host_id):
raise UpdateError("Agent enrollment is incomplete")
parsed = urllib.parse.urlsplit(controller)
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment:
raise UpdateError("Agent updates require an HTTPS controller without embedded credentials")
root = Path(root_value)
if not re.fullmatch(r"/[A-Za-z0-9/_.-]+", root_value) or str(root) != root_value or ".." in root.parts or len(root.parts) < 3:
raise UpdateError("The enrolled agent data directory is invalid")
trusted(root, directory=True)
trusted(root / "agent", directory=True)
marker = read_json(root / ".electric-cherry.json")
controller = controller.rstrip("/")
expected = {"version": 1, "root": root_value, "hostId": host_id, "controller": controller}
if marker != expected:
raise UpdateError("Agent data ownership does not match this enrollment")
info = root.stat()
installation = {"hostId": host_id, "controller": controller, "root": root_value, "rootIdentity": [info.st_dev, info.st_ino]}
if self.installation is not None and installation != self.installation:
raise UpdateError("Agent enrollment changed during the update; no code was replaced")
self.installation = installation
return installation
def ensure_directory(self):
trusted(UPDATE_DIR, missing=True, directory=True)
trusted(UPDATE_DIR.parent, directory=True)
if not UPDATE_DIR.exists():
UPDATE_DIR.mkdir(mode=0o700)
sync_parent(UPDATE_DIR)
def check_uninstall(self):
if UNINSTALL_REQUEST.exists() or UNINSTALL_REQUEST.is_symlink():
raise Deferred("Host deletion is pending; agent updates are paused")
def status(self, state, error=None, current=None):
value = {"state": state, "checkedAt": time.time()}
if current or self.current_version:
value["currentVersion"] = current or self.current_version
if self.target:
value["targetVersion"] = self.target["version"]
if error:
value["error"] = str(error)[:500]
atomic_json(UPDATE_STATUS, value)
return state
def fetch(self, path, limit):
request = urllib.request.Request(self.installation["controller"] + path, headers={"User-Agent": "electric-cherry-updater/1"})
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise ContactError("Agent release redirects are not allowed")
try:
with urllib.request.build_opener(NoRedirect()).open(request, timeout=25) as response:
if response.status != 200:
raise ContactError("The controller did not return a complete agent release")
length = response.headers.get("Content-Length")
if length is not None and (not length.isdecimal() or int(length) > limit):
raise IntegrityError("The controller release exceeds its download size limit")
chunks, total = [], 0
deadline = time.monotonic() + 120
while True:
if time.monotonic() >= deadline:
raise ContactError("The controller release download timed out")
# HTTPResponse.read1 performs one socket read. read(n) can
# wait indefinitely for n bytes from a trickling server.
block = response.read1(min(64 * 1024, limit + 1 - total))
if not block:
break
total += len(block)
if total > limit:
raise IntegrityError("The controller release exceeds its download size limit")
chunks.append(block)
if length is not None and total != int(length):
raise ContactError("The controller release download was interrupted")
return b"".join(chunks)
except (OSError, ValueError, http.client.HTTPException):
raise ContactError("The controller release is unavailable; the running agent was left unchanged") from None
def manifest(self):
try:
value = json.loads(self.fetch("/api/agent/release/manifest.json", MAX_MANIFEST))
except (ValueError, UnicodeError):
raise IntegrityError("The controller release manifest is invalid") from None
if not isinstance(value, dict) or type(value.get("protocolVersion")) is not int or value["protocolVersion"] != 1 or value.get("platform") != "debian-amd64":
raise IntegrityError("The controller release is not compatible with this host agent")
if type(value.get("updaterProtocolVersion")) is not int or value["updaterProtocolVersion"] != UPDATER_PROTOCOL_VERSION:
raise Deferred("This release needs a newer updater bootstrap; rerun the host installer")
version(value.get("version"))
if not isinstance(value.get("sha256"), str) or not DIGEST_RE.fullmatch(value["sha256"]):
raise IntegrityError("The controller release manifest has an invalid checksum")
return {"version": value["version"], "sha256": value["sha256"]}
def hold(self, target, error):
self.ensure_directory()
atomic_json(UPDATE_DIR / "failed-release.json", {**target, "error": error, "failedAt": time.time()})
def release_held(self):
path = UPDATE_DIR / "failed-release.json"
if not path.exists():
return False
held = read_json(path)
return all(held.get(key) == self.target[key] for key in ("version", "sha256"))
def save_journal(self, journal, **changes):
journal.update(changes)
atomic_json(UPDATE_JOURNAL, journal)
def read_journal(self):
journal = read_json(UPDATE_JOURNAL)
if journal.get("installation") != self.installation or journal.get("phase") not in ("installing", "verifying", "rollback") or not isinstance(journal.get("id"), str) or not re.fullmatch(r"[a-f0-9]{32}", journal["id"]):
raise UpdateError("Pending agent update ownership or recovery metadata is invalid")
version(journal.get("previousVersion"))
version(journal.get("targetVersion"))
for field in ("targetSha256", "agentBackupSha256", "uninstallBackupSha256"):
if not isinstance(journal.get(field), str) or not DIGEST_RE.fullmatch(journal[field]):
raise UpdateError("Pending agent update recovery checksums are invalid")
return journal
def finish_journal(self):
trusted(UPDATE_JOURNAL)
UPDATE_JOURNAL.unlink()
sync_parent(UPDATE_JOURNAL)
# Removing the journal commits the decision. Backups are disposable
# only afterward, so a crash never leaves recovery without its inputs.
for name in ("agent.backup.py", "uninstall.backup.py", "candidate.py"):
path = trusted(UPDATE_DIR / name, missing=True)
path.unlink(missing_ok=True)
sync_parent(UPDATE_JOURNAL)
def activity_lock(self):
return exclusive_lock(Path(self.installation["root"]) / "agent" / "activity.lock")
def install(self, source):
with self.activity_lock() as activity:
if activity is None:
raise Deferred("The agent is executing an operation; update will retry when it is idle")
self.check_uninstall()
self.load_installation()
active, pid = service_state()
if not active or pid <= 0:
raise Deferred("Agent service is not running; automatic updates wait until it is started")
previous = read_bytes(AGENT_EXECUTABLE, MAX_SOURCE)
uninstall = read_bytes(UNINSTALL_EXECUTABLE, MAX_SOURCE)
if source_version(previous) != self.current_version:
raise Deferred("The installed agent changed while checking for updates")
source_version(uninstall)
self.ensure_directory()
atomic_bytes(UPDATE_DIR / "agent.backup.py", previous)
atomic_bytes(UPDATE_DIR / "uninstall.backup.py", uninstall)
atomic_bytes(UPDATE_DIR / "candidate.py", source)
journal = {"id": uuid.uuid4().hex, "phase": "installing", "installation": self.installation, "previousVersion": self.current_version, "targetVersion": self.target["version"], "targetSha256": self.target["sha256"], "agentBackupSha256": sha256(previous), "uninstallBackupSha256": sha256(uninstall), "startedAt": time.time()}
self.save_journal(journal)
self.status("updating")
self.check_uninstall()
self.load_installation()
systemctl("stop", AGENT_SERVICE)
atomic_bytes(AGENT_EXECUTABLE, source, 0o755)
atomic_bytes(UNINSTALL_EXECUTABLE, source, 0o755)
self.save_journal(journal, phase="verifying", switchedAt=time.time())
# The candidate takes the same lock during startup credential recovery.
# Never wait for its start or authenticated heartbeat while holding it.
systemctl("start", AGENT_SERVICE)
if not self.wait_healthy(journal):
raise UpdateError("The new agent did not report a healthy authenticated heartbeat; the previous release will be restored")
self.check_uninstall()
self.load_installation()
if any(sha256(read_bytes(path, MAX_SOURCE)) != journal["targetSha256"] for path in (AGENT_EXECUTABLE, UNINSTALL_EXECUTABLE)):
raise UpdateError("Installed agent code changed before update verification finished")
self.status("updated", current=journal["targetVersion"])
self.current_version = journal["targetVersion"]
self.finish_journal()
return "updated"
def wait_healthy(self, journal):
deadline = time.monotonic() + HEALTH_TIMEOUT
while time.monotonic() < deadline:
self.check_uninstall()
try:
active, pid = service_state()
health = read_json(AGENT_HEALTH, 4096)
checked = health.get("checkedAt")
if active and pid > 0 and type(health.get("pid")) is int and health["pid"] == pid and health.get("version") == journal["targetVersion"] and health.get("updateId") == journal["id"] and type(checked) in (float, int) and math.isfinite(checked) and journal["switchedAt"] <= checked <= time.time() + 5:
return True
except (UpdateError, OSError, ValueError):
pass
time.sleep(2)
return False
def rollback(self, journal, error):
self.target = {"version": journal["targetVersion"], "sha256": journal["targetSha256"]}
self.check_uninstall()
self.load_installation()
if journal["installation"] != self.installation:
raise UpdateError("Agent enrollment changed; rollback needs operator review")
backups = [(AGENT_EXECUTABLE, "agent.backup.py", "agentBackupSha256"), (UNINSTALL_EXECUTABLE, "uninstall.backup.py", "uninstallBackupSha256")]
sources = []
for destination, name, checksum in backups:
source = read_bytes(UPDATE_DIR / name, MAX_SOURCE)
if sha256(source) != journal[checksum]:
raise UpdateError("Agent rollback backup failed integrity verification; repair the installation")
source_version(source)
sources.append((destination, source))
if source_version(sources[0][1]) != journal["previousVersion"]:
raise UpdateError("Agent rollback version does not match its recovery journal")
self.save_journal(journal, phase="rollback")
with self.activity_lock() as activity:
if activity is None:
raise Deferred("Agent rollback is waiting for the active operation to finish")
self.check_uninstall()
self.load_installation()
systemctl("stop", AGENT_SERVICE)
for destination, source in sources:
atomic_bytes(destination, source, 0o755)
self.save_journal(journal, rollbackSwitchedAt=time.time())
systemctl("start", AGENT_SERVICE)
rollback_health = {**journal, "targetVersion": journal["previousVersion"], "switchedAt": journal["rollbackSwitchedAt"]}
if not self.wait_healthy(rollback_health):
raise UpdateError("The previous agent release was restored but its authenticated health is unconfirmed; recovery will retry")
self.hold(self.target, error)
self.status("failed", error, current=journal["previousVersion"])
self.current_version = journal["previousVersion"]
self.finish_journal()
return "failed"
def run(self):
with exclusive_lock(UPDATE_LOCK) as updater:
if updater is None:
return "deferred" # Preserve the active worker's status.
recovering = False
try:
self.check_uninstall()
self.load_installation()
if UPDATE_JOURNAL.exists() or UPDATE_JOURNAL.is_symlink():
journal = self.read_journal()
self.current_version = journal["previousVersion"]
recovering = True
return self.rollback(journal, "An interrupted agent update was rolled back to the previous release")
self.current_version = source_version(read_bytes(AGENT_EXECUTABLE, MAX_SOURCE))
self.status("checking")
self.target = self.manifest()
if version(self.target["version"]) <= version(self.current_version):
return self.status("current")
if self.release_held():
return self.status("failed", "This release previously failed verification and is held until the controller publishes a different release")
source = self.fetch("/api/agent/release/ec-agent.py", MAX_SOURCE)
if sha256(source) != self.target["sha256"]:
raise IntegrityError("The agent release checksum does not match its manifest")
if source_version(source) != self.target["version"]:
raise IntegrityError("The agent source VERSION does not match its release manifest")
return self.install(source)
except Deferred as error:
return self.status("deferred", error)
except ContactError as error:
return self.status("deferred", error)
except (UpdateError, OSError, ValueError, KeyError) as error:
message = str(error) if isinstance(error, UpdateError) else "Agent update could not finish; inspect the updater service"
if UPDATE_JOURNAL.exists():
if recovering:
return self.status("failed", "Agent update recovery is pending; repair the updater service before accepting new operations")
try:
return self.rollback(self.read_journal(), message)
except Deferred as deferred:
return self.status("deferred", deferred)
except (UpdateError, OSError, ValueError, KeyError):
return self.status("failed", "Agent update recovery is pending; repair the updater service before accepting new operations")
if isinstance(error, IntegrityError) and self.target:
self.hold(self.target, message)
return self.status("failed", message)
def main():
if os.geteuid() != 0:
raise UpdateError("Run the agent updater through its installed root service")
Updater().run()
if __name__ == "__main__":
try:
main()
except (UpdateError, OSError, ValueError, KeyError):
print("Electric Cherry agent updater failed; inspect its installation and update status", file=sys.stderr)
sys.exit(1)
+146 -22
View File
@@ -8,6 +8,8 @@ controller=''
enrollment_token=''
data_dir='/var/lib/electric-cherry'
reenroll=''
existing_enrollment=false
existing_data_root=false
while [ "$#" -gt 0 ]; do
case "$1" in
--controller) controller="${2:?Missing controller URL}"; shift 2 ;;
@@ -31,7 +33,16 @@ command -v systemctl >/dev/null || { echo 'systemd is required.' >&2; exit 1; }
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y --no-install-recommends ca-certificates curl python3
apt-get install -y --no-install-recommends ca-certificates curl python3 util-linux
installation_preflight() {
if [ "$existing_enrollment" = true ] && [ ! -f /etc/electric-cherry/agent.json ]; then
echo 'The existing enrollment disappeared during installation. Stop and inspect host deletion before trying again.' >&2
return 1
fi
if [ "$existing_data_root" = true ] && [ ! -d "$data_dir" ]; then
echo 'The existing data directory disappeared during installation. Stop and inspect host deletion before trying again.' >&2
return 1
fi
python3 - "$controller" "$data_dir" /etc/electric-cherry/agent.json <<'PY'
# INSTALLATION_PREFLIGHT: this runs before touching managed directories or services.
import json, pathlib, re, stat, sys
@@ -51,9 +62,14 @@ if (not re.fullmatch(r'/[A-Za-z0-9/_.-]+', raw_root)
raise SystemExit('Choose a canonical, dedicated data directory outside the agent configuration directory.')
installed_paths = [config,
pathlib.Path('/usr/local/libexec/electric-cherry-agent.py'),
pathlib.Path('/usr/local/libexec/electric-cherry-agent.py.update'),
pathlib.Path('/usr/local/libexec/electric-cherry-uninstall.py'),
pathlib.Path('/usr/local/libexec/electric-cherry-uninstall.py.update'),
pathlib.Path('/usr/local/libexec/electric-cherry-update.py'),
pathlib.Path('/etc/systemd/system/electric-cherry-agent.service'),
pathlib.Path('/etc/systemd/system/electric-cherry-uninstall.service')]
pathlib.Path('/etc/systemd/system/electric-cherry-uninstall.service'),
pathlib.Path('/etc/systemd/system/electric-cherry-update.service'),
pathlib.Path('/etc/systemd/system/electric-cherry-update.timer')]
if any(root == path or root in path.parents for path in installed_paths):
raise SystemExit('Choose a dedicated data directory outside the agent executable and service directories.')
@@ -66,10 +82,13 @@ def trusted_directories(path):
if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
raise SystemExit('Installation directories and their parents must be root-owned, not symlinks, and not writable by other users.')
def trusted_json(path):
def trusted_file(path):
info = path.lstat()
if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
raise SystemExit('Existing installation metadata must be regular, root-owned files that other users cannot modify.')
def trusted_json(path):
trusted_file(path)
value = json.loads(path.read_text())
if not isinstance(value, dict):
raise SystemExit('Existing installation metadata is invalid.')
@@ -89,6 +108,13 @@ if mountinfo.exists():
request = config.with_name('decommission.json')
if request.exists() or request.is_symlink():
raise SystemExit('Host deletion is in progress. Finish or repair that deletion before installing again.')
trusted_directories(config.parent / 'update')
journal = config.parent / 'update' / 'journal.json'
if journal.exists() or journal.is_symlink():
raise SystemExit('An automatic update needs recovery. Let electric-cherry-update.service finish or repair that update before rerunning the installer.')
update_lock = config.with_name('update.lock')
if update_lock.exists() or update_lock.is_symlink():
trusted_file(update_lock)
saved = None
if config.exists() or config.is_symlink():
saved = trusted_json(config)
@@ -108,6 +134,62 @@ elif root.exists() and any(root.iterdir()):
raise SystemExit('The data directory contains files not owned by this installation. Choose an empty, dedicated directory.')
for directory in ('agent', 'instances', 'libraries'):
trusted_directories(root / directory)
activity_lock = root / 'agent' / 'activity.lock'
if activity_lock.exists() or activity_lock.is_symlink():
trusted_file(activity_lock)
PY
}
if [ -f /etc/electric-cherry/agent.json ]; then existing_enrollment=true; fi
if [ -d "$data_dir" ]; then existing_data_root=true; fi
installation_preflight
temporary_dir=$(mktemp -d)
updater_timer_was_active=false
update_lock_held=false
activity_lock_held=false
installer_finished=false
installation_cleanup() {
local ec_install_status=$?
trap - EXIT
if [ "$activity_lock_held" = true ]; then
flock --unlock 8 || true
exec 8>&-
fi
if [ "$update_lock_held" = true ]; then
flock --unlock 9 || true
exec 9>&-
fi
# Recovery must remain scheduled when an upgrade or its second preflight
# fails. Release the worker lock before restarting the prior active timer.
if [ "$installer_finished" != true ] && [ "$updater_timer_was_active" = true ]; then
systemctl start electric-cherry-update.timer >/dev/null 2>&1 || true
fi
rm -rf "$temporary_dir" || true
exit "$ec_install_status"
}
trap installation_cleanup EXIT
controller="${controller%/}"
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/manifest.json" -o "$temporary_dir/manifest.json"
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/ec-agent.py" -o "$temporary_dir/ec-agent.py"
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/ec-agent-update.py" -o "$temporary_dir/ec-agent-update.py"
python3 - "$temporary_dir/manifest.json" "$temporary_dir/ec-agent.py" "$temporary_dir/ec-agent-update.py" <<'PY'
# INSTALLATION_RELEASE: check both downloads before installing or executing either.
import ast, hashlib, json, pathlib, re, sys
manifest = json.loads(pathlib.Path(sys.argv[1]).read_text())
agent = pathlib.Path(sys.argv[2]).read_bytes()
updater = pathlib.Path(sys.argv[3]).read_bytes()
if hashlib.sha256(agent).hexdigest() != manifest.get('sha256'):
raise SystemExit('Agent release checksum verification failed.')
if hashlib.sha256(updater).hexdigest() != manifest.get('updaterSha256'):
raise SystemExit('Updater release checksum verification failed.')
if manifest.get('platform') != 'debian-amd64' or manifest.get('protocolVersion') != 1 or manifest.get('updaterProtocolVersion') != 1:
raise SystemExit('Unsupported agent or updater release manifest.')
versions = [node.value.value for node in ast.parse(agent).body
if isinstance(node, ast.Assign) and any(isinstance(target, ast.Name) and target.id == 'VERSION' for target in node.targets)
and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str)]
if len(versions) != 1 or versions[0] != manifest.get('version') or not re.fullmatch(r'(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)', versions[0]):
raise SystemExit('Agent release version does not match its manifest.')
compile(agent, 'ec-agent.py', 'exec')
compile(updater, 'ec-agent-update.py', 'exec')
PY
if ! command -v docker >/dev/null; then
if [ -e /var/lib/docker ] || [ -e /etc/docker/daemon.json ]; then
@@ -118,29 +200,37 @@ if ! command -v docker >/dev/null; then
systemctl enable --now docker
fi
docker info >/dev/null 2>&1 || { echo 'Docker is installed but unavailable. Start or repair it, then rerun.' >&2; exit 1; }
install -d -m 0755 "$data_dir" "$data_dir/libraries" "$data_dir/instances"
install -d -m 0700 "$data_dir/agent" /etc/electric-cherry
available_kb=$(df -Pk "$data_dir" | awk 'NR==2 {print $4}')
[ "$available_kb" -ge 1048576 ] || { echo 'At least 1 GiB of available game storage is required.' >&2; exit 1; }
temporary_dir=$(mktemp -d)
trap 'rm -rf "$temporary_dir"' EXIT
controller="${controller%/}"
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/manifest.json" -o "$temporary_dir/manifest.json"
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/ec-agent.py" -o "$temporary_dir/ec-agent.py"
python3 - "$temporary_dir/manifest.json" "$temporary_dir/ec-agent.py" <<'PY'
import hashlib, json, pathlib, sys
manifest = json.loads(pathlib.Path(sys.argv[1]).read_text())
actual = hashlib.sha256(pathlib.Path(sys.argv[2]).read_bytes()).hexdigest()
if actual != manifest.get('sha256'):
raise SystemExit('Agent release checksum verification failed.')
if manifest.get('platform') != 'debian-amd64' or manifest.get('protocolVersion') != 1:
raise SystemExit('Unsupported agent release manifest.')
PY
install -d -m 0700 /etc/electric-cherry
# Wait for an already-running updater to finish; never interrupt its rollback.
# The same lock also prevents a timer firing while files are being installed.
if systemctl cat electric-cherry-update.timer >/dev/null 2>&1; then
if systemctl is-active --quiet electric-cherry-update.timer; then
updater_timer_was_active=true
fi
systemctl stop electric-cherry-update.timer
fi
exec 9>/etc/electric-cherry/update.lock
flock --exclusive 9
update_lock_held=true
installation_preflight
# Serialize with running operations, especially a deletion request that could
# otherwise start between preflight and stopping the main agent.
if [ -d "$data_dir/agent" ]; then
exec 8>"$data_dir/agent/activity.lock"
flock --exclusive 8
activity_lock_held=true
installation_preflight
fi
# Stop only the agent while replacing its own executable/journal owner. Existing
# game containers keep running under Docker throughout installation and updates.
if systemctl cat electric-cherry-agent.service >/dev/null 2>&1; then
systemctl stop electric-cherry-agent.service
fi
installation_preflight
install -d -m 0755 "$data_dir" "$data_dir/libraries" "$data_dir/instances"
install -d -m 0700 "$data_dir/agent"
available_kb=$(df -Pk "$data_dir" | awk 'NR==2 {print $4}')
[ "$available_kb" -ge 1048576 ] || { echo 'At least 1 GiB of available game storage is required.' >&2; exit 1; }
install -m 0755 "$temporary_dir/ec-agent.py" /usr/local/libexec/electric-cherry-agent.py 2>/dev/null || {
install -d -m 0755 /usr/local/libexec
install -m 0755 "$temporary_dir/ec-agent.py" /usr/local/libexec/electric-cherry-agent.py
@@ -148,6 +238,7 @@ install -m 0755 "$temporary_dir/ec-agent.py" /usr/local/libexec/electric-cherry-
# A separate worker survives stopping/removing the agent service. Its only
# entry point is a fixed, authenticated deletion request prepared by the agent.
install -m 0755 "$temporary_dir/ec-agent.py" /usr/local/libexec/electric-cherry-uninstall.py
install -m 0755 "$temporary_dir/ec-agent-update.py" /usr/local/libexec/electric-cherry-update.py
printf '%s' "$enrollment_token" > "$temporary_dir/enrollment-token"
unset enrollment_token
python3 /usr/local/libexec/electric-cherry-agent.py --enroll --controller "$controller" --root "$data_dir" --token-file "$temporary_dir/enrollment-token" ${reenroll:+"$reenroll"}
@@ -215,8 +306,41 @@ UMask=0077
[Install]
WantedBy=multi-user.target
EOF
cat > /etc/systemd/system/electric-cherry-update.service <<'EOF'
[Unit]
Description=Update the Electric Cherry Host Agent
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /usr/local/libexec/electric-cherry-update.py
TimeoutStartSec=0
UMask=0077
EOF
cat > /etc/systemd/system/electric-cherry-update.timer <<'EOF'
[Unit]
Description=Check for Electric Cherry Host Agent Updates
[Timer]
OnBootSec=2min
OnUnitInactiveSec=5min
RandomizedDelaySec=30s
AccuracySec=1s
Unit=electric-cherry-update.service
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable electric-cherry-agent
if [ "$activity_lock_held" = true ]; then
flock --unlock 8
exec 8>&-
activity_lock_held=false
fi
systemctl restart electric-cherry-agent
systemctl is-active --quiet electric-cherry-agent || { echo 'Agent service did not start. Inspect journalctl -u electric-cherry-agent.' >&2; exit 1; }
echo 'Electric Cherry host agent installed and enrolled. Host readiness is shown in Hosts.'
systemctl enable --now electric-cherry-update.timer
installer_finished=true
echo 'Electric Cherry host agent installed and enrolled. Automatic agent updates are enabled; host readiness is shown in Hosts.'
+30 -6
View File
@@ -1,13 +1,37 @@
#!/usr/bin/env python3
"""Publish the reviewed local agent source into the controller static release."""
import ast
import hashlib
import json
from pathlib import Path
import re
import shutil
source = Path(__file__).resolve().parent
target = source.parent / "static" / "agent"
target.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source / "ec_agent.py", target / "ec-agent.py")
shutil.copyfile(source / "install.sh", target / "install.sh")
(target / "manifest.json").write_text(json.dumps({"version": "1.1.0", "protocolVersion": 1, "platform": "debian-amd64", "sha256": hashlib.sha256((target / "ec-agent.py").read_bytes()).hexdigest()}, indent=2) + "\n")
def package(source, target):
"""Read version metadata without importing or executing release code."""
source, target = Path(source), Path(target)
agent = (source / "ec_agent.py").read_bytes()
updater = (source / "ec_agent_update.py").read_bytes()
versions = [node.value.value for node in ast.parse(agent).body
if isinstance(node, ast.Assign) and any(isinstance(name, ast.Name) and name.id == "VERSION" for name in node.targets)
and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str)]
if len(versions) != 1 or not re.fullmatch(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)", versions[0]):
raise ValueError("Agent source must declare exactly one literal semantic VERSION")
compile(agent, "ec_agent.py", "exec")
compile(updater, "ec_agent_update.py", "exec")
manifest = {"version": versions[0], "protocolVersion": 1, "platform": "debian-amd64",
"sha256": hashlib.sha256(agent).hexdigest(), "updaterProtocolVersion": 1,
"updaterSha256": hashlib.sha256(updater).hexdigest()}
target.mkdir(parents=True, exist_ok=True)
(target / "ec-agent.py").write_bytes(agent)
(target / "ec-agent-update.py").write_bytes(updater)
shutil.copyfile(source / "install.sh", target / "install.sh")
temporary_manifest = target / ".manifest.json.tmp"
temporary_manifest.write_text(json.dumps(manifest, indent=2) + "\n")
temporary_manifest.replace(target / "manifest.json")
if __name__ == "__main__":
source = Path(__file__).resolve().parent
package(source, source.parent / "static" / "agent")
+169
View File
@@ -1,10 +1,13 @@
"""Exercise installer guards without installing software or touching services."""
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import re
import stat
import subprocess
import sys
import tempfile
import unittest
@@ -15,6 +18,11 @@ SOURCE = (Path(__file__).resolve().parents[1] / "install.sh").read_text()
BLOCKS = re.findall(r"<<'PY'\n(.*?)\nPY", SOURCE, re.DOTALL)
PREFLIGHT = next(block for block in BLOCKS if "# INSTALLATION_PREFLIGHT:" in block)
OWNERSHIP = next(block for block in BLOCKS if "# INSTALLATION_OWNERSHIP:" in block)
RELEASE = next(block for block in BLOCKS if "# INSTALLATION_RELEASE:" in block)
CLEANUP = re.search(r"installation_cleanup\(\) \{.*?\n\}", SOURCE, re.DOTALL).group()
SPEC = importlib.util.spec_from_file_location("package_release", Path(__file__).resolve().parents[1] / "package_release.py")
PACKAGER = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(PACKAGER)
class InstallerTests(unittest.TestCase):
@@ -163,6 +171,47 @@ class InstallerTests(unittest.TestCase):
with self.assertRaisesRegex(SystemExit, "deletion is in progress"):
self.preflight()
def test_pending_update_recovery_is_preserved_and_blocks_manual_replacement(self):
self.legacy_installation()
update = self.config.parent / "update"
update.mkdir()
journal = update / "journal.json"
saved = '{"phase":"swapping","targetVersion":"1.2.0"}'
journal.write_text(saved)
with self.assertRaisesRegex(SystemExit, "automatic update needs recovery"):
self.preflight()
self.assertEqual(journal.read_text(), saved)
journal.unlink()
journal.symlink_to(self.base / "missing")
with self.assertRaisesRegex(SystemExit, "automatic update needs recovery"):
self.preflight()
def test_update_directory_and_lock_cannot_follow_symlinks(self):
self.legacy_installation()
elsewhere = self.base / "unrelated"
elsewhere.mkdir()
update = self.config.parent / "update"
update.symlink_to(elsewhere, target_is_directory=True)
with self.assertRaisesRegex(SystemExit, "not symlinks"):
self.preflight()
update.unlink()
lock = self.config.with_name("update.lock")
lock.symlink_to(elsewhere / "sentinel")
with self.assertRaisesRegex(SystemExit, "regular, root-owned"):
self.preflight()
self.assertFalse((elsewhere / "sentinel").exists())
lock.unlink()
activity = self.root / "agent" / "activity.lock"
activity.symlink_to(elsewhere / "activity-sentinel")
with self.assertRaisesRegex(SystemExit, "regular, root-owned"):
self.preflight()
self.assertFalse((elsewhere / "activity-sentinel").exists())
def test_data_root_cannot_contain_the_updater_or_service_installation(self):
for path in ("/etc/systemd", "/etc/systemd/system", "/usr/local/libexec/electric-cherry-update.py", "/usr/local/libexec/electric-cherry-agent.py.update", "/usr/local/libexec/electric-cherry-uninstall.py.update"):
with self.subTest(path=path), self.assertRaisesRegex(SystemExit, "outside the agent executable and service"):
self.preflight(path)
def test_mounted_parent_is_allowed_but_data_root_and_nested_mounts_are_rejected(self):
mountinfo = Path('/proc/1/mountinfo')
original_exists, original_read = Path.exists, Path.read_text
@@ -181,5 +230,125 @@ class InstallerTests(unittest.TestCase):
self.preflight()
class ReleaseTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.source = Path(self.temporary.name) / "source"
self.target = Path(self.temporary.name) / "release"
self.source.mkdir()
(self.source / "ec_agent.py").write_text('VERSION = "3.12.7"\nraise RuntimeError("must never execute during packaging")\n')
(self.source / "ec_agent_update.py").write_text('raise RuntimeError("must never execute during verification")\n')
(self.source / "install.sh").write_text("#!/bin/bash\n")
PACKAGER.package(self.source, self.target)
def tearDown(self):
self.temporary.cleanup()
def verify(self):
argv = ["-", str(self.target / "manifest.json"), str(self.target / "ec-agent.py"), str(self.target / "ec-agent-update.py")]
with patch.object(sys, "argv", argv):
exec(compile(RELEASE, "installer-release", "exec"), {})
def manifest(self, **changes):
path = self.target / "manifest.json"
value = json.loads(path.read_text())
value.update(changes)
path.write_text(json.dumps(value))
return value
def test_package_derives_version_and_hashes_both_sources_without_executing_them(self):
manifest = self.manifest()
self.assertEqual(manifest["version"], "3.12.7")
self.assertEqual(manifest["updaterProtocolVersion"], 1)
self.assertEqual(manifest["sha256"], hashlib.sha256((self.source / "ec_agent.py").read_bytes()).hexdigest())
self.assertEqual(manifest["updaterSha256"], hashlib.sha256((self.source / "ec_agent_update.py").read_bytes()).hexdigest())
self.assertEqual((self.target / "ec-agent-update.py").read_bytes(), (self.source / "ec_agent_update.py").read_bytes())
self.verify()
def test_changed_agent_and_updater_downloads_are_rejected(self):
for filename, error in (("ec-agent.py", "Agent release checksum"), ("ec-agent-update.py", "Updater release checksum")):
with self.subTest(filename=filename):
path = self.target / filename
original = path.read_bytes()
path.write_bytes(original + b"# changed\n")
with self.assertRaisesRegex(SystemExit, error):
self.verify()
path.write_bytes(original)
def test_installer_rejects_missing_or_unsupported_updater_protocol(self):
for protocol in (None, 0, 2, "1"):
self.manifest(updaterProtocolVersion=protocol)
with self.subTest(protocol=protocol), self.assertRaisesRegex(SystemExit, "Unsupported agent or updater"):
self.verify()
def test_installer_rejects_manifest_version_mismatch_and_invalid_code(self):
self.manifest(version="3.12.8")
with self.assertRaisesRegex(SystemExit, "version does not match"):
self.verify()
malformed = b"def malformed(:\n"
(self.target / "ec-agent-update.py").write_bytes(malformed)
self.manifest(version="3.12.7", updaterSha256=hashlib.sha256(malformed).hexdigest())
with self.assertRaises(SyntaxError):
self.verify()
def test_packaging_requires_one_literal_semantic_agent_version(self):
for source in ('VERSION = "bad"', 'VERSION = "01.2.3"', 'VERSION = str(3)', 'VERSION = "1.2.3"\nVERSION = "1.2.4"'):
(self.source / "ec_agent.py").write_text(source)
with self.subTest(source=source), self.assertRaisesRegex(ValueError, "literal semantic VERSION"):
PACKAGER.package(self.source, self.target)
def test_installer_serializes_changes_and_enables_update_timer_only_after_agent_starts(self):
locked = SOURCE.index("flock --exclusive 9")
self.assertLess(SOURCE.index("systemctl stop electric-cherry-update.timer"), locked)
self.assertLess(locked, SOURCE.index("installation_preflight", locked))
self.assertLess(SOURCE.index("installation_preflight", locked), SOURCE.index('install -m 0755 "$temporary_dir/ec-agent.py"'))
self.assertLess(locked, SOURCE.index("flock --exclusive 8"))
self.assertLess(SOURCE.index("flock --exclusive 8"), SOURCE.index("systemctl stop electric-cherry-agent.service"))
self.assertLess(SOURCE.index("flock --unlock 8", SOURCE.index("systemctl daemon-reload")), SOURCE.index("systemctl restart electric-cherry-agent"))
self.assertLess(SOURCE.index("systemctl is-active --quiet electric-cherry-agent"), SOURCE.index("systemctl enable --now electric-cherry-update.timer"))
self.assertIn("ExecStart=/usr/bin/python3 /usr/local/libexec/electric-cherry-update.py", SOURCE)
self.assertIn("OnUnitInactiveSec=5min", SOURCE)
self.assertIn("RandomizedDelaySec=30s", SOURCE)
self.assertIn("AccuracySec=1s", SOURCE)
self.assertNotIn("Persistent=true", SOURCE)
def test_failed_install_restores_only_previously_active_timer_after_releasing_locks(self):
for previous, finished, code in ((True, False, 37), (False, False, 37), (True, True, 0)):
with self.subTest(previous=previous, finished=finished):
scratch = Path(self.temporary.name) / "scratch"
scratch.mkdir(exist_ok=True)
log = Path(self.temporary.name) / "cleanup.log"
log.unlink(missing_ok=True)
script = CLEANUP + r'''
flock() { printf 'unlock:%s\n' "$2" >> "$EC_TEST_LOG"; }
systemctl() {
if { : >&8; } 2>/dev/null || { : >&9; } 2>/dev/null; then
printf 'lock-still-open\n' >> "$EC_TEST_LOG"
return 1
fi
printf '%s\n' "$*" >> "$EC_TEST_LOG"
}
temporary_dir="$EC_TEST_TEMP"
updater_timer_was_active="$EC_TEST_PREVIOUS"
installer_finished="$EC_TEST_FINISHED"
update_lock_held=true
activity_lock_held=true
exec 8>"$EC_TEST_LOCK"
exec 9>"$EC_TEST_LOCK"
trap installation_cleanup EXIT
exit "$EC_TEST_EXIT"
'''
environment = {**os.environ, "EC_TEST_LOG": str(log), "EC_TEST_TEMP": str(scratch),
"EC_TEST_PREVIOUS": str(previous).lower(), "EC_TEST_FINISHED": str(finished).lower(),
"EC_TEST_LOCK": str(Path(self.temporary.name) / "test.lock"), "EC_TEST_EXIT": str(code)}
result = subprocess.run(["bash", "-c", script], env=environment, capture_output=True)
self.assertEqual(result.returncode, code, result.stderr)
expected = ["unlock:8", "unlock:9"]
if previous and not finished:
expected.append("start electric-cherry-update.timer")
self.assertEqual(log.read_text().splitlines(), expected)
self.assertFalse(scratch.exists())
if __name__ == "__main__":
unittest.main()
+13 -1
View File
@@ -57,6 +57,9 @@ class UninstallTests(unittest.TestCase):
"UNINSTALL_EXECUTABLE": self.bin_dir / "electric-cherry-uninstall.py",
"UNINSTALL_UNIT": self.unit_dir / "electric-cherry-uninstall.service",
"UNINSTALL_REQUEST": self.config_dir / "decommission.json",
"UPDATE_EXECUTABLE": self.bin_dir / "electric-cherry-update.py",
"UPDATE_UNIT": self.unit_dir / "electric-cherry-update.service",
"UPDATE_TIMER": self.unit_dir / "electric-cherry-update.timer",
}
self.patches = [patch.object(module, key, value) for key, value in replacements.items()]
# Unit tests run without root privileges. Keep actual path, symlink,
@@ -74,8 +77,10 @@ class UninstallTests(unittest.TestCase):
self.config = {"hostId": "host-one", "controller": "https://controller.example", "credential": "secret", "root": str(self.root), "configPath": str(module.AGENT_CONFIG)}
self.agent = module.Agent(self.config)
self.docker = CleanupDocker()
for path in (module.AGENT_EXECUTABLE, module.AGENT_UNIT, module.UNINSTALL_EXECUTABLE, module.UNINSTALL_UNIT):
for path in (module.AGENT_EXECUTABLE, module.AGENT_UNIT, module.UNINSTALL_EXECUTABLE, module.UNINSTALL_UNIT, module.UPDATE_EXECUTABLE, module.UPDATE_UNIT, module.UPDATE_TIMER):
path.write_text("installed")
for path in (module.AGENT_EXECUTABLE, module.UNINSTALL_EXECUTABLE):
path.with_suffix(".py.update").write_text("interrupted update")
module.atomic_json(module.AGENT_CONFIG, self.config)
module.atomic_json(self.root / module.ROOT_MARKER, {"version": 1, "root": str(self.root), "hostId": "host-one", "controller": "https://controller.example"})
self.game = self.root / "libraries" / "t6" / "library" / "content" / "zone" / "all" / "dlc.ff"
@@ -108,6 +113,11 @@ class UninstallTests(unittest.TestCase):
self.assertFalse(module.AGENT_CONFIG.exists())
self.assertFalse(module.AGENT_EXECUTABLE.exists())
self.assertFalse(module.AGENT_UNIT.exists())
self.assertFalse(module.UPDATE_EXECUTABLE.exists())
self.assertFalse(module.UPDATE_UNIT.exists())
self.assertFalse(module.UPDATE_TIMER.exists())
self.assertFalse(module.AGENT_EXECUTABLE.with_suffix(".py.update").exists())
self.assertFalse(module.UNINSTALL_EXECUTABLE.with_suffix(".py.update").exists())
if stage == "cleaned":
self.assertTrue(module.UNINSTALL_REQUEST.exists())
self.assertTrue(module.UNINSTALL_EXECUTABLE.exists())
@@ -120,6 +130,8 @@ class UninstallTests(unittest.TestCase):
self.assertEqual(stages, ["cleaned", "completed"])
self.assertEqual((self.base / "unrelated.txt").read_text(), "keep")
module.systemctl.assert_any_call("disable", "--now", "electric-cherry-agent.service")
module.systemctl.assert_any_call("disable", "--now", "electric-cherry-update.timer")
module.systemctl.assert_any_call("stop", "electric-cherry-update.service")
module.systemctl.assert_any_call("disable", "electric-cherry-uninstall.service")
def test_handoff_is_durable_and_never_reports_operation_completed(self):
+137
View File
@@ -0,0 +1,137 @@
import fcntl
import importlib.util
import json
import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
spec = importlib.util.spec_from_file_location("ec_update_integration", Path(__file__).resolve().parents[1] / "ec_agent.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
class UpdateIntegrationTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.base = Path(self.temporary.name)
self.config_dir = self.base / "config"
self.config_dir.mkdir()
replacements = {
"AGENT_CONFIG": self.config_dir / "agent.json",
"UPDATE_JOURNAL": self.config_dir / "update" / "journal.json",
"UPDATE_STATUS": self.config_dir / "update-status.json",
"AGENT_HEALTH": self.config_dir / "agent-health.json",
}
self.patches = [patch.object(module, key, value) for key, value in replacements.items()]
self.patches.append(patch.object(module, "protected_file", side_effect=lambda path, **_: path))
for entry in self.patches:
entry.start()
self.agent = module.Agent({"root": str(self.base / "data"), "configPath": str(module.AGENT_CONFIG), "controller": "https://controller.example", "hostId": "one", "credential": "secret"})
self.agent.can_update = lambda: True
self.agent.inventory = lambda: {"agentVersion": module.VERSION}
def tearDown(self):
self.agent.journal.close()
for entry in reversed(self.patches):
entry.stop()
self.temporary.cleanup()
def assert_activity_held(self):
with (self.agent.root / "agent" / "activity.lock").open("a") as stream:
with self.assertRaises(BlockingIOError):
fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB)
def test_operation_delivery_execution_and_reports_exclude_updates(self):
operation = {"id": "one", "kind": "host.inspect", "payload": {}}
calls = []
def request(method, path, body=None):
self.assert_activity_held()
calls.append((method, path))
return {"operations": [operation]} if method == "GET" else {}
def dispatch(value):
self.assert_activity_held()
return {"ok": True}
self.agent.request = request
self.agent.dispatch = dispatch
self.agent.poll_operations()
self.assertEqual(self.agent.journal.get("one")["state"], "completed")
self.assertEqual(len(calls), 4)
self.assertIsNone(self.agent.current_operation)
with self.agent.activity() as acquired:
self.assertTrue(acquired)
def test_updater_lock_defers_delivery_without_blocking_health(self):
self.agent.request = lambda method, path, body=None: {"host": {}}
with self.agent.activity() as acquired:
self.assertTrue(acquired)
with patch.object(self.agent, "request", wraps=self.agent.request) as request:
self.agent.poll_operations()
request.assert_not_called()
self.agent.heartbeat()
self.assertTrue(module.AGENT_HEALTH.exists())
def test_journal_blocks_operations_during_verification_and_recovery(self):
module.UPDATE_JOURNAL.parent.mkdir()
for content in ('{"id":"candidate","phase":"verifying"}', '{"id":"candidate","phase":"rollback"}', '{broken'):
module.UPDATE_JOURNAL.write_text(content)
with patch.object(self.agent, "request") as request:
self.agent.poll_operations()
request.assert_not_called()
module.UPDATE_JOURNAL.unlink()
with patch.object(self.agent, "request", return_value={"operations": []}) as request:
self.agent.poll_operations()
request.assert_called_once_with("GET", "/api/agent/operations")
def test_failed_controller_contact_never_writes_healthy_receipt(self):
with patch.object(self.agent, "request", side_effect=module.AgentError("offline")):
with self.assertRaises(module.AgentError):
self.agent.heartbeat()
self.assertFalse(module.AGENT_HEALTH.exists())
def test_health_proves_current_process_version_and_transaction(self):
module.atomic_json(module.UPDATE_JOURNAL, {"id": "candidate-id", "phase": "verifying"})
with patch.object(self.agent, "request", return_value={"host": {}}), patch.object(module.time, "time", return_value=12345.5):
self.agent.heartbeat()
self.assertEqual(json.loads(module.AGENT_HEALTH.read_text()), {"version": module.VERSION, "pid": os.getpid(), "checkedAt": 12345.5, "updateId": "candidate-id"})
module.UPDATE_JOURNAL.unlink()
self.agent.record_health()
self.assertNotIn("updateId", json.loads(module.AGENT_HEALTH.read_text()))
def test_real_agent_health_receipt_is_accepted_by_the_independent_updater(self):
updater_spec = importlib.util.spec_from_file_location("ec_update_contract", Path(__file__).resolve().parents[1] / "ec_agent_update.py")
updater_module = importlib.util.module_from_spec(updater_spec)
updater_spec.loader.exec_module(updater_module)
journal = {"id": "candidate-id", "phase": "verifying", "targetVersion": module.VERSION, "switchedAt": module.time.time() - 1}
module.atomic_json(module.UPDATE_JOURNAL, journal)
with patch.object(self.agent, "request", return_value={"host": {}}):
self.agent.heartbeat()
updater = updater_module.Updater()
with patch.object(updater, "check_uninstall"), patch.object(updater_module, "AGENT_HEALTH", module.AGENT_HEALTH), patch.object(updater_module, "trusted", side_effect=lambda path, **_: path), patch.object(updater_module, "service_state", return_value=(True, os.getpid())):
self.assertTrue(updater.wait_healthy(journal))
def test_operation_exception_releases_update_lock(self):
with patch.object(self.agent, "request", side_effect=module.AgentError("offline")):
with self.assertRaises(module.AgentError):
self.agent.poll_operations()
with self.agent.activity() as acquired:
self.assertTrue(acquired)
def test_update_inventory_excludes_local_recovery_and_secret_fields(self):
module.atomic_json(module.UPDATE_STATUS, {"state": "failed", "targetVersion": "1.3.0", "currentVersion": "untrusted", "error": "Restored previous agent", "checkedAt": 1700000000, "credential": "secret", "journal": {"private": True}, "failedSha256": "digest"})
self.assertEqual(self.agent.update_inventory(), {"enabled": True, "state": "failed", "currentVersion": module.VERSION, "targetVersion": "1.3.0", "error": "Restored previous agent", "checkedAt": "2023-11-14T22:13:20Z"})
def test_malformed_update_status_is_visible_without_breaking_inventory(self):
module.UPDATE_STATUS.write_text("[]")
self.assertEqual(self.agent.update_inventory()["state"], "failed")
def test_shutdown_never_polls_work(self):
self.agent.stopping.set()
with patch.object(self.agent, "request") as request:
self.agent.poll_operations()
request.assert_not_called()
if __name__ == "__main__":
unittest.main()
+418
View File
@@ -0,0 +1,418 @@
import contextlib
import fcntl
import importlib.util
import io
import json
import os
from pathlib import Path
import stat
import tempfile
import time
import unittest
from unittest.mock import patch
spec = importlib.util.spec_from_file_location("ec_agent_update", Path(__file__).resolve().parents[1] / "ec_agent_update.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
PREVIOUS = b'VERSION = "1.0.0"\n# previous agent\n'
CANDIDATE = b'VERSION = "1.1.0"\n# next agent\n'
class PowerLoss(BaseException):
pass
class Response(io.BytesIO):
def __init__(self, value, headers=None):
super().__init__(value)
self.headers = headers or {}
self.status = 200
class UpdateTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.base = Path(self.temp.name).resolve()
self.config_dir = self.base / "etc" / "electric-cherry"
self.bin_dir = self.base / "libexec"
self.root = self.base / "data"
for directory in (self.config_dir, self.bin_dir, self.root / "agent"):
directory.mkdir(parents=True)
paths = {
"AGENT_CONFIG": self.config_dir / "agent.json",
"AGENT_EXECUTABLE": self.bin_dir / "electric-cherry-agent.py",
"UNINSTALL_EXECUTABLE": self.bin_dir / "electric-cherry-uninstall.py",
"UNINSTALL_REQUEST": self.config_dir / "decommission.json",
"UPDATE_LOCK": self.config_dir / "update.lock",
"UPDATE_DIR": self.config_dir / "update",
"UPDATE_JOURNAL": self.config_dir / "update" / "journal.json",
"UPDATE_STATUS": self.config_dir / "update-status.json",
"AGENT_HEALTH": self.config_dir / "agent-health.json",
}
self.patches = [patch.object(module, name, value) for name, value in paths.items()]
def trusted_for_test(value, missing=False, directory=False):
path = Path(value)
if any(item.is_symlink() for item in (path, *path.parents)):
raise module.UpdateError("Unsafe symbolic link")
if not path.exists():
if missing:
return path
raise module.UpdateError("File unavailable")
if directory and not path.is_dir() or not directory and not path.is_file():
raise module.UpdateError("Wrong file type")
return path
self.patches.extend([patch.object(module, "trusted", side_effect=trusted_for_test), patch.object(module, "sync_parent"), patch.object(module, "systemctl", side_effect=self.systemctl)])
for item in self.patches:
item.start()
self.config = {"hostId": "host-one", "root": str(self.root), "controller": "https://controller.example", "credential": "secret-enrollment-credential"}
self.config_bytes = json.dumps(self.config).encode()
module.AGENT_CONFIG.write_bytes(self.config_bytes)
(self.root / ".electric-cherry.json").write_text(json.dumps({"version": 1, "root": str(self.root), "hostId": "host-one", "controller": "https://controller.example"}))
module.AGENT_EXECUTABLE.write_bytes(PREVIOUS)
module.UNINSTALL_EXECUTABLE.write_bytes(PREVIOUS)
(self.root / "games.ff").write_bytes(b"large game files unchanged")
self.commands = []
self.active = True
self.pid = 123
self.write_health = True
self.downloads = []
self.source = CANDIDATE
self.release = {"version": "1.1.0", "sha256": module.sha256(CANDIDATE), "protocolVersion": 1, "updaterProtocolVersion": 1, "platform": "debian-amd64"}
def tearDown(self):
for item in reversed(self.patches):
item.stop()
self.temp.cleanup()
def systemctl(self, *args):
self.commands.append(args)
if args == ("stop", module.AGENT_SERVICE):
self.active = False
return ""
if args == ("start", module.AGENT_SERVICE):
# The candidate must be able to take the operation lock at startup.
with module.exclusive_lock(self.root / "agent" / "activity.lock") as activity:
self.assertIsNotNone(activity, "Updater held the operation lock while starting the agent")
self.active = True
self.pid += 1
if self.write_health and module.UPDATE_JOURNAL.exists():
journal = json.loads(module.UPDATE_JOURNAL.read_text())
module.AGENT_HEALTH.write_text(json.dumps({"version": module.source_version(module.AGENT_EXECUTABLE.read_bytes()), "pid": self.pid, "checkedAt": time.time(), "updateId": journal["id"]}))
return ""
if args == ("show", module.AGENT_SERVICE, "--property=ActiveState", "--property=MainPID"):
return "ActiveState=" + ("active" if self.active else "inactive") + "\nMainPID=" + str(self.pid if self.active else 0) + "\n"
self.fail("Unexpected service command " + str(args))
def fetch(self, path, limit):
self.downloads.append(path)
if path.endswith("manifest.json"):
return json.dumps(self.release).encode()
if path.endswith("ec-agent.py"):
return self.source
self.fail("Unexpected download " + path)
def updater(self):
updater = module.Updater()
updater.fetch = self.fetch
return updater
def status(self):
return json.loads(module.UPDATE_STATUS.read_text())
def assert_unchanged(self):
self.assertEqual(module.AGENT_EXECUTABLE.read_bytes(), PREVIOUS)
self.assertEqual(module.UNINSTALL_EXECUTABLE.read_bytes(), PREVIOUS)
self.assertEqual(module.AGENT_CONFIG.read_bytes(), self.config_bytes)
self.assertEqual((self.root / "games.ff").read_bytes(), b"large game files unchanged")
def test_verified_upgrade_waits_for_authenticated_candidate_health(self):
self.assertEqual(self.updater().run(), "updated")
self.assertEqual(module.AGENT_EXECUTABLE.read_bytes(), CANDIDATE)
self.assertEqual(module.UNINSTALL_EXECUTABLE.read_bytes(), CANDIDATE)
self.assertEqual(module.AGENT_CONFIG.read_bytes(), self.config_bytes)
self.assertEqual((self.root / "games.ff").read_bytes(), b"large game files unchanged")
self.assertEqual(self.status()["currentVersion"], "1.1.0")
self.assertFalse(module.UPDATE_JOURNAL.exists())
self.assertFalse((module.UPDATE_DIR / "agent.backup.py").exists())
self.assertEqual(next(command for command in self.commands if command[0] != "show"), ("stop", module.AGENT_SERVICE))
def test_equal_or_older_versions_do_not_download_or_restart(self):
for target in ("1.0.0", "0.9.9"):
with self.subTest(target=target):
self.release["version"] = target
self.assertEqual(self.updater().run(), "current")
self.assert_unchanged()
self.assertFalse(any(path.endswith("ec-agent.py") for path in self.downloads))
self.assertEqual(self.commands, [])
def test_busy_operation_defers_update_without_stopping_agent(self):
with module.exclusive_lock(self.root / "agent" / "activity.lock"):
self.assertEqual(self.updater().run(), "deferred")
self.assertIn("executing an operation", self.status()["error"])
self.assert_unchanged()
self.assertEqual(self.commands, [])
self.assertFalse(module.UPDATE_JOURNAL.exists())
def test_deliberately_stopped_agent_is_not_updated_or_started(self):
self.active = False
self.assertEqual(self.updater().run(), "deferred")
self.assertIn("Agent service is not running", self.status()["error"])
self.assert_unchanged()
self.assertFalse(module.UPDATE_JOURNAL.exists())
self.assertFalse(self.active)
self.assertFalse(any(command[0] in ("start", "stop") for command in self.commands))
def test_concurrent_worker_does_not_overwrite_active_worker_status(self):
module.UPDATE_STATUS.write_text('{"state":"updating"}')
with module.exclusive_lock(module.UPDATE_LOCK):
self.assertEqual(self.updater().run(), "deferred")
self.assertEqual(self.status(), {"state": "updating"})
self.assertEqual(self.downloads, [])
def test_uninstall_request_prevents_any_download_or_mutation(self):
module.UNINSTALL_REQUEST.write_text("{}")
self.assertEqual(self.updater().run(), "deferred")
self.assertIn("deletion", self.status()["error"])
self.assertEqual(self.downloads, [])
self.assertEqual(self.commands, [])
self.assert_unchanged()
def test_uninstall_starting_during_download_wins_before_code_changes(self):
updater = self.updater()
def fetch(path, limit):
data = self.fetch(path, limit)
if path.endswith("ec-agent.py"):
module.UNINSTALL_REQUEST.write_text("{}")
return data
updater.fetch = fetch
self.assertEqual(updater.run(), "deferred")
self.assert_unchanged()
self.assertEqual(self.commands, [])
def test_controller_unavailable_keeps_running_agent(self):
updater = self.updater()
updater.fetch = lambda *_: (_ for _ in ()).throw(module.ContactError("Controller unavailable"))
self.assertEqual(updater.run(), "deferred")
self.assert_unchanged()
self.assertTrue(self.active)
self.assertEqual(self.commands, [])
def test_checksum_failure_holds_artifact_without_executing_it(self):
self.source = CANDIDATE + b"# changed in transit"
self.assertEqual(self.updater().run(), "failed")
self.assertIn("checksum", self.status()["error"])
self.assert_unchanged()
self.assertEqual(self.commands, [])
self.downloads.clear()
self.source = CANDIDATE
self.assertEqual(self.updater().run(), "failed")
self.assertEqual(self.downloads, ["/api/agent/release/manifest.json"])
def test_invalid_or_mismatched_python_version_is_never_installed(self):
for source in (b'VERSION = "1.1.0"\ninvalid syntax ?\n', b'VERSION = "5.0.0"\n'):
with self.subTest(source=source):
self.source = source
self.release["sha256"] = module.sha256(source)
self.assertEqual(self.updater().run(), "failed")
self.assert_unchanged()
self.assertEqual(self.commands, [])
def test_incompatible_protocol_platform_or_version_never_downloads_code(self):
original = dict(self.release)
for key, value in (("protocolVersion", 2), ("protocolVersion", True), ("platform", "linux-arm64"), ("version", "1.1.0-rc1"), ("version", "01.1.0"), ("updaterProtocolVersion", 2)):
with self.subTest(key=key, value=value):
self.release = {**original, key: value}
self.assertIn(self.updater().run(), ("failed", "deferred"))
self.assert_unchanged()
self.assertFalse(any(path.endswith("ec-agent.py") for path in self.downloads))
self.assertEqual(self.commands, [])
def test_failed_health_rolls_back_both_files_then_holds_release(self):
updater = self.updater()
updater.wait_healthy = lambda journal: journal["targetVersion"] == "1.0.0"
self.assertEqual(updater.run(), "failed")
self.assert_unchanged()
self.assertTrue(self.active)
self.assertFalse(module.UPDATE_JOURNAL.exists())
self.assertEqual(self.status()["currentVersion"], "1.0.0")
self.assertEqual(sum(command == ("start", module.AGENT_SERVICE) for command in self.commands), 2)
self.downloads.clear()
self.assertEqual(self.updater().run(), "failed")
self.assertEqual(self.downloads, ["/api/agent/release/manifest.json"])
def test_changed_artifact_of_failed_target_can_be_retried(self):
updater = self.updater()
updater.wait_healthy = lambda journal: journal["targetVersion"] == "1.0.0"
self.assertEqual(updater.run(), "failed")
self.source = CANDIDATE + b"# corrected release\n"
self.release["sha256"] = module.sha256(self.source)
self.assertEqual(self.updater().run(), "updated")
self.assertEqual(module.AGENT_EXECUTABLE.read_bytes(), self.source)
def crash_during_second_replacement(self):
original = module.atomic_bytes
def interrupt(path, value, mode=0o600):
if Path(path) == module.UNINSTALL_EXECUTABLE and value == CANDIDATE:
raise PowerLoss()
return original(path, value, mode)
with patch.object(module, "atomic_bytes", side_effect=interrupt):
with self.assertRaises(PowerLoss):
self.updater().run()
self.assertEqual(module.AGENT_EXECUTABLE.read_bytes(), CANDIDATE)
self.assertEqual(module.UNINSTALL_EXECUTABLE.read_bytes(), PREVIOUS)
self.assertTrue(module.UPDATE_JOURNAL.exists())
def test_crash_between_executable_replacements_recovers_from_backups(self):
self.crash_during_second_replacement()
self.downloads.clear()
self.assertEqual(self.updater().run(), "failed")
self.assert_unchanged()
self.assertEqual(self.downloads, [])
self.assertTrue(self.active)
self.assertFalse(module.UPDATE_JOURNAL.exists())
def test_crash_after_verification_still_recovers_conservatively(self):
updater = self.updater()
updater.finish_journal = lambda: (_ for _ in ()).throw(PowerLoss())
with self.assertRaises(PowerLoss):
updater.run()
self.assertEqual(json.loads(module.UPDATE_JOURNAL.read_text())["phase"], "verifying")
self.assertEqual(self.updater().run(), "failed")
self.assert_unchanged()
self.assertFalse(module.UPDATE_JOURNAL.exists())
def test_corrupt_backup_keeps_journal_and_never_overwrites_code(self):
self.crash_during_second_replacement()
(module.UPDATE_DIR / "agent.backup.py").write_bytes(b"corrupt")
before = len(self.commands)
self.assertEqual(self.updater().run(), "failed")
self.assertTrue(module.UPDATE_JOURNAL.exists())
self.assertEqual(module.AGENT_EXECUTABLE.read_bytes(), CANDIDATE)
self.assertEqual(len(self.commands), before)
def test_changed_enrollment_prevents_update_and_rollback(self):
self.crash_during_second_replacement()
changed = {**self.config, "hostId": "different-host"}
module.AGENT_CONFIG.write_text(json.dumps(changed))
(self.root / ".electric-cherry.json").write_text(json.dumps({"version": 1, "root": str(self.root), "hostId": "different-host", "controller": "https://controller.example"}))
before = len(self.commands)
self.assertEqual(self.updater().run(), "failed")
self.assertTrue(module.UPDATE_JOURNAL.exists())
self.assertEqual(module.AGENT_EXECUTABLE.read_bytes(), CANDIDATE)
self.assertEqual(len(self.commands), before)
def test_rollback_busy_lock_keeps_recovery_journal_for_next_tick(self):
self.crash_during_second_replacement()
with module.exclusive_lock(self.root / "agent" / "activity.lock"):
self.assertEqual(self.updater().run(), "deferred")
self.assertEqual(json.loads(module.UPDATE_JOURNAL.read_text())["phase"], "rollback")
self.assertEqual(self.updater().run(), "failed")
self.assert_unchanged()
def test_unconfirmed_rollback_health_retains_backups_and_journal(self):
updater = self.updater()
updater.wait_healthy = lambda _: False
self.assertEqual(updater.run(), "failed")
self.assert_unchanged()
self.assertEqual(json.loads(module.UPDATE_JOURNAL.read_text())["phase"], "rollback")
self.assertTrue((module.UPDATE_DIR / "agent.backup.py").exists())
self.assertIn("recovery is pending", self.status()["error"])
# A subsequent worker can safely confirm the restored release.
self.assertEqual(self.updater().run(), "failed")
self.assertFalse(module.UPDATE_JOURNAL.exists())
def test_stale_wrong_pid_wrong_release_or_wrong_update_health_is_rejected(self):
updater = self.updater()
journal = {"id": "this-update", "targetVersion": "1.1.0", "switchedAt": time.time()}
valid = {"version": "1.1.0", "pid": self.pid, "checkedAt": time.time(), "updateId": "this-update"}
for key, value in (("pid", self.pid + 1), ("version", "1.0.0"), ("updateId", "previous-update"), ("checkedAt", journal["switchedAt"] - 1), ("checkedAt", float("nan")), ("checkedAt", time.time() + 100)):
with self.subTest(key=key, value=value):
module.AGENT_HEALTH.write_text(json.dumps({**valid, key: value}))
with patch.object(module, "HEALTH_TIMEOUT", 1), patch.object(module.time, "monotonic", side_effect=[0, 0, 2]), patch.object(module.time, "sleep"):
self.assertFalse(updater.wait_healthy(journal))
module.AGENT_HEALTH.write_text(json.dumps(valid))
self.assertTrue(updater.wait_healthy(journal))
def test_symlinked_executable_is_rejected_without_touching_target(self):
external = self.base / "external.py"
external.write_bytes(PREVIOUS)
module.AGENT_EXECUTABLE.unlink()
module.AGENT_EXECUTABLE.symlink_to(external)
self.assertEqual(self.updater().run(), "failed")
self.assertEqual(external.read_bytes(), PREVIOUS)
self.assertEqual(self.commands, [])
def test_network_reader_enforces_header_and_stream_bounds(self):
updater = module.Updater()
updater.load_installation()
for response in (Response(b"abc", {"Content-Length": "100"}), Response(b"abcdef")):
with self.subTest(response=response), patch.object(module.urllib.request, "build_opener") as opener:
opener.return_value.open.return_value = response
with self.assertRaises(module.IntegrityError):
updater.fetch("/api/agent/release/ec-agent.py", 5)
def test_trickling_or_truncated_download_defers_without_holding_release(self):
updater = module.Updater()
updater.load_installation()
with patch.object(module.urllib.request, "build_opener") as opener:
opener.return_value.open.return_value = Response(b"source")
with patch.object(module.time, "monotonic", side_effect=[0, 0, 121]):
with self.assertRaisesRegex(module.ContactError, "timed out"):
updater.fetch("/api/agent/release/ec-agent.py", 100)
with patch.object(module.urllib.request, "build_opener") as opener:
response = Response(b"source")
response.read1 = lambda *_: (_ for _ in ()).throw(module.http.client.IncompleteRead(b"partial"))
opener.return_value.open.return_value = response
with self.assertRaises(module.ContactError):
updater.fetch("/api/agent/release/ec-agent.py", 100)
def test_stale_code_staging_is_recovered_without_leaving_extra_executables(self):
temporary = module.AGENT_EXECUTABLE.with_name(module.AGENT_EXECUTABLE.name + ".update")
temporary.write_bytes(b"partial previous download")
self.assertEqual(self.updater().run(), "updated")
self.assertFalse(temporary.exists())
self.assertEqual(module.AGENT_EXECUTABLE.read_bytes(), CANDIDATE)
def test_download_uses_fixed_https_controller_and_no_enrollment_credentials(self):
updater = module.Updater()
updater.load_installation()
with patch.object(module.urllib.request, "build_opener") as opener:
opener.return_value.open.return_value = Response(b"source", {"Content-Length": "6"})
self.assertEqual(updater.fetch("/api/agent/release/ec-agent.py", 100), b"source")
request = opener.return_value.open.call_args.args[0]
self.assertEqual(request.full_url, "https://controller.example/api/agent/release/ec-agent.py")
self.assertIsNone(request.get_header("Authorization"))
redirect = opener.call_args.args[0]
with self.assertRaises(module.ContactError):
redirect.redirect_request(None, None, 302, "redirect", {}, "https://other.example")
def test_insecure_or_credential_embedded_controller_is_rejected(self):
for controller in ("http://controller.example", "https://token:secret@controller.example"):
with self.subTest(controller=controller):
module.AGENT_CONFIG.write_text(json.dumps({**self.config, "controller": controller}))
self.assertEqual(self.updater().run(), "failed")
self.assertEqual(self.downloads, [])
self.assertEqual(self.commands, [])
class UpdateSecurityTests(unittest.TestCase):
def test_untrusted_ownership_writable_paths_and_special_files_are_rejected(self):
for mode, uid in ((stat.S_IFREG | 0o666, 0), (stat.S_IFDIR | 0o755, 1000), (stat.S_IFLNK | 0o777, 0), (stat.S_IFIFO | 0o600, 0)):
with self.subTest(mode=mode, uid=uid), patch.object(Path, "lstat", return_value=os.stat_result((mode, 1, 1, 1, uid, 0, 0, 0, 0, 0))):
with self.assertRaises(module.UpdateError):
module.trusted("/etc/electric-cherry/update/journal.json")
def test_source_is_parsed_and_compiled_without_execution(self):
source = b'VERSION="1.2.3"\nraise RuntimeError("Never execute downloaded code during verification")\n'
self.assertEqual(module.source_version(source), "1.2.3")
def test_numeric_version_order_and_strict_format(self):
self.assertGreater(module.version("1.10.0"), module.version("1.9.9"))
for value in ("v1.0.0", "1.0", "1.2.03", "1.0.0+local", "1.0.0-rc1", True, None, "1.0.000000000000"):
with self.subTest(value=value), self.assertRaises(module.IntegrityError):
module.version(value)
if __name__ == "__main__":
unittest.main()