239 lines
9.6 KiB
Python
239 lines
9.6 KiB
Python
|
|
"""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
|