homelab-codex-ws/jobs/deploy-runner/tests/test_runner_integration.py

164 lines
5.7 KiB
Python
Raw Normal View History

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
"""End-to-end test of deploy-runner.sh on a fake node.
Runs the real bash runner against temp directories with a stubbed `docker`, in
local (VPS) mode so no rsync/ssh is involved. Covers the full loop the control
plane depends on: dispatch file in validate deploy action_result event
out idempotency marker inbox drained.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[3]
RUNNER = REPO / "jobs" / "deploy-runner" / "deploy-runner.sh"
@pytest.fixture
def node(tmp_path: Path):
"""A fake node: repo checkout, /opt/homelab-alike runtime, docker stub."""
repo = tmp_path / "repo"
(repo / "scripts" / "deploy").mkdir(parents=True)
shutil.copy(REPO / "scripts" / "deploy" / "deploy-service.sh",
repo / "scripts" / "deploy" / "deploy-service.sh")
os.chmod(repo / "scripts" / "deploy" / "deploy-service.sh", 0o755)
(repo / "hosts" / "piha").mkdir(parents=True)
(repo / "hosts" / "piha" / "services.yaml").write_text(
"host: piha\nservices:\n vikunja:\n role: task-tracker\n"
)
(repo / "services" / "vikunja").mkdir(parents=True)
(repo / "services" / "vikunja" / "docker-compose.yml").write_text("services: {}\n")
runtime = tmp_path / "runtime"
(runtime / "actions" / "deploy" / "piha").mkdir(parents=True)
bindir = tmp_path / "bin"
bindir.mkdir()
argv_log = tmp_path / "docker-argv.txt"
(bindir / "docker").write_text(
f'#!/usr/bin/env bash\nprintf "%s " "$@" >> {argv_log}\necho >> {argv_log}\n'
f'exit ${{DOCKER_STUB_EXIT:-0}}\n'
)
os.chmod(bindir / "docker", 0o755)
return {"repo": repo, "runtime": runtime, "bindir": bindir, "argv_log": argv_log,
"inbox": runtime / "actions" / "deploy" / "piha",
"events": runtime / "events" / "piha"}
def dispatch(fake_node, action_id="redeploy-piha-vikunja", **overrides):
action = {"action_id": action_id, "type": "redeploy", "node": "piha",
"service": "vikunja", "dispatched_at": 0}
action.update(overrides)
(fake_node["inbox"] / f"{action_id}.json").write_text(json.dumps(action))
def run_runner(fake_node, **extra_env):
env = dict(os.environ)
env.update({
"PATH": f"{fake_node['bindir']}:{os.environ['PATH']}",
"NODE_NAME": "piha",
"REPO_PATH": str(fake_node["repo"]),
"RUNTIME_PATH": str(fake_node["runtime"]),
"VPS_EVENTS_HOST": "", # local mode: executor shares the same filesystem
})
env.update(extra_env)
return subprocess.run([str(RUNNER)], capture_output=True, text=True, env=env)
def results(node) -> list[dict]:
if not node["events"].exists():
return []
return [json.loads(p.read_text())
for p in sorted(node["events"].glob("evt-*-action_result-*.json"))]
def test_successful_redeploy_reports_and_drains(node):
dispatch(node)
proc = run_runner(node)
assert proc.returncode == 0, proc.stdout + proc.stderr
assert "compose -f" in node["argv_log"].read_text()
assert "--force-recreate" in node["argv_log"].read_text()
events = results(node)
assert len(events) == 1
assert events[0]["payload"] == {
"action_id": "redeploy-piha-vikunja", "success": True, "error": "",
"node": "piha", "source": "deploy-runner",
}
assert list(node["inbox"].glob("*.json")) == []
assert (node["runtime"] / "state" / "processed-deploy-actions"
/ "redeploy-piha-vikunja.done").exists()
def test_failed_deploy_reports_compose_output(node):
dispatch(node)
proc = run_runner(node, DOCKER_STUB_EXIT="1")
assert proc.returncode == 0 # the runner itself succeeds; the action fails
event = results(node)[0]
assert event["payload"]["success"] is False
assert "deploy-service.sh exited 1" in event["payload"]["error"]
assert event["severity"] == "high"
# Full output kept on the node for forensics
assert list((node["runtime"] / "logs" / "deploy-runner").glob("*.log"))
def test_rejected_action_is_reported_not_silently_dropped(node):
"""An action for a service this node does not declare must come back as a
failure otherwise it sits in running/ until the executor times it out."""
dispatch(node, service="gokapi")
run_runner(node)
event = results(node)[0]
assert event["payload"]["success"] is False
assert "not in hosts/piha/services.yaml" in event["payload"]["error"]
assert not node["argv_log"].exists() # docker never invoked
assert list(node["inbox"].glob("*.json")) == []
def test_action_for_another_node_is_refused(node):
dispatch(node, node="solaria")
run_runner(node)
event = results(node)[0]
assert "addressed to node" in event["payload"]["error"]
assert not node["argv_log"].exists()
def test_container_restart_is_not_executed_by_the_runner(node):
"""node-agent's job. The runner refuses anything but redeploy."""
dispatch(node, type="container_restart")
run_runner(node)
event = results(node)[0]
assert "not executable by the deploy runner" in event["payload"]["error"]
assert not node["argv_log"].exists()
def test_already_processed_action_is_not_redeployed(node):
dispatch(node)
run_runner(node)
first = len(results(node))
dispatch(node) # same action_id arrives again
run_runner(node)
assert len(results(node)) == first # no second result
assert len(node["argv_log"].read_text().splitlines()) == 1 # docker ran once
assert list(node["inbox"].glob("*.json")) == []
def test_missing_node_name_fails_loudly(node):
proc = run_runner(node, NODE_NAME="")
assert proc.returncode == 1
assert "NODE_NAME is not set" in proc.stdout + proc.stderr