197 lines
9.8 KiB
Python
197 lines
9.8 KiB
Python
import fcntl
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import tempfile
|
|
import threading
|
|
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_execution_and_reports_exclude_updates_but_long_poll_does_not(self):
|
|
operation = {"id": "one", "kind": "runtime.pull", "payload": {}}
|
|
calls = []
|
|
def request(method, path, body=None):
|
|
if method != "GET":
|
|
self.assert_activity_held()
|
|
else:
|
|
with (self.agent.root / "agent" / "activity.lock").open("a") as stream:
|
|
fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
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?lane=write&wait=20")
|
|
|
|
def test_read_lane_completes_while_mutation_is_busy_and_excludes_updater(self):
|
|
read_done = threading.Event()
|
|
operation = {"id": "read-one", "kind": "host.inspect", "payload": {}}
|
|
self.agent.request = lambda method, path, body=None: {"operations": [operation]} if method == "GET" else {}
|
|
def dispatch(value):
|
|
self.assert_activity_held()
|
|
read_done.set()
|
|
self.agent.stopping.set()
|
|
return {"ok": True}
|
|
self.agent.dispatch = dispatch
|
|
with self.agent.activity() as acquired:
|
|
self.assertTrue(acquired)
|
|
reader = threading.Thread(target=self.agent.read_loop)
|
|
reader.start()
|
|
self.assertTrue(read_done.wait(2), "Read waited behind unrelated mutation activity")
|
|
reader.join(2)
|
|
self.assertFalse(reader.is_alive())
|
|
self.assertEqual(self.agent.journal.get("read-one")["state"], "completed")
|
|
self.assertIsNone(self.agent.executing_id)
|
|
|
|
def test_read_lane_never_dispatches_mutations(self):
|
|
def request(method, path, body=None):
|
|
self.agent.stopping.set()
|
|
return {"operations": [{"id": "bad-read", "kind": "instance.remove", "payload": {}}]}
|
|
self.agent.request = request
|
|
with patch.object(self.agent, "dispatch") as dispatch:
|
|
self.agent.read_loop()
|
|
dispatch.assert_not_called()
|
|
|
|
def test_instance_mutations_and_global_mutations_exclude_observations(self):
|
|
operation = {"instanceId": "one"}
|
|
entered = threading.Event()
|
|
release = threading.Event()
|
|
def read():
|
|
with self.agent.read_gate.read(), self.agent.instance_lock(operation):
|
|
entered.set()
|
|
release.wait(2)
|
|
with self.agent.instance_lock(operation):
|
|
reader = threading.Thread(target=read)
|
|
reader.start()
|
|
self.assertFalse(entered.wait(0.05))
|
|
self.assertTrue(entered.wait(2))
|
|
writer_entered = threading.Event()
|
|
def write():
|
|
with self.agent.read_gate.write():
|
|
writer_entered.set()
|
|
writer = threading.Thread(target=write)
|
|
writer.start()
|
|
self.assertFalse(writer_entered.wait(0.05))
|
|
release.set()
|
|
reader.join(2)
|
|
writer.join(2)
|
|
self.assertTrue(writer_entered.is_set())
|
|
|
|
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()
|