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
+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()