Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions docs/decisions/0030-stack-device-mappings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# ADR-0030: Host device mappings are a per-stack setting, injected through the generated override

## Status

Accepted

## Context

Some workloads need a host device inside a container — the canonical case is GPU-accelerated
transcoding via `/dev/dri/renderD128`. Compose expresses this with the service-level `devices:` key,
but the value is inherently **host-specific**: which render node exists (and whether one exists at
all) differs per machine, while the compose file in the product's repository is shared by every stack
of that product on every host (ADR-0026). Committing a device path to the repository either breaks
the deployments that lack the device or forces every consumer to define pass-through variables for
something that is not the application's concern.

Watchtower already has exactly one mechanism for adding configuration to somebody else's compose file
without touching the repository: the generated override file merged in as a second `--file`
(ADR-0012), which today carries injected environment variables and release image pins (ADR-0026
decision 6). And it has a house precedent for per-service UI configuration: the backup service
overrides of ADR-0020, stored per `(stack, service)` and replaced whole on save.

Compose's merge rule for `devices:` (like `volumes:`) merges entries **by container path**: entries
in a later file with a new target are appended, and an entry with the same target replaces the
earlier one. So an override file can add devices to a service that declares none, and coexist with a
repository that declares some.

## Decision

1. **Device mappings are stored per stack, keyed by compose service name** — a
`stack_device_mappings` row per device: service, host path, container path, optional cgroup
permissions (`r`/`w`/`m`). The set is replaced atomically via `stacks.setDevices` (the
`stacks.setEnv` shape), read via `stacks.getDevices`, and edited in the stack's Settings tab. Rows
are keyed by service name, not container id, so they survive redeploys and apply to every replica.

2. **The deploy renders them into the ADR-0012 generated override** as a `devices:` list under the
service. The policy half is a `DeviceMappingPlan` — runtime-neutral per ADR-0010's seam rule: it
names no Compose concept, so a future Kubernetes engine could apply the same plan as
`volumeDevices`/CDI annotations — and only `ComposeOverrideFile` knows what YAML it becomes.

3. **A mapping for a service the resolved project does not contain is a warning, not a failure** —
the same tolerance as image pinning: services come and go with the repository, and failing the
deploy would break a fleet over a leftover row. The warning lands in the deploy output; so does
one line per applied device, so "why does this container see the GPU" is answerable from the
deploy log alone.

