homelab-codex-ws/jobs/deploy-runner/tests/test_action.py
oskar da151fc8d3 fix(control-plane): redeploy wykonywalny — dispatch do host-side deploy-runnera
Executor odpalal scripts/deploy/deploy-node.sh <node> <service> wewnatrz
swojego kontenera: skrypt ignoruje oba argumenty i wymaga repo w
${HOME}/homelab-codex-ws (w kontenerze HOME=/home/homelab) -> exit 1 w 18.
linii. Za tym brak git, brak klienta docker w obrazie, a gdyby przeszedl —
deploy calego zestawu uslug hosta executora zamiast wezla z akcji. Kazdy
redeploy padal (recon D14/D15; 18 pending / 0 completed).

Redeploy idzie teraz ta sama sciezka pull co container_restart — VPS nigdy
nie inicjuje polaczenia do wezla:
  executor -> actions/deploy/<node>/<id>.json
  -> deploy-runner (systemd na hoscie) rsync-pull, walidacja, deploy
  -> action_result event -> executor rozlicza completed/failed

- scripts/deploy/deploy-service.sh: deploy jednej uslugi, wspoldzielony z
  deploy-node.sh, wiec inwokacja compose (a przez to nazwa projektu) jest
  identyczna jak przy deployu recznym
- jobs/deploy-runner/: host-level, nie kontener — compose rozwiazuje
  wzgledne bindy i nazwe projektu tak jak przy deployu czlowieka;
  niezalezny od node-agenta, wiec potrafi zredeployowac takze jego
- walidacja: tylko typ redeploy, node musi sie zgadzac, usluga musi byc w
  hosts/<node>/services.yaml, zadna tresc z payloadu nie trafia do shella
- --force-recreate bez --build i bez --remove-orphans: redeploy to
  rekoncyliacja, nie wysylka kodu
- executor: REDEPLOY_TIMEOUT_SECS=900, /repo zjechany do :ro (nieuzywany)

248 testow zielonych; deploy-node.sh przecwiczony na atrapie dockera —
argv compose bez zmian. Instalacja unitow na wezlach i E2E: backlog.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 18:26:30 +02:00

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