151 lines
5.2 KiB
Python
151 lines
5.2 KiB
Python
|
|
"""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()
|