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.
This commit is contained in:
2026-10-16 03:10:01 -07:00
parent 8a9843658c
commit 55c2f269ec
6 changed files with 90 additions and 4 deletions

View File

@@ -697,7 +697,7 @@ def _requires_env(config: WildpcConfig, dep: DeploymentSpec) -> dict[str, str]:
if r.kind == "deployment" and r.bind: if r.kind == "deployment" and r.bind:
url = _target_url(config, r.ref) url = _target_url(config, r.ref)
if url: if url:
out[r.bind] = url out[r.bind] = url.rstrip("/") + r.path if r.path else url
return out 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 """A deployment's `requires` as plain dicts for the registry — carried into the
mesh payload so peers can draw cross-node consumption.""" mesh payload so peers can draw cross-node consumption."""
return [ 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 []) 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 # certs/data/secrets readable with no chown). ${uid}/${gid} expand
# to the wildpc process's own ids. # to the wildpc process's own ids.
cmd.extend(["--user", _subst(run.user, placeholders)]) # type: ignore[union-attr] 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] for tp in run.tmpfs: # type: ignore[union-attr]
cmd.extend(["--tmpfs", _subst(tp, placeholders)]) cmd.extend(["--tmpfs", _subst(tp, placeholders)])
for container_port, host_port in run.ports.items(): # type: ignore[union-attr] 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. """The ExecStop teardown command for a runner, or [] if a plain SIGTERM suffices.
Compose stacks need an explicit ``down`` so networks/anonymous volumes are 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] if run.launcher == "compose": # type: ignore[union-attr]
return [*_compose_base(name, run, source_dir), "down"] 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 [] return []

View File

@@ -97,6 +97,9 @@ class RunContainer(LaunchBase):
# tmpfs mounts (e.g. ["/var/run/postgresql"]) for image runtime dirs that must # tmpfs mounts (e.g. ["/var/run/postgresql"]) for image runtime dirs that must
# be writable when the container runs as a non-default uid. # be writable when the container runs as a non-default uid.
tmpfs: list[str] = Field(default_factory=list) 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): class RunNode(LaunchBase):
@@ -308,6 +311,9 @@ class Requirement(BaseModel):
kind: Literal["system", "deployment", "tool"] = "deployment" kind: Literal["system", "deployment", "tool"] = "deployment"
ref: str ref: str
bind: str | None = None 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
# --------------------- # ---------------------

View File

@@ -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: def test_non_compose_runner_has_no_stop_cmd(tmp_path: Path) -> None:
run = RunPython(launcher="python", program="svc") run = RunPython(launcher="python", program="svc")
assert _build_stop_cmd("svc", run, tmp_path) == [] 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"]

View File

@@ -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"}

View File

@@ -274,7 +274,7 @@ Nested launch spec, discriminated union on `launcher`:
|----------|--------|------------| |----------|--------|------------|
| `python` | `uv run --project <source> --no-dev <program>` | `program`, `args` | | `python` | `uv run --project <source> --no-dev <program>` | `program`, `args` |
| `command` | `which(argv[0])` → resolved path | `argv` | | `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 <project> -f <file> up` (+ `ExecStop=down`) | `file`, `project_name` | | `compose` | `docker compose -p <project> -f <file> up` (+ `ExecStop=down`) | `file`, `project_name` |
| `node` | `package_manager run script` | `script`, `package_manager` | | `node` | `package_manager run script` | `script`, `package_manager` |

View File

@@ -51,6 +51,9 @@ requires:
- { ref: litellm, bind: LITELLM_URL } # bind: project the target's URL into env - { 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 A **program**'s host-package preconditions stay on the program as
`system_dependencies` (a plain list of package names); the model synthesizes the `system_dependencies` (a plain list of package names); the model synthesizes the
`{kind: system}` requirements from it for the `functional?` check. This split keeps `{kind: system}` requirements from it for the `functional?` check. This split keeps