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