164 lines
5.7 KiB
Python
164 lines
5.7 KiB
Python
|
|
"""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
|