diff --git a/Makefile b/Makefile index 92bda7e..3b9e72e 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 86630b6..5d855f9 100644 --- a/README.md +++ b/README.md @@ -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//_variants/` 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. diff --git a/demo/Dockerfile.gpu b/demo/Dockerfile.gpu new file mode 100644 index 0000000..efb30ad --- /dev/null +++ b/demo/Dockerfile.gpu @@ -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"] diff --git a/demo/Dockerfile.null b/demo/Dockerfile.null new file mode 100644 index 0000000..21ad1fd --- /dev/null +++ b/demo/Dockerfile.null @@ -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"] diff --git a/demo/profiles/cpu-only.json b/demo/profiles/cpu-only.json new file mode 100644 index 0000000..0607703 --- /dev/null +++ b/demo/profiles/cpu-only.json @@ -0,0 +1,3 @@ +{ + "x86_64": { "level": ["v3", "v2", "v1"] } +} diff --git a/demo/profiles/gpu-cuda126.json b/demo/profiles/gpu-cuda126.json new file mode 100644 index 0000000..00c7cf5 --- /dev/null +++ b/demo/profiles/gpu-cuda126.json @@ -0,0 +1,4 @@ +{ + "nvidia": { "cuda_version_lower_bound": ["12.6", "12.4", "12.2", "12.0"] }, + "x86_64": { "level": ["v3", "v2", "v1"] } +} diff --git a/demo/profiles/gpu-cuda128.json b/demo/profiles/gpu-cuda128.json new file mode 100644 index 0000000..4633812 --- /dev/null +++ b/demo/profiles/gpu-cuda128.json @@ -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"] } +} diff --git a/demo/run-demo.sh b/demo/run-demo.sh new file mode 100755 index 0000000..8fc3210 --- /dev/null +++ b/demo/run-demo.sh @@ -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."