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