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:
@@ -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 []
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ---------------------
|
||||
|
||||
@@ -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"]
|
||||
|
||||
48
core/tests/test_requires_env.py
Normal file
48
core/tests/test_requires_env.py
Normal 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"}
|
||||
@@ -274,7 +274,7 @@ Nested launch spec, discriminated union on `launcher`:
|
||||
|----------|--------|------------|
|
||||
| `python` | `uv run --project <source> --no-dev <program>` | `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 <project> -f <file> up` (+ `ExecStop=down`) | `file`, `project_name` |
|
||||
| `node` | `package_manager run script` | `script`, `package_manager` |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user