From 55c2f269ec16375486809f6a23316c9111dc228b Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Fri, 16 Oct 2026 03:10:01 -0700 Subject: [PATCH] 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-`. 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. --- core/src/wildpc_core/deploy.py | 14 +++++++-- core/src/wildpc_core/manifest.py | 6 ++++ core/tests/test_deploy_run_cmd.py | 21 ++++++++++++++ core/tests/test_requires_env.py | 48 +++++++++++++++++++++++++++++++ docs/registry.md | 2 +- docs/relationships.md | 3 ++ 6 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 core/tests/test_requires_env.py diff --git a/core/src/wildpc_core/deploy.py b/core/src/wildpc_core/deploy.py index fa9785f..c4c5c84 100644 --- a/core/src/wildpc_core/deploy.py +++ b/core/src/wildpc_core/deploy.py @@ -697,7 +697,7 @@ def _requires_env(config: WildpcConfig, dep: DeploymentSpec) -> dict[str, str]: if r.kind == "deployment" and r.bind: url = _target_url(config, r.ref) if url: - out[r.bind] = url + out[r.bind] = url.rstrip("/") + r.path if r.path else url return out @@ -787,7 +787,7 @@ def _registry_requires(dep: DeploymentSpec) -> list[dict]: """A deployment's `requires` as plain dicts for the registry — carried into the mesh payload so peers can draw cross-node consumption.""" return [ - {"kind": r.kind, "ref": r.ref, "bind": r.bind} + {"kind": r.kind, "ref": r.ref, "bind": r.bind, "path": r.path} for r in (getattr(dep, "requires", None) or []) ] @@ -1092,6 +1092,8 @@ def _build_run_cmd( # certs/data/secrets readable with no chown). ${uid}/${gid} expand # to the wildpc process's own ids. cmd.extend(["--user", _subst(run.user, placeholders)]) # type: ignore[union-attr] + if run.gpus: # type: ignore[union-attr] + cmd.extend(["--gpus", run.gpus]) # type: ignore[union-attr] for tp in run.tmpfs: # type: ignore[union-attr] cmd.extend(["--tmpfs", _subst(tp, placeholders)]) for container_port, host_port in run.ports.items(): # type: ignore[union-attr] @@ -1158,10 +1160,16 @@ def _build_stop_cmd(name: str, run: object, source_dir: Path | None) -> list[str """The ExecStop teardown command for a runner, or [] if a plain SIGTERM suffices. Compose stacks need an explicit ``down`` so networks/anonymous volumes are - reclaimed rather than left dangling when the unit stops. + reclaimed rather than left dangling when the unit stops. A container needs an + explicit ``stop``: SIGTERM only reaches the ``docker run`` client, leaving the + container running unsupervised and holding its name, so the next start fails. + ``-`` tells systemd a container that's already gone isn't a failure. """ if run.launcher == "compose": # type: ignore[union-attr] return [*_compose_base(name, run, source_dir), "down"] + if run.launcher == "container": # type: ignore[union-attr] + runtime = shutil.which("docker") or shutil.which("podman") or "docker" + return [f"-{runtime}", "stop", f"wildpc-{name}"] return [] diff --git a/core/src/wildpc_core/manifest.py b/core/src/wildpc_core/manifest.py index 06a71e1..70d4696 100644 --- a/core/src/wildpc_core/manifest.py +++ b/core/src/wildpc_core/manifest.py @@ -97,6 +97,9 @@ class RunContainer(LaunchBase): # tmpfs mounts (e.g. ["/var/run/postgresql"]) for image runtime dirs that must # be writable when the container runs as a non-default uid. tmpfs: list[str] = Field(default_factory=list) + # GPUs to hand the container (docker `--gpus`), e.g. "all" or "device=0". + # None → no GPU access. + gpus: str | None = None class RunNode(LaunchBase): @@ -308,6 +311,9 @@ class Requirement(BaseModel): kind: Literal["system", "deployment", "tool"] = "deployment" ref: str bind: str | None = None + # Appended to the target's URL when binding — for a consumer that wants a + # sub-path of it (e.g. an OpenAI-compatible server's "/v1") as its base URL. + path: str | None = None # --------------------- diff --git a/core/tests/test_deploy_run_cmd.py b/core/tests/test_deploy_run_cmd.py index 6bd43ae..701df02 100644 --- a/core/tests/test_deploy_run_cmd.py +++ b/core/tests/test_deploy_run_cmd.py @@ -205,3 +205,24 @@ def test_compose_stop_cmd_is_down(tmp_path: Path) -> None: 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"] diff --git a/core/tests/test_requires_env.py b/core/tests/test_requires_env.py new file mode 100644 index 0000000..15b472d --- /dev/null +++ b/core/tests/test_requires_env.py @@ -0,0 +1,48 @@ +"""Tests for `_requires_env` — a `{ref, bind}` requirement projects the target's URL.""" + +from __future__ import annotations + +import wildpc_core.config as C +from wildpc_core.deploy import _requires_env +from wildpc_core.manifest import RemoteDeployment, SystemdDeployment + + +def _svc(requires: list | None = None, port: int | None = None) -> SystemdDeployment: + spec: dict = {"manager": "systemd", "run": {"launcher": "command", "argv": ["x"]}} + if requires is not None: + spec["requires"] = requires + if port is not None: + spec["expose"] = {"http": {"internal": {"port": port}}} + spec["reach"] = "internal" + return SystemdDeployment.model_validate(spec) + + +def _cfg(deployments: dict) -> C.WildpcConfig: + return C.WildpcConfig( + root=None, + gateway=C.GatewayConfig(port=9000, tls="acme", domain="node.example"), + repo=None, + programs={}, + deployments=deployments, + ) + + +def test_bind_projects_target_url() -> None: + consumer = _svc(requires=[{"ref": "api", "bind": "API_URL"}]) + cfg = _cfg({"api": _svc(port=9100), "app": consumer}) + assert _requires_env(cfg, consumer) == {"API_URL": "https://api.node.example"} + + +def test_bind_path_is_appended() -> None: + consumer = _svc(requires=[{"ref": "llm", "bind": "LLM_BASE", "path": "/v1"}]) + cfg = _cfg({"llm": _svc(port=8100), "app": consumer}) + assert _requires_env(cfg, consumer) == {"LLM_BASE": "https://llm.node.example/v1"} + + +def test_bind_path_on_reference_base_url() -> None: + ref = RemoteDeployment.model_validate( + {"manager": "none", "base_url": "https://ext.example/"} + ) + consumer = _svc(requires=[{"ref": "ext", "bind": "EXT", "path": "/v1"}]) + cfg = _cfg({"ext": ref, "app": consumer}) + assert _requires_env(cfg, consumer) == {"EXT": "https://ext.example/v1"} diff --git a/docs/registry.md b/docs/registry.md index 864adf4..b68895e 100644 --- a/docs/registry.md +++ b/docs/registry.md @@ -274,7 +274,7 @@ Nested launch spec, discriminated union on `launcher`: |----------|--------|------------| | `python` | `uv run --project --no-dev ` | `program`, `args` | | `command` | `which(argv[0])` → resolved path | `argv` | -| `container` | `docker`/`podman` `run` | `image`, `command`, `ports`, `volumes` | +| `container` | `docker`/`podman` `run` (+ `ExecStop=docker stop`) | `image`, `command`, `ports`, `volumes`, `gpus` | | `compose` | `docker compose -p -f up` (+ `ExecStop=down`) | `file`, `project_name` | | `node` | `package_manager run script` | `script`, `package_manager` | diff --git a/docs/relationships.md b/docs/relationships.md index 8689061..3d31abe 100644 --- a/docs/relationships.md +++ b/docs/relationships.md @@ -51,6 +51,9 @@ requires: - { ref: litellm, bind: LITELLM_URL } # bind: project the target's URL into env ``` +`path` is appended to the bound URL, for a consumer that wants a sub-path as its +base (e.g. `{ ref: vllm, bind: VLLM_API_BASE, path: /v1 }`). + A **program**'s host-package preconditions stay on the program as `system_dependencies` (a plain list of package names); the model synthesizes the `{kind: system}` requirements from it for the `functional?` check. This split keeps