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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user