"""Tests for the Executor redeploy path (recon D14/D15 — etap 2). Before this, `redeploy` ran scripts/deploy/deploy-node.sh inside the executor container: a script that ignores its arguments, expects a repo at ${HOME}/homelab-codex-ws (absent there, along with git and the docker CLI), and would have deployed the EXECUTOR's own host's full service set rather than the action's target. Every redeploy failed, which is why healthcheck_failed had to be rerouted to container_restart and the action queue never drained. Now redeploy follows the same pull architecture as container_restart: the executor writes a dispatch file, the host-side deploy runner on the target node (jobs/deploy-runner/) executes it and reports back an action_result event. """ from __future__ import annotations import json import sys import time from pathlib import Path import pytest sys.path.insert(0, str(Path(__file__).parent.parent / "src")) import executor as executor_module from executor import Executor def _setup_executor(tmp_path: Path, monkeypatch) -> Executor: actions = tmp_path / "actions" events = tmp_path / "events" for d in (actions, events): d.mkdir(parents=True, exist_ok=True) monkeypatch.setattr(executor_module, "ACTIONS_DIR", actions) monkeypatch.setattr(executor_module, "EVENTS_DIR", events) monkeypatch.setattr(executor_module, "DISPATCH_DIR", actions / "dispatch") monkeypatch.setattr(executor_module, "DEPLOY_DISPATCH_DIR", actions / "deploy") return Executor() def _write_approved(tmp_path, action_id, node="piha", service="vikunja"): action = { "action_id": action_id, "type": "redeploy", "node": node, "service": service, "status": "approved", "timestamp": time.time(), } path = tmp_path / "actions" / "approved" / f"{action_id}.json" path.write_text(json.dumps(action)) return path def _write_action_result_event(tmp_path, node, action_id, success, error="", ts=None): ts = int(ts if ts is not None else time.time()) node_dir = tmp_path / "events" / node node_dir.mkdir(parents=True, exist_ok=True) event_id = f"evt-{node}-{ts}-action_result-{action_id}" (node_dir / f"{event_id}.json").write_text(json.dumps({ "id": event_id, "timestamp": ts, "type": "action_result", "node": node, "payload": {"action_id": action_id, "success": success, "error": error, "node": node, "source": "deploy-runner"}, })) def _exists(tmp_path, state, action_id): return (tmp_path / "actions" / state / f"{action_id}.json").exists() def _read(tmp_path, state, action_id): return json.loads((tmp_path / "actions" / state / f"{action_id}.json").read_text()) # --------------------------------------------------------------------------- # Dispatch # --------------------------------------------------------------------------- def test_redeploy_is_dispatched_not_executed_locally(tmp_path, monkeypatch): ex = _setup_executor(tmp_path, monkeypatch) def fail(*args, **kwargs): raise AssertionError("executor must never run a deploy itself") monkeypatch.setattr(executor_module.subprocess, "run", fail) ex._execute_action(_write_approved(tmp_path, "redeploy-piha-vikunja")) dispatch_file = tmp_path / "actions" / "deploy" / "piha" / "redeploy-piha-vikunja.json" assert dispatch_file.exists() payload = json.loads(dispatch_file.read_text()) assert payload["type"] == "redeploy" assert payload["node"] == "piha" assert payload["service"] == "vikunja" assert "dispatched_at" in payload def test_redeploy_inbox_is_separate_from_node_agent_dispatch(tmp_path, monkeypatch): """node-agent deletes and failure-reports anything in its own inbox that is not container_restart, so a redeploy must never be written there.""" ex = _setup_executor(tmp_path, monkeypatch) ex._execute_action(_write_approved(tmp_path, "redeploy-piha-vikunja")) node_agent_inbox = tmp_path / "actions" / "dispatch" assert not list(node_agent_inbox.glob("**/*.json")) def test_redeploy_stays_running_until_reported(tmp_path, monkeypatch): ex = _setup_executor(tmp_path, monkeypatch) ex._execute_action(_write_approved(tmp_path, "rd-1")) assert _exists(tmp_path, "running", "rd-1") assert not _exists(tmp_path, "completed", "rd-1") assert not _exists(tmp_path, "failed", "rd-1") @pytest.mark.parametrize("node,service", [("", "vikunja"), ("piha", ""), ("", "")]) def test_redeploy_without_node_or_service_fails_immediately(tmp_path, monkeypatch, node, service): ex = _setup_executor(tmp_path, monkeypatch) ex._execute_action(_write_approved(tmp_path, "rd-bad", node=node, service=service)) assert _exists(tmp_path, "failed", "rd-bad") assert "requires both node and service" in _read(tmp_path, "failed", "rd-bad")["error"] assert not list((tmp_path / "actions" / "deploy").glob("**/*.json")) # --------------------------------------------------------------------------- # Reconcile # --------------------------------------------------------------------------- def test_redeploy_completes_on_success_result(tmp_path, monkeypatch): ex = _setup_executor(tmp_path, monkeypatch) ex._execute_action(_write_approved(tmp_path, "rd-2")) _write_action_result_event(tmp_path, "piha", "rd-2", success=True) ex._reconcile_running_actions() assert _exists(tmp_path, "completed", "rd-2") assert not _exists(tmp_path, "running", "rd-2") def test_redeploy_fails_on_failure_result(tmp_path, monkeypatch): ex = _setup_executor(tmp_path, monkeypatch) ex._execute_action(_write_approved(tmp_path, "rd-3")) _write_action_result_event(tmp_path, "piha", "rd-3", success=False, error="deploy-service.sh exited 1") ex._reconcile_running_actions() assert _read(tmp_path, "failed", "rd-3")["error"] == "deploy-service.sh exited 1" def test_redeploy_uses_its_own_longer_timeout(tmp_path, monkeypatch): """A redeploy must not be timed out on the container_restart budget: the runner polls once a minute and may pull images before reporting.""" monkeypatch.setattr(executor_module, "ACTION_TIMEOUT_SECS", 300) monkeypatch.setattr(executor_module, "REDEPLOY_TIMEOUT_SECS", 900) ex = _setup_executor(tmp_path, monkeypatch) ex._execute_action(_write_approved(tmp_path, "rd-4")) running_path = tmp_path / "actions" / "running" / "rd-4.json" data = json.loads(running_path.read_text()) data["started_at"] = time.time() - 600 # past 300s, inside 900s running_path.write_text(json.dumps(data)) ex._reconcile_running_actions() assert _exists(tmp_path, "running", "rd-4") data["started_at"] = time.time() - 1000 # past 900s running_path.write_text(json.dumps(data)) ex._reconcile_running_actions() assert _exists(tmp_path, "failed", "rd-4") error = _read(tmp_path, "failed", "rd-4")["error"] assert "Timed out after 900s" in error assert "deploy runner" in error def test_stale_result_from_previous_run_does_not_resolve(tmp_path, monkeypatch): ex = _setup_executor(tmp_path, monkeypatch) _write_action_result_event(tmp_path, "piha", "rd-5", success=True, ts=int(time.time()) - 86_400) ex._execute_action(_write_approved(tmp_path, "rd-5")) ex._reconcile_running_actions() assert _exists(tmp_path, "running", "rd-5")