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
14 changes: 13 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
GO ?= go
PLUGIN_DIR ?= $(HOME)/.docker/cli-plugins

.PHONY: test vet fmt tidy e2e
.PHONY: build install-plugin demo test vet fmt tidy e2e

build:
$(GO) build -o bin/docker-variant ./cmd/docker-variant
$(GO) build -o bin/variant-proxy ./cmd/variant-proxy

install-plugin: build
mkdir -p $(PLUGIN_DIR)
install bin/docker-variant $(PLUGIN_DIR)/docker-variant

demo:
./demo/run-demo.sh

e2e:
./e2e/run.sh
Expand Down
105 changes: 104 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,105 @@
# docker-variant
Docker extension with PEP 817-like variant properties matching for container images

Hardware-variant-aware container image selection for Docker, modeled on
Python's [PEP 817 wheel variants](https://peps.python.org/pep-0817/).

Publish one *base version* of an image as several *variants* — say a CUDA
12.8 build, a CUDA 12.6 build, and a CPU-only fallback — and let the client
pick the right one for the machine it runs on:

```console
$ docker variant pull registry.example.com/myorg/app:2.1.0
Selected variant "cu128" of registry.example.com/myorg/app version 2.1.0
...
Tagged registry.example.com/myorg/app:2.1.0 and registry.example.com/myorg/app:2.1.0-cu128
```

The same command on a GPU-less machine selects the `null` (CPU-only)
variant, and on a machine with no compatible variant at all it falls back to
the plain `2.1.0` tag.

## How it works

PEP 817's wheel-variant concepts are mapped onto standard Docker/OCI
primitives — no registry modifications required:

| PEP 817 | docker-variant |
|---|---|
| variant label in the wheel filename | tag suffix: `app:2.1.0-cu128` |
| variant properties (`ns :: feature :: value`) | image config labels: `dev.pep817.variant.nvidia.cuda_version_lower_bound=12.8` |
| `{name}-{version}-variants.json` on the index | variant index: an OCI artifact at `app:2.1.0-variants` mapping labels → properties + manifest **digests** |
| provider plugins (hardware detection) | compiled-in detectors (NVIDIA via nvidia-smi, x86-64 levels via CPUID, arm64) |
| null variant | `app:2.1.0-null`, zero properties, always compatible, ranked last |

Declaring a variant is just Dockerfile labels:

```dockerfile
LABEL dev.pep817.variant-label="cu128" \
dev.pep817.variant.nvidia.cuda_version_lower_bound="12.8"
```

`docker variant pull` fetches the index, detects the local hardware, ranks
the compatible variants (index priorities → system preference →
deterministic tie-breaks), and pulls the winner **by digest**.

## Commands

| Command | What it does |
|---|---|
| `docker variant detect` | Show this machine's variant properties |
| `docker variant pull REPO:VERSION` | Pull the best-matching variant (`--dry-run`, `--properties-file`, `--no-fallback`) |
| `docker variant push REPO:VERSION-LABEL` | Push a variant tag and update the repository's variant index |
| `docker variant list REPO:VERSION` | Table of variants with match ranking for this host |
| `docker variant inspect REPO:VERSION` | Raw variant index JSON |
| `docker variant index update REPO:VERSION` | Rebuild the index by scanning the registry's tags |

All registry-touching commands accept `--plain-http`; loopback registries
use plain HTTP automatically.

## Registry side

Two deployment modes:

1. **Any V2 registry** (Docker Hub, registry:2, Harbor, …): `variant push` /
`index update` maintain the `-variants` index artifact from the client.
2. **variant-proxy**: a stateless reverse proxy in front of the registry
that serves `GET /v2/<name>/_variants/<version>` computed on the fly from
image labels — publishers then need nothing but plain `docker push`.
The client tries the endpoint first and falls back to the artifact.

```console
$ variant-proxy --listen :5599 --upstream registry.internal:5000
```

## Try it

```console
$ make demo # full walkthrough against a throwaway local registry
$ make install-plugin # installs into ~/.docker/cli-plugins => `docker variant ...`
$ make e2e # the same flows as CI assertions
```

The demo builds three variants of a runnable hello image, publishes them,
then pulls under three mocked hardware profiles (`demo/profiles/*.json`) so
you can watch the selection change — and repeats the flow through
variant-proxy with plain `docker push` only.