4. **On a container-path collision, the Watchtower mapping wins** (Compose's own merge semantics).
This deliberately inverts ADR-0020's "labels win": device paths are per-host facts, and the
per-host value must be able to override a repository default — the repo may declare a generic
`/dev/dri`, one host may need a specific card. The deploy log names every applied device, so
nothing is silently overridden. ADR-0014's actual hazard — a UI edit that silently never takes
effect — cannot occur here, because the override always applies.

5. **No template-level twin, no NVIDIA `gpus`/CDI support for now.** Device paths are host-specific,
which is the opposite of what a template shares across tenants; a fleet-wide default can be added
later if a real need appears. NVIDIA GPUs want `deploy.resources.reservations.devices` (a
different mechanism); out of scope until asked for.

## Consequences

- A host GPU (or serial port, TPU, …) reaches a stack's container with zero repository changes, and
the same repository deploys unchanged on hosts without the device.
- The mapping grants the container access to a host device node — an operator-level capability, so
the change is audit-logged like other stack lifecycle operations.
- The generated override is no longer byte-identical to its pre-device form only when mappings exist;
a stack with none renders exactly what it rendered before (the ADR-0012/0026 invariant holds).
- Devices configured here are invisible to `docker compose` invocations made outside Watchtower —
consistent with ADR-0012, which already accepted that Watchtower owns the whole invocation.
76 changes: 76 additions & 0 deletions docs/decisions/0031-host-gpu-passthrough-by-intent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# ADR-0031: "Map host GPUs" is a per-service intent, resolved by probing the Docker host at deploy time

## Status

Accepted

## Context

ADR-0030 lets an operator map a host device like `/dev/dri/renderD128` into a stack's container by
literal path. For the case that motivated it — GPU-accelerated transcoding — literal paths are still
one notch too concrete:

- `renderD128` merely means "the first GPU in probe order". On a single-GPU host it is stable; the
number is an implementation detail either way, and the operator should not need to know it.
- The value differs per host, so a literal path cannot be shared — which is why ADR-0030 rejected
template-level mappings. An *intent* ("this service wants the host GPUs") is host-neutral and
could be shared.
- Mapping the node is not always enough: the container's user must be in the device node's owning
group (`render`/`video`), whose **GID differs per host**. This is the classic "device mapped but
VAAPI still fails" trap, and no literal-path UI can solve it.

The kernel makes the concrete facts cheaply and *deterministically* discoverable. DRM render nodes
are always `/dev/dri/renderD<N>` (minors from 128), and per node sysfs reports the PCI vendor id
(`/sys/class/drm/renderD<N>/device/vendor` — `0x8086` Intel, `0x1002` AMD, `0x10de` NVIDIA), the
bound driver (`uevent`, e.g. `i915`, `amdgpu`), and the PCI address; `stat` on the node gives the
owning group's GID. Watchtower's own container does not see the host's `/dev` — but the backup
feature already established the pattern for that: a short-lived helper container (ADR-0016's
`busybox:stable`, operator-configurable) with the needed paths bind-mounted.

NVIDIA is the deliberate odd one out: mapping `/dev/nvidia*` nodes is not sufficient (the container
also needs the toolkit-injected user-space driver), so a device mapping would *look* supported and
fail inconsistently — the worst outcome.

## Decision

1. **GPU passthrough is stored as an intent, keyed `(stack, service)`** — a `stack_gpu_mappings`
row meaning "map every mappable host GPU into this service". No paths are stored; the row is
host-neutral. It is edited in the same Settings section and replaced atomically by the same
`stacks.setDevices` call as the literal mappings (one save, one audit entry).

2. **A deploy resolves the intent against a live host probe.** `HostGpuProbe` runs the backup
helper image with the host's `/dev` and `/sys` bind-mounted read-only (`NetworkMode: none`, no
device grants — the default device cgroup denies opening the nodes; the probe only lists and
stats). It reports each render node's path, vendor, driver, PCI address and owning GID, cached
for a few minutes. A probe failure is a deploy-log warning and an empty catalog — never a failed
deploy, and never a blocker for stacks that use no GPU intent.

3. **Resolution maps render nodes and injects the group.** Every non-NVIDIA render node becomes a
`devices:` entry (same generated-override mechanism as ADR-0030), and the union of the mapped
nodes' GIDs becomes a `group_add:` list on the service — Compose appends `group_add`, so the
repository's own entries survive. NVIDIA nodes are skipped with a deploy-log note naming the
toolkit (`gpus:`/CDI) as the supported route — a later ADR when someone needs it.

4. **A host without a mappable GPU is a note, not a warning.** That is the feature working as
designed — the same stack deploys everywhere and gets the GPU where one exists. Unknown service
names keep ADR-0030's warning treatment.

5. **The plan stays runtime-neutral** (ADR-0010): `DeviceMappingPlan` gains GPU intents, the probed
catalog, and per-service supplemental group ids — all concepts Kubernetes expresses natively
(`volumeDevices`/CDI, `supplementalGroups`). Only `ComposeOverrideFile` knows about `devices:`
and `group_add:` syntax.

## Consequences

