Files
wild-pc/core/tests/test_deploy_run_cmd.py
Paul Payne 55c2f269ec core: GPU containers, bind sub-paths, and stop containers on unit stop
- container launcher: `gpus:` passes through as docker `--gpus`.
- requires: `path:` is appended to the bound URL, for consumers that want a
  sub-path as their base (e.g. an OpenAI-compatible server's /v1).
- container services now get `ExecStop=-docker stop wildpc-<name>`. SIGTERM
  only reached the `docker run` client, so a stop/restart left the container
  running unsupervised and holding its name, and the next start failed with
  a name conflict until systemd gave up.
2026-10-16 03:10:01 -07:00

229 lines
9.5 KiB
Python

"""Tests for `_build_run_cmd` — the python runner runs in place via `uv run`."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from wildpc_core.deploy import _build_run_cmd, _build_stop_cmd
from wildpc_core.manifest import RunCompose, RunContainer, RunNode, RunPython
def test_python_runner_uses_uv_run_from_source(tmp_path: Path) -> None:
"""A python service with a real source dir launches via `uv run --project`."""
run = RunPython(launcher="python", program="my-svc")
with patch("wildpc_core.deploy.shutil.which", return_value="/usr/bin/uv"):
cmd = _build_run_cmd("my-svc", run, {}, [], source_dir=tmp_path)
assert cmd == [
"/usr/bin/uv",
"run",
"--project",
str(tmp_path),
"--no-dev",
"my-svc",
]
def test_python_runner_appends_args(tmp_path: Path) -> None:
run = RunPython(launcher="python", program="my-svc", args=["--flag", "x"])
with patch("wildpc_core.deploy.shutil.which", return_value="/usr/bin/uv"):
cmd = _build_run_cmd("my-svc", run, {}, [], source_dir=tmp_path)
assert cmd[-2:] == ["--flag", "x"]
assert cmd[:5] == ["/usr/bin/uv", "run", "--project", str(tmp_path), "--no-dev"]
def test_python_runner_falls_back_to_path_without_source() -> None:
"""No resolvable source → PATH lookup of the script (no uv run)."""
run = RunPython(launcher="python", program="my-svc")
with patch(
"wildpc_core.deploy.shutil.which", return_value="/home/u/.local/bin/my-svc"
):
cmd = _build_run_cmd("my-svc", run, {}, [], source_dir=None)
assert cmd == ["/home/u/.local/bin/my-svc"]
def test_python_runner_warns_when_unresolvable() -> None:
"""No source and not on PATH → a warning, bare program name as last resort."""
run = RunPython(launcher="python", program="my-svc")
messages: list[str] = []
with patch("wildpc_core.deploy.shutil.which", return_value=None):
cmd = _build_run_cmd("my-svc", run, {}, messages, source_dir=None)
assert cmd == ["my-svc"]
assert any("my-svc" in m for m in messages)
def test_node_runner_bakes_source_dir(tmp_path: Path) -> None:
"""A node service runs the script in its source dir via `--dir` (no unit cwd)."""
run = RunNode(launcher="node", script="gateway:watch:raw", package_manager="pnpm")
with patch("wildpc_core.deploy.shutil.which", return_value="/usr/bin/pnpm"):
cmd = _build_run_cmd("oc", run, {}, [], source_dir=tmp_path)
assert cmd == ["/usr/bin/pnpm", "--dir", str(tmp_path), "run", "gateway:watch:raw"]
def test_node_runner_appends_args(tmp_path: Path) -> None:
run = RunNode(
launcher="node", script="start", package_manager="pnpm", args=["--port", "18789"]
)
with patch("wildpc_core.deploy.shutil.which", return_value="/usr/bin/pnpm"):
cmd = _build_run_cmd("oc", run, {}, [], source_dir=tmp_path)
assert cmd == [
"/usr/bin/pnpm",
"--dir",
str(tmp_path),
"run",
"start",
"--port",
"18789",
]
def test_node_runner_without_source_omits_dir() -> None:
"""No resolvable source → bare `pnpm run` (package manager still PATH-resolved)."""
run = RunNode(launcher="node", script="start", package_manager="pnpm")
with patch("wildpc_core.deploy.shutil.which", return_value="/usr/bin/pnpm"):
cmd = _build_run_cmd("oc", run, {}, [], source_dir=None)
assert cmd == ["/usr/bin/pnpm", "run", "start"]
def test_container_secrets_use_env_file_not_argv() -> None:
"""Secrets go through --env-file; plain vars stay as -e; no secret in argv."""
run = RunContainer(launcher="container", image="img:latest", env={"PLAIN": "1"})
env_file = Path("/home/u/.wildpc/secrets/env/wildpc-svc.service.env")
with patch("wildpc_core.deploy.shutil.which", return_value="/usr/bin/docker"):
cmd = _build_run_cmd("svc", run, {"PORT": "9001"}, [], secret_env_file=env_file)
joined = " ".join(cmd)
assert "--env-file" in cmd
assert str(env_file) in cmd
# plain (non-secret) vars are still inlined as -e
assert "-e" in cmd and "PORT=9001" in joined and "PLAIN=1" in joined
# no resolved secret value leaks into argv (only the file path is referenced)
assert "SECRET=" not in joined
def test_container_without_secrets_has_no_env_file() -> None:
run = RunContainer(launcher="container", image="img:latest")
with patch("wildpc_core.deploy.shutil.which", return_value="/usr/bin/docker"):
cmd = _build_run_cmd("svc", run, {}, [], secret_env_file=None)
assert "--env-file" not in cmd
def test_container_placeholders_expand_in_run_fields() -> None:
"""${key} placeholders expand consistently across a container's env, volumes,
and args — env used to be the odd one out that passed through literally."""
run = RunContainer(
launcher="container",
image="img:latest",
env={"DATA": "${data_dir}/x"},
volumes=["${tls_dir}:/tls:ro"],
args=["--advertise", "${name}:${port}"],
)
ph = {
"name": "svc",
"port": "5432",
"data_dir": "/data/wildpc/svc",
"tls_dir": "/data/wildpc/svc/tls",
}
with patch("wildpc_core.deploy.shutil.which", return_value="/usr/bin/docker"):
cmd = _build_run_cmd("svc", run, {}, [], placeholders=ph)
joined = " ".join(cmd)
assert "DATA=/data/wildpc/svc/x" in joined # env expanded
assert "/data/wildpc/svc/tls:/tls:ro" in joined # volume expanded
assert "svc:5432" in cmd # arg expanded
def test_container_double_dollar_escapes_placeholder() -> None:
"""$${key} passes a literal ${key} through to the container's own shell/env
instead of wildpc expanding it (docker-compose-style escape)."""
run = RunContainer(
launcher="container",
image="img:latest",
args=["sh", "-c", "exec myd --advertise $${name}:${port}"],
)
ph = {"name": "svc", "port": "5432"}
with patch("wildpc_core.deploy.shutil.which", return_value="/usr/bin/docker"):
cmd = _build_run_cmd("svc", run, {}, [], placeholders=ph)
# wildpc expands ${port} but leaves $${name} as a literal ${name} for the shell
assert "exec myd --advertise ${name}:5432" in cmd
def test_compose_runner_up_with_resolved_file(tmp_path: Path) -> None:
"""A compose service runs `docker compose -p <project> -f <abs-file> up`."""
run = RunCompose(launcher="compose", file="docker-compose.yml")
with patch("wildpc_core.deploy.shutil.which", return_value="/usr/bin/docker"):
cmd = _build_run_cmd("supabase", run, {}, [], source_dir=tmp_path)
assert cmd == [
"/usr/bin/docker",
"compose",
"-p",
"wildpc-supabase",
"-f",
str(tmp_path / "docker-compose.yml"),
"up",
]
def test_compose_runner_secrets_never_hit_argv(tmp_path: Path) -> None:
"""Compose reads env from the process (systemd), not --env-file/-e — argv is clean."""
run = RunCompose(launcher="compose", file="docker-compose.yml")
env_file = Path("/home/u/.wildpc/secrets/env/wildpc-supabase.service.env")
with patch("wildpc_core.deploy.shutil.which", return_value="/usr/bin/docker"):
cmd = _build_run_cmd(
"supabase", run, {"PORT": "8000"}, [], source_dir=tmp_path,
secret_env_file=env_file,
)
joined = " ".join(cmd)
assert "--env-file" not in cmd
assert "-e" not in cmd
assert str(env_file) not in joined
def test_compose_project_name_override(tmp_path: Path) -> None:
run = RunCompose(launcher="compose", file="stack.yml", project_name="myproj")
with patch("wildpc_core.deploy.shutil.which", return_value="/usr/bin/docker"):
cmd = _build_run_cmd("s", run, {}, [], source_dir=tmp_path)
assert cmd[:6] == [
"/usr/bin/docker", "compose", "-p", "myproj", "-f", str(tmp_path / "stack.yml"),
]
def test_compose_stop_cmd_is_down(tmp_path: Path) -> None:
"""The teardown command mirrors up but ends in `down` (same project + file)."""
run = RunCompose(launcher="compose", file="docker-compose.yml")
with patch("wildpc_core.deploy.shutil.which", return_value="/usr/bin/docker"):
stop = _build_stop_cmd("supabase", run, tmp_path)
assert stop == [
"/usr/bin/docker",
"compose",
"-p",
"wildpc-supabase",
"-f",
str(tmp_path / "docker-compose.yml"),
"down",
]
def test_non_compose_runner_has_no_stop_cmd(tmp_path: Path) -> None:
run = RunPython(launcher="python", program="svc")
assert _build_stop_cmd("svc", run, tmp_path) == []
def test_container_gpus_flag() -> None:
"""`gpus` passes through as docker `--gpus`; omitted → no GPU access."""
run = RunContainer(launcher="container", image="img:latest", gpus="all")
with patch("wildpc_core.deploy.shutil.which", return_value="/usr/bin/docker"):
cmd = _build_run_cmd("svc", run, {}, [])
assert cmd[cmd.index("--gpus") + 1] == "all"
assert cmd.index("--gpus") < cmd.index("img:latest")
plain = RunContainer(launcher="container", image="img:latest")
with patch("wildpc_core.deploy.shutil.which", return_value="/usr/bin/docker"):
assert "--gpus" not in _build_run_cmd("svc", plain, {}, [])
def test_container_stop_cmd_stops_named_container() -> None:
"""Stopping the unit must stop the container itself, not just the docker client."""
run = RunContainer(launcher="container", image="img:latest")
with patch("wildpc_core.deploy.shutil.which", return_value="/usr/bin/docker"):
stop = _build_stop_cmd("svc", run, None)
assert stop == ["-/usr/bin/docker", "stop", "wildpc-svc"]