## Development

```console
$ make test # unit tests (pkg/variant is pure and I/O-free)
$ make vet
$ make e2e # needs docker
```

Design and rationale: [docs/PLAN.md](docs/PLAN.md) (roadmap, plugin-vs-fork
and registry-side decisions) and [docs/DESIGN.md](docs/DESIGN.md) (frozen v1
contracts: label schema, index schema, selection algorithm, ADRs).

## Status / roadmap

Working end-to-end (see `e2e/run.sh`): publish, list, select, pull,
fallback chain, proxy. Planned next: OCI 1.1 referrers-based index
attachment, index signing (cosign), an exec-based detection-provider
protocol behind explicit opt-in, and a moby fork PoC to demonstrate native
`docker pull` integration.
10 changes: 10 additions & 0 deletions demo/Dockerfile.gpu
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# A GPU variant of the demo app: the variant label and its property are
# plain Dockerfile LABELs — no special tooling needed at build time.
FROM busybox
ARG VARIANT
ARG CUDA_LOWER_BOUND
LABEL dev.pep817.variant-label="${VARIANT}"
LABEL dev.pep817.variant.nvidia.cuda_version_lower_bound="${CUDA_LOWER_BOUND}"
RUN printf 'echo "Hello from demo-app 1.0.0, variant: %s (built for CUDA >= %s)"\n' \
"${VARIANT}" "${CUDA_LOWER_BOUND}" > /hello.sh
CMD ["/bin/sh", "/hello.sh"]
5 changes: 5 additions & 0 deletions demo/Dockerfile.null
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# The null variant: a safe CPU-only fallback with zero variant properties.
FROM busybox
LABEL dev.pep817.variant-label="null"
RUN printf 'echo "Hello from demo-app 1.0.0, variant: null (CPU-only fallback)"\n' > /hello.sh
CMD ["/bin/sh", "/hello.sh"]
3 changes: 3 additions & 0 deletions demo/profiles/cpu-only.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"x86_64": { "level": ["v3", "v2", "v1"] }
}
4 changes: 4 additions & 0 deletions demo/profiles/gpu-cuda126.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"nvidia": { "cuda_version_lower_bound": ["12.6", "12.4", "12.2", "12.0"] },
"x86_64": { "level": ["v3", "v2", "v1"] }
}
4 changes: 4 additions & 0 deletions demo/profiles/gpu-cuda128.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"nvidia": { "cuda_version_lower_bound": ["12.8", "12.6", "12.4", "12.2", "12.0"] },
"x86_64": { "level": ["v3", "v2", "v1"] }
}
106 changes: 106 additions & 0 deletions demo/run-demo.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# Interactive walkthrough of docker-variant: publishes three variants of a
# runnable demo app to a local registry, then shows how `variant pull`
# selects a different image per (mocked) hardware profile — first with
# client-maintained indexes, then with the server-computed variant-proxy.
#
# Requires: docker, the Go toolchain, curl. Everything runs against
# disposable local containers; pass --keep to leave them running for
# exploration.
set -euo pipefail

cd "$(dirname "$0")/.."
REG_PORT="${DEMO_REGISTRY_PORT:-5595}"
PROXY_PORT="${DEMO_PROXY_PORT:-5596}"
REGISTRY="127.0.0.1:${REG_PORT}"
PROXY="127.0.0.1:${PROXY_PORT}"
REPO="${REGISTRY}/demo/app"
PROXY_REPO="${PROXY}/demo/proxied-app"
CONTAINER="variant-demo-registry"
KEEP=false
[ "${1:-}" = "--keep" ] && KEEP=true

BIN=bin/docker-variant
PROXY_BIN=bin/variant-proxy
PROXY_PID=""

say() { printf '\n\033[1;34m==> %s\033[0m\n' "$*"; }
run() { printf '\033[0;33m$ %s\033[0m\n' "$*"; "$@"; }

cleanup() {
$KEEP && { echo; echo "(--keep: registry ${REGISTRY} and proxy ${PROXY} left running)"; return; }
[ -n "${PROXY_PID}" ] && kill "${PROXY_PID}" >/dev/null 2>&1 || true
docker rm -f "${CONTAINER}" >/dev/null 2>&1 || true
docker rmi -f \
"${REPO}:1.0.0" "${REPO}:1.0.0-cu128" "${REPO}:1.0.0-cu126" "${REPO}:1.0.0-null" \
"${PROXY_REPO}:1.0.0" "${PROXY_REPO}:1.0.0-cu128" "${PROXY_REPO}:1.0.0-null" >/dev/null 2>&1 || true
}
trap cleanup EXIT

