Add automatic agent updates with verified rollback
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user