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

151 lines
5.2 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
"""Contract tests for scripts/deploy/deploy-service.sh.
Why these live here: the deploy runner and the human whole-node deploy both go
through this script, and what must stay identical between them is the argv it
hands to `docker compose`. docker compose derives the project name from the
directory of the FIRST -f file a caller that assembles those arguments
differently lands in a different project, where `--remove-orphans` tears down
the running stack (the 2026-06-25 control-plane wipe on VPS).
Each test runs the real script with a stub `docker` on PATH that records its
argv, so nothing is executed against a daemon.
"""
from __future__ import annotations
import os
import subprocess
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[3]
SCRIPT = REPO / "scripts" / "deploy" / "deploy-service.sh"
@pytest.fixture
def env(tmp_path: Path):
"""A fake repo + host dir + a `docker` stub that logs its arguments."""
repo = tmp_path / "repo"
(repo / "services" / "vikunja").mkdir(parents=True)
(repo / "services" / "vikunja" / "docker-compose.yml").write_text("services: {}\n")
host_dir = repo / "hosts" / "piha"
host_dir.mkdir(parents=True)
bindir = tmp_path / "bin"
bindir.mkdir()
argv_log = tmp_path / "docker-argv.txt"
stub = bindir / "docker"
stub.write_text(f'#!/usr/bin/env bash\nprintf "%s\\n" "$@" > {argv_log}\nexit 0\n')
stub.chmod(0o755)
return {"repo": repo, "host_dir": host_dir, "bindir": bindir, "argv_log": argv_log}
def run(env, *args, service="vikunja"):
proc_env = dict(os.environ, PATH=f"{env['bindir']}:{os.environ['PATH']}")
return subprocess.run(
[str(SCRIPT), "--repo", str(env["repo"]), "--host-dir", str(env["host_dir"]),
"--service", service, *args],
capture_output=True, text=True, env=proc_env,
)
def argv(env) -> list[str]:
return env["argv_log"].read_text().splitlines()
def test_base_invocation(env):
result = run(env)
assert result.returncode == 0, result.stderr
assert argv(env) == [
"compose", "-f", str(env["repo"] / "services" / "vikunja" / "docker-compose.yml"),
"up", "-d",
]
def test_project_name_invariant(env):
"""No -p and no --project-directory: the project name must come from the
compose file's own directory, exactly as it does for a human deploy."""
run(env, "--force-recreate")
assert "-p" not in argv(env)
assert "--project-directory" not in argv(env)
assert argv(env)[1] == "-f" # first -f is the service's own compose file
def test_override_is_second_f(env):
override = env["host_dir"] / "runtime" / "vikunja"
override.mkdir(parents=True)
(override / "docker-compose.override.yml").write_text("services: {}\n")
run(env)
args = argv(env)
assert args[1:5] == [
"-f", str(env["repo"] / "services" / "vikunja" / "docker-compose.yml"),
"-f", str(override / "docker-compose.override.yml"),
]
def test_env_file_is_passed_when_present(env):
(env["repo"] / "services" / "vikunja" / ".env").write_text("FOO=bar\n")
run(env)
args = argv(env)
assert "--env-file" in args
assert args[args.index("--env-file") + 1] == str(env["repo"] / "services" / "vikunja" / ".env")
def test_force_recreate_flag(env):
"""The remediation path needs it: plain `up -d` is a no-op when config is
unchanged, i.e. exactly the unhealthy-container case a redeploy targets."""
run(env, "--force-recreate")
assert "--force-recreate" in argv(env)
def test_no_remove_orphans_by_default(env):
"""The agent path must never be able to delete containers on a project-name
mismatch only the human whole-node deploy opts in."""
run(env, "--force-recreate")
assert "--remove-orphans" not in argv(env)
def test_remove_orphans_when_requested(env):
run(env, "--remove-orphans")
assert "--remove-orphans" in argv(env)
def test_build_if_needed_only_builds_with_dockerfile(env):
run(env, "--build-if-needed")
assert "--build" not in argv(env)
(env["repo"] / "services" / "vikunja" / "Dockerfile").write_text("FROM scratch\n")
run(env, "--build-if-needed")
assert "--build" in argv(env)
def test_redeploy_path_never_builds(env):
"""The runner does not pass --build-if-needed: building on the 4 GiB VPS
mid-incident is an OOM risk, and a redeploy is a reconcile, not a code ship."""
(env["repo"] / "services" / "vikunja" / "Dockerfile").write_text("FROM scratch\n")
run(env, "--force-recreate")
assert "--build" not in argv(env)
def test_service_with_own_deploy_path_exits_3(env):
(env["repo"] / "services" / "vikunja" / "deploy-local.sh").write_text("#!/bin/sh\n")
result = run(env)
assert result.returncode == 3
assert not env["argv_log"].exists() # docker never invoked
@pytest.mark.parametrize("bad", ["../../etc", "/abs", "Vikunja", "a b"])
def test_invalid_service_name_exits_2(env, bad):
result = run(env, service=bad)
assert result.returncode == 2
assert not env["argv_log"].exists()
def test_missing_compose_file_exits_2(env):
(env["repo"] / "services" / "vikunja" / "docker-compose.yml").unlink()
result = run(env)
assert result.returncode == 2
assert not env["argv_log"].exists()