"""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