say "Building docker-variant and variant-proxy"
run make build

say "Starting a throwaway registry at ${REGISTRY}"
docker rm -f "${CONTAINER}" >/dev/null 2>&1 || true
run docker run -d --name "${CONTAINER}" -p "127.0.0.1:${REG_PORT}:5000" registry:2
for _ in $(seq 1 30); do curl -fsS "http://${REGISTRY}/v2/" >/dev/null 2>&1 && break; sleep 0.5; done

say "Building three variants of demo-app 1.0.0 (labels are plain Dockerfile LABELs)"
run docker build -q -f demo/Dockerfile.gpu --build-arg VARIANT=cu128 --build-arg CUDA_LOWER_BOUND=12.8 \
-t "${REPO}:1.0.0-cu128" demo
run docker build -q -f demo/Dockerfile.gpu --build-arg VARIANT=cu126 --build-arg CUDA_LOWER_BOUND=12.6 \
-t "${REPO}:1.0.0-cu126" demo
run docker build -q -f demo/Dockerfile.null -t "${REPO}:1.0.0-null" demo

say "Publishing them with 'variant push' (pushes the tag + updates the variant index artifact)"
run "${BIN}" variant push "${REPO}:1.0.0-cu128"
run "${BIN}" variant push "${REPO}:1.0.0-cu126"
run "${BIN}" variant push "${REPO}:1.0.0-null"

say "The variant index is a plain OCI artifact at ${REPO}:1.0.0-variants"
run "${BIN}" variant inspect "${REPO}:1.0.0"

say "What this machine actually looks like"
run "${BIN}" variant detect || true

say "Ranking under three hardware profiles (mocked with --properties-file)"
for profile in gpu-cuda128 gpu-cuda126 cpu-only; do
echo
echo "--- profile: ${profile} ---"
run "${BIN}" variant list "${REPO}:1.0.0" --properties-file "demo/profiles/${profile}.json"
done

say "'variant pull' on a CUDA 12.8 machine"
run "${BIN}" variant pull "${REPO}:1.0.0" --properties-file demo/profiles/gpu-cuda128.json
run docker run --rm "${REPO}:1.0.0"

say "'variant pull' on a CPU-only machine (same command, different hardware)"
docker rmi -f "${REPO}:1.0.0" >/dev/null
run "${BIN}" variant pull "${REPO}:1.0.0" --properties-file demo/profiles/cpu-only.json
run docker run --rm "${REPO}:1.0.0"

say "Phase B: variant-proxy computes the index server-side from labels"
run_bg() { printf '\033[0;33m$ %s &\033[0m\n' "$*"; "$@" & }
run_bg "${PROXY_BIN}" --listen "127.0.0.1:${PROXY_PORT}" --upstream "${REGISTRY}"
PROXY_PID=$!
for _ in $(seq 1 30); do curl -fsS "http://${PROXY}/v2/" >/dev/null 2>&1 && break; sleep 0.5; done

echo
echo "Publish through the proxy with NOTHING but plain 'docker push':"
docker tag "${REPO}:1.0.0-cu128" "${PROXY_REPO}:1.0.0-cu128"
docker tag "${REPO}:1.0.0-null" "${PROXY_REPO}:1.0.0-null"
run docker push -q "${PROXY_REPO}:1.0.0-cu128"
run docker push -q "${PROXY_REPO}:1.0.0-null"

echo
echo "The proxy serves the index computed from the image labels:"
run curl -fsS "http://${PROXY}/v2/demo/proxied-app/_variants/1.0.0"

echo
echo "...and 'variant pull' works against it the same way:"
run "${BIN}" variant pull "${PROXY_REPO}:1.0.0" --properties-file demo/profiles/gpu-cuda128.json
run docker run --rm "${PROXY_REPO}:1.0.0"

say "Done"
echo "Tip: 'make install-plugin' installs the binary into ~/.docker/cli-plugins/"
echo "so all of the above works as native 'docker variant ...' commands."
Loading