- The Settings UI can offer "map host GPU(s)" per service plus a read-out of what the probe found
("renderD128 — intel, i915, 0000:00:02.0"), including the honest empty state on GPU-less hosts
and on Docker Desktop.
- Because the intent is host-neutral, template-level sharing becomes possible later — the reason
ADR-0030 rejected it (literal paths) does not apply to intents. Not built yet.
- Multi-GPU hosts map *all* mappable GPUs. Selecting a specific one (which would need the stable
`/dev/dri/by-path/pci-…-render` alias to survive probe-order shuffles) is deferred until a real
multi-GPU need appears; the literal-path editor covers it meanwhile.
- The probe's device list is only as fresh as its cache and container hot-plug does not exist in
Docker's model anyway: a GPU that appears or vanishes takes effect on the next deploy.
- One more place runs the helper image; it inherits the backup feature's pull-on-first-use and
operator-configurable image reference.
2 changes: 2 additions & 0 deletions docs/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,5 @@ live here.
- [ADR-0027: Watchtower backs itself up, and a bundle restores it somewhere else](0027-full-instance-backup-and-restore.md)
- [ADR-0028: CI runners carry the host's BuildKit knowledge — a generated default buildkitd config, and a reusable docker-driver workflow](0028-ci-buildkit-defaults.md)
- [ADR-0029: Zero-downtime deploys — routed services warm up in a new generation, then traffic swaps](0029-blue-green-stack-deploys.md) — *proposed*
- [ADR-0030: Host device mappings are a per-stack setting, injected through the generated override](0030-stack-device-mappings.md)
- [ADR-0031: "Map host GPUs" is a per-service intent, resolved by probing the Docker host at deploy time](0031-host-gpu-passthrough-by-intent.md)
219 changes: 219 additions & 0 deletions rpc-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -10922,6 +10922,67 @@
]
}
},
"stacks.getDevices": {
"params": {
"type": "object",
"properties": {
"stackId": {
"type": "integer"
}
},
"required": [
"stackId"
]
},
"result": {
"type": "object",
"properties": {
"devices": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"service": {
"type": "string"
},
"hostPath": {
"type": "string"
},
"containerPath": {
"type": "string"
},
"permissions": {
"type": [
"string",
"null"
]
}
},
"required": [
"id",
"service",
"hostPath",
"containerPath",
"permissions"
]
}
},
"gpuServices": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"devices",
"gpuServices"
]
}
},
"stacks.getEnv": {
"params": {
"type": "object",
Expand Down Expand Up @@ -10965,6 +11026,60 @@
]
}
},
"stacks.hostGpus": {
"params": {
"type": "object"
},
"result": {
"type": "object",
"properties": {
"gpus": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"path": {
"type": "string"
},
"vendor": {
"type": "string"
},
"driver": {
"type": "string"
},
"pciAddress": {
"type": "string"
},
"mappable": {
"type": "boolean"
}
},
"required": [
"name",
"path",
"vendor",
"driver",
"pciAddress",
"mappable"
]
}
},
"error": {
"type": [
"string",
"null"
]
}
},
"required": [
"gpus",
"error"
]
}
},
"stacks.list": {
"params": {
"type": "object"
Expand Down Expand Up @@ -11238,6 +11353,110 @@
]
}
},
"stacks.setDevices": {
"params": {
"type": "object",
"properties": {
"stackId": {
"type": "integer"
},
"devices": {
"type": "array",
"items": {
"type": "object",
"properties": {
"service": {
"type": "string"
},
"hostPath": {
"type": "string"
},
"containerPath": {
"type": [
"string",
"null"
],
"default": null
},
"permissions": {
"type": [
"string",
"null"
],
"default": null
}
},
"required": [
"service",
"hostPath"
]
}
},
"gpuServices": {
"type": [
"array",
"null"
],
"items": {
"type": "string"
},
"default": null
}
},
"required": [
"stackId",
"devices"
]
},
"result": {
"type": "object",
"properties": {
"devices": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"service": {
"type": "string"
},
"hostPath": {
"type": "string"
},
"containerPath": {
"type": "string"
},
"permissions": {
"type": [
"string",
"null"
]
}
},
"required": [
"id",
"service",
"hostPath",
"containerPath",
"permissions"
]
}
},
"gpuServices": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"devices",
"gpuServices"
]
}
},
"stacks.setEnv": {
"params": {
"type": "object",
Expand Down
Loading
Loading