"""Tests for the deploy runner's validation / result-reporting helper. The validator is the only gate between a JSON file that arrived over the network and `docker compose up` running on the node, so its rejection cases matter more than its happy path. The emit-result tests pin the event format against the control-plane executor's actual parser — if either side drifts, redeploy actions would hang in running/ until they time out. """ from __future__ import annotations import json import shlex import sys import time from pathlib import Path import pytest _JOB_DIR = Path(__file__).resolve().parents[1] sys.path.insert(0, str(_JOB_DIR)) import action as action_mod # noqa: E402 from action import Invalid, emit_result, validate # noqa: E402 @pytest.fixture def repo(tmp_path: Path) -> Path: """Minimal repo: node piha declares vikunja, which has a compose file.""" repo = tmp_path / "repo" (repo / "hosts" / "piha").mkdir(parents=True) (repo / "hosts" / "piha" / "services.yaml").write_text( "host: piha\nservices:\n vikunja:\n role: task-tracker\n node-agent:\n role: monitor\n" ) (repo / "services" / "vikunja").mkdir(parents=True) (repo / "services" / "vikunja" / "docker-compose.yml").write_text("services: {}\n") (repo / "services" / "gokapi").mkdir(parents=True) (repo / "services" / "gokapi" / "docker-compose.yml").write_text("services: {}\n") return repo def _action_file(tmp_path: Path, **overrides) -> Path: action = { "action_id": "redeploy-piha-vikunja", "type": "redeploy", "node": "piha", "service": "vikunja", } action.update(overrides) path = tmp_path / "redeploy-piha-vikunja.json" path.write_text(json.dumps(action)) return path # --------------------------------------------------------------------------- # validate # --------------------------------------------------------------------------- def test_valid_action_returns_id_and_service(tmp_path, repo): action_id, service = validate(_action_file(tmp_path), "piha", repo) assert action_id == "redeploy-piha-vikunja" assert service == "vikunja" def test_action_for_another_node_is_refused(tmp_path, repo): path = _action_file(tmp_path, node="solaria") with pytest.raises(Invalid, match="addressed to node"): validate(path, "piha", repo) def test_non_redeploy_type_is_refused(tmp_path, repo): """The runner executes redeploys only — container_restart stays node-agent's job, and anything else must never reach a shell.""" path = _action_file(tmp_path, type="container_restart") with pytest.raises(Invalid, match="not executable by the deploy runner"): validate(path, "piha", repo) def test_service_not_in_desired_state_is_refused(tmp_path, repo): """gokapi has a compose file in the repo but is not in hosts/piha — desired state is the authority on what this node may run.""" path = _action_file(tmp_path, service="gokapi") with pytest.raises(Invalid, match="not in hosts/piha/services.yaml"): validate(path, "piha", repo) @pytest.mark.parametrize("bad", ["../../etc/passwd", "/absolute", "Vikunja", "", "a b", "x" * 65]) def test_malformed_service_names_are_refused(tmp_path, repo, bad): path = _action_file(tmp_path, service=bad) with pytest.raises(Invalid, match="invalid service name"): validate(path, "piha", repo) def test_service_without_compose_file_is_refused(tmp_path, repo): (repo / "services" / "vikunja" / "docker-compose.yml").unlink() with pytest.raises(Invalid, match="no compose file"): validate(_action_file(tmp_path), "piha", repo) def test_missing_manifest_for_own_node_is_refused(tmp_path, repo): (repo / "hosts" / "piha" / "services.yaml").unlink() with pytest.raises(Invalid, match="no desired-state manifest"): validate(_action_file(tmp_path), "piha", repo) def test_services_as_list_is_supported(tmp_path, repo): (repo / "hosts" / "piha" / "services.yaml").write_text("host: piha\nservices:\n - vikunja\n") _, service = validate(_action_file(tmp_path), "piha", repo) assert service == "vikunja" def test_unparseable_file_is_refused(tmp_path, repo): path = tmp_path / "broken.json" path.write_text("{not json") with pytest.raises(Invalid, match="unparseable"): validate(path, "piha", repo) def test_invalid_action_id_is_refused(tmp_path, repo): path = _action_file(tmp_path, action_id="../escape") with pytest.raises(Invalid, match="invalid action_id"): validate(path, "piha", repo) # --------------------------------------------------------------------------- # validate via the CLI (shell-assignment contract used by deploy-runner.sh) # --------------------------------------------------------------------------- def _eval_cli(argv) -> dict: """Parse the KEY='value' lines the bash runner evaluates.""" import io import contextlib buf = io.StringIO() with contextlib.redirect_stdout(buf): rc = action_mod.main(argv) assert rc == 0 out = {} for line in buf.getvalue().splitlines(): key, _, raw = line.partition("=") out[key] = shlex.split(raw)[0] if raw and raw != "''" else "" return out def test_cli_valid_action_emits_ok_true(tmp_path, repo): path = _action_file(tmp_path) result = _eval_cli(["validate", "--file", str(path), "--node", "piha", "--repo", str(repo)]) assert result["OK"] == "true" assert result["SERVICE"] == "vikunja" assert result["ACTION_ID"] == "redeploy-piha-vikunja" assert result["ERROR"] == "" def test_cli_rejected_action_still_reports_action_id(tmp_path, repo): """So the runner can report a failure result instead of silently dropping the action — a dropped action would sit in running/ until it times out.""" path = _action_file(tmp_path, service="gokapi") result = _eval_cli(["validate", "--file", str(path), "--node", "piha", "--repo", str(repo)]) assert result["OK"] == "false" assert result["ACTION_ID"] == "redeploy-piha-vikunja" assert "not in hosts/piha/services.yaml" in result["ERROR"] def test_cli_quotes_hostile_error_content(tmp_path, repo): """A crafted service name must not be able to inject shell syntax through the eval'd ERROR assignment.""" path = _action_file(tmp_path, service="'; touch /tmp/pwned; '") result = _eval_cli(["validate", "--file", str(path), "--node", "piha", "--repo", str(repo)]) assert result["OK"] == "false" assert "touch /tmp/pwned" in result["ERROR"] # inert data, not a command # --------------------------------------------------------------------------- # emit-result — format compatibility with the control-plane executor # --------------------------------------------------------------------------- def test_emit_result_shape(tmp_path): events = tmp_path / "events" / "piha" path = emit_result(events, "piha", "redeploy-piha-vikunja", "vikunja", True, "") event = json.loads(path.read_text()) assert event["type"] == "action_result" assert event["node"] == "piha" assert event["severity"] == "info" assert event["payload"]["action_id"] == "redeploy-piha-vikunja" assert event["payload"]["success"] is True assert event["payload"]["source"] == "deploy-runner" def test_emit_result_failure_carries_error(tmp_path): events = tmp_path / "events" / "piha" path = emit_result(events, "piha", "act-1", "vikunja", False, "compose exited 1") event = json.loads(path.read_text()) assert event["severity"] == "high" assert event["payload"]["success"] is False assert event["payload"]["error"] == "compose exited 1" def test_executor_finds_the_emitted_result(tmp_path, monkeypatch): """The real consumer: control-plane executor._find_action_result(). This is the contract that makes a redeploy resolve instead of timing out.""" executor_src = _JOB_DIR.parents[1] / "services" / "control-plane" / "src" sys.path.insert(0, str(executor_src)) import executor as executor_mod events_root = tmp_path / "events" monkeypatch.setattr(executor_mod, "EVENTS_DIR", events_root) monkeypatch.setattr(executor_mod, "ACTIONS_DIR", tmp_path / "actions") monkeypatch.setattr(executor_mod, "DISPATCH_DIR", tmp_path / "actions" / "dispatch") monkeypatch.setattr(executor_mod, "DEPLOY_DISPATCH_DIR", tmp_path / "actions" / "deploy") started_at = time.time() emit_result(events_root / "piha", "piha", "redeploy-piha-vikunja", "vikunja", True, "") payload = executor_mod.Executor()._find_action_result( "piha", "redeploy-piha-vikunja", started_at ) assert payload is not None assert payload["success"] is True def test_executor_ignores_result_older_than_the_action(tmp_path, monkeypatch): """Action IDs are deterministic and repeat, so a result from a previous run must not resolve the current one.""" executor_src = _JOB_DIR.parents[1] / "services" / "control-plane" / "src" sys.path.insert(0, str(executor_src)) import executor as executor_mod events_root = tmp_path / "events" monkeypatch.setattr(executor_mod, "EVENTS_DIR", events_root) monkeypatch.setattr(executor_mod, "ACTIONS_DIR", tmp_path / "actions") monkeypatch.setattr(executor_mod, "DISPATCH_DIR", tmp_path / "actions" / "dispatch") monkeypatch.setattr(executor_mod, "DEPLOY_DISPATCH_DIR", tmp_path / "actions" / "deploy") emit_result(events_root / "piha", "piha", "redeploy-piha-vikunja", "vikunja", True, "") started_at = time.time() + 60 # action started after the event was written assert executor_mod.Executor()._find_action_result( "piha", "redeploy-piha-vikunja", started_at ) is None