From df66dfec867643e2eb025eb37680033c590d0813 Mon Sep 17 00:00:00 2001 From: Joongi Kim Date: Mon, 27 Jul 2026 03:26:24 +0000 Subject: [PATCH 1/2] feat: `docker variant pull` with fallback chain, plus e2e harness pull fetches the variant index, resolves system properties (real detection or --properties-file), ranks compatible variants, and pulls the best match by digest, tagging the result as both the base tag and the selected variant tag (ADR-6). Missing index or no compatible variant falls back to the plain base tag with a notice (the null variant, when indexed, is preferred over that fallback by construction); --no-fallback makes those cases hard errors and --dry-run prints the full ranking without pulling. e2e/run.sh exercises the whole loop against a disposable registry:2: variant pushes, plain-push + index update reconciliation, selection under three mocked hardware profiles (asserted via the variant label of the locally tagged result), dry-run, fallback, and --no-fallback. Wired into CI as a second job and `make e2e`. Claude-Session: https://claude.ai/code/session_01D383U8kkQkJc1yzyC5H5Nk --- .github/workflows/ci.yml | 10 ++++ Makefile | 5 +- cmd/docker-variant/main.go | 1 + cmd/docker-variant/pull.go | 104 +++++++++++++++++++++++++++++++++ e2e/run.sh | 117 +++++++++++++++++++++++++++++++++++++ 5 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 cmd/docker-variant/pull.go create mode 100755 e2e/run.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4bfac90..f2046c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,3 +19,13 @@ jobs: run: go vet ./... - name: unit tests run: go test ./... + + e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: end-to-end tests + run: ./e2e/run.sh diff --git a/Makefile b/Makefile index 863781a..92bda7e 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,9 @@ GO ?= go -.PHONY: test vet fmt tidy +.PHONY: test vet fmt tidy e2e + +e2e: + ./e2e/run.sh test: $(GO) test ./... diff --git a/cmd/docker-variant/main.go b/cmd/docker-variant/main.go index b953c15..7ac88b2 100644 --- a/cmd/docker-variant/main.go +++ b/cmd/docker-variant/main.go @@ -62,6 +62,7 @@ func newVariantCommand() *cobra.Command { } cmd.AddCommand( newDetectCommand(), + newPullCommand(), newPushCommand(), newListCommand(), newInspectCommand(), diff --git a/cmd/docker-variant/pull.go b/cmd/docker-variant/pull.go new file mode 100644 index 0000000..14e0c93 --- /dev/null +++ b/cmd/docker-variant/pull.go @@ -0,0 +1,104 @@ +package main + +import ( + "errors" + "fmt" + + "github.com/spf13/cobra" + + "github.com/achimnol/docker-variant/pkg/docker" + "github.com/achimnol/docker-variant/pkg/oci" + "github.com/achimnol/docker-variant/pkg/variant" +) + +func newPullCommand() *cobra.Command { + var ( + regFlags registryFlags + propertiesFile string + dryRun bool + noFallback bool + ) + cmd := &cobra.Command{ + Use: "pull REGISTRY/REPOSITORY:VERSION", + Short: "Pull the best-matching variant of a base version for this system", + Long: "Fetches the repository's variant index, detects this system's\n" + + "variant properties, ranks the compatible variants, and pulls the\n" + + "best match by digest. The result is tagged as both the base tag\n" + + "and the selected variant tag. Without an index or a compatible\n" + + "variant, falls back to pulling the plain base tag.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + out := cmd.OutOrStdout() + ref, err := versionRef(args[0]) + if err != nil { + return err + } + client, err := regFlags.client() + if err != nil { + return err + } + + fallback := func(reason string) error { + if noFallback { + return fmt.Errorf("%s (and --no-fallback is set)", reason) + } + fmt.Fprintf(out, "%s; falling back to %s\n", reason, args[0]) + if dryRun { + fmt.Fprintf(out, "Would pull %s\n", args[0]) + return nil + } + return docker.Pull(ctx, args[0]) + } + + ix, err := client.FetchIndex(ctx, ref, ref.Tag) + if errors.Is(err, oci.ErrNoIndex) { + return fallback(fmt.Sprintf("No variant index for %s version %s", ref.Name(), ref.Tag)) + } else if err != nil { + return err + } + sys, err := systemProperties(cmd, propertiesFile) + if err != nil { + return err + } + ranked := variant.Rank(ix, sys) + if len(ranked) == 0 { + return fallback("No variant is compatible with this system") + } + + best := ranked[0] + fmt.Fprintf(out, "Selected variant %q of %s version %s\n", best.Label, ref.Name(), ref.Tag) + if dryRun { + for i, r := range ranked { + fmt.Fprintf(out, " #%d %-10s %s (%s)\n", i+1, r.Label, r.Entry.Digest, r.Entry.Tag) + } + fmt.Fprintf(out, "Would pull %s and tag it as %s and %s\n", + ref.WithDigest(best.Entry.Digest), args[0], ref.WithTag(best.Entry.Tag)) + return nil + } + if best.Entry.Digest == "" { + return fmt.Errorf("index entry %q has no digest; run `docker variant index update`", best.Label) + } + if err := docker.Pull(ctx, ref.WithDigest(best.Entry.Digest)); err != nil { + return err + } + // ADR-6: the base tag is what the user asked for; the variant tag + // records what was actually selected. + for _, tag := range []string{ref.Tag, best.Entry.Tag} { + if tag == "" { + continue + } + if err := docker.Tag(ctx, ref.WithDigest(best.Entry.Digest), ref.WithTag(tag)); err != nil { + return err + } + } + fmt.Fprintf(out, "Tagged %s and %s\n", args[0], ref.WithTag(best.Entry.Tag)) + return nil + }, + } + regFlags.add(cmd) + cmd.Flags().StringVar(&propertiesFile, "properties-file", "", "read system properties from a JSON file instead of detecting") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print the selection without pulling") + cmd.Flags().BoolVar(&noFallback, "no-fallback", false, "fail instead of falling back to the plain base tag") + return cmd +} diff --git a/e2e/run.sh b/e2e/run.sh new file mode 100755 index 0000000..bbdbb30 --- /dev/null +++ b/e2e/run.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# End-to-end test: variant push / index update / list / pull against a +# disposable local registry:2 container, with detection mocked via +# properties files. Requires docker and the Go toolchain. +set -euo pipefail + +cd "$(dirname "$0")/.." +PORT="${E2E_REGISTRY_PORT:-5591}" +REGISTRY="127.0.0.1:${PORT}" +REPO="${REGISTRY}/e2e/app" +PLAIN_REPO="${REGISTRY}/e2e/plain" +WORKDIR="$(mktemp -d)" +BIN="${WORKDIR}/docker-variant" +CONTAINER="variant-e2e-registry-${PORT}" + +fail() { echo "FAIL: $*" >&2; exit 1; } + +cleanup() { + 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" \ + "${PLAIN_REPO}:1.0.0" >/dev/null 2>&1 || true + rm -rf "${WORKDIR}" +} +trap cleanup EXIT + +echo "==> building plugin" +go build -o "${BIN}" ./cmd/docker-variant + +echo "==> starting registry on ${REGISTRY}" +docker rm -f "${CONTAINER}" >/dev/null 2>&1 || true +docker run -d --name "${CONTAINER}" -p "127.0.0.1:${PORT}:5000" registry:2 >/dev/null +for _ in $(seq 1 30); do + curl -fsS "http://${REGISTRY}/v2/" >/dev/null 2>&1 && break + sleep 0.5 +done + +build_variant() { # label, extra LABEL lines... + local label="$1"; shift + local dir="${WORKDIR}/img-${label}" + mkdir -p "${dir}" + echo "payload for ${label}" > "${dir}/payload.txt" + { + echo 'FROM scratch' + echo 'COPY payload.txt /payload.txt' + echo "LABEL dev.pep817.variant-label=\"${label}\"" + for l in "$@"; do echo "LABEL ${l}"; done + } > "${dir}/Dockerfile" + docker build -q -t "${REPO}:1.0.0-${label}" "${dir}" >/dev/null +} + +echo "==> building and pushing variants" +build_variant cu128 'dev.pep817.variant.nvidia.cuda_version_lower_bound="12.8"' +build_variant cu126 'dev.pep817.variant.nvidia.cuda_version_lower_bound="12.6"' +build_variant null +"${BIN}" variant push "${REPO}:1.0.0-cu128" >/dev/null +"${BIN}" variant push "${REPO}:1.0.0-cu126" >/dev/null +# The null variant goes in via plain docker push + index update (exercises +# the registry-scan reconciliation path). +docker push -q "${REPO}:1.0.0-null" >/dev/null +"${BIN}" variant index update "${REPO}:1.0.0" >/dev/null + +echo "==> index contents" +"${BIN}" variant inspect "${REPO}:1.0.0" | grep -q '"cu128"' || fail "index missing cu128" +"${BIN}" variant inspect "${REPO}:1.0.0" | grep -q '"null"' || fail "index missing null (index update did not discover it)" + +cat > "${WORKDIR}/gpu128.json" <<'EOF' +{"nvidia": {"cuda_version_lower_bound": ["12.8", "12.6", "12.4", "12.2", "12.0"]}} +EOF +cat > "${WORKDIR}/gpu126.json" <<'EOF' +{"nvidia": {"cuda_version_lower_bound": ["12.6", "12.4", "12.2", "12.0"]}} +EOF +cat > "${WORKDIR}/cpu.json" <<'EOF' +{"x86_64": {"level": ["v3", "v2", "v1"]}} +EOF + +pulled_label() { # what the local base tag resolved to + docker image inspect --format '{{ index .Config.Labels "dev.pep817.variant-label" }}' "${REPO}:1.0.0" +} + +check_pull() { # properties-file, expected label + local props="$1" expected="$2" + docker rmi -f "${REPO}:1.0.0" >/dev/null 2>&1 || true + "${BIN}" variant pull "${REPO}:1.0.0" --properties-file "${props}" >/dev/null + local got + got="$(pulled_label)" + [ "${got}" = "${expected}" ] || fail "pull with $(basename "${props}"): got variant ${got}, want ${expected}" + echo " pull with $(basename "${props}") -> ${got} (ok)" +} + +echo "==> pull selects per mocked hardware" +check_pull "${WORKDIR}/gpu128.json" cu128 +check_pull "${WORKDIR}/gpu126.json" cu126 +check_pull "${WORKDIR}/cpu.json" null + +echo "==> dry-run ranks without pulling" +"${BIN}" variant pull "${REPO}:1.0.0" --properties-file "${WORKDIR}/gpu128.json" --dry-run \ + | grep -q 'Selected variant "cu128"' || fail "dry-run did not select cu128" + +echo "==> fallback to plain tag when no index exists" +dir="${WORKDIR}/img-plain" +mkdir -p "${dir}" +echo plain > "${dir}/payload.txt" +printf 'FROM scratch\nCOPY payload.txt /payload.txt\n' > "${dir}/Dockerfile" +docker build -q -t "${PLAIN_REPO}:1.0.0" "${dir}" >/dev/null +docker push -q "${PLAIN_REPO}:1.0.0" >/dev/null +docker rmi -f "${PLAIN_REPO}:1.0.0" >/dev/null +out="$("${BIN}" variant pull "${PLAIN_REPO}:1.0.0")" || fail "fallback pull failed" +echo "${out}" | grep -q 'falling back' || fail "expected fallback notice" +docker image inspect "${PLAIN_REPO}:1.0.0" >/dev/null || fail "fallback did not pull the plain tag" + +echo "==> --no-fallback fails cleanly" +if "${BIN}" variant pull "${PLAIN_REPO}:1.0.0" --no-fallback >/dev/null 2>&1; then + fail "--no-fallback should have failed" +fi + +echo "PASS" From 5b04718bbd88928eb928469ca0bd8ee0bb072fb8 Mon Sep 17 00:00:00 2001 From: Joongi Kim Date: Mon, 27 Jul 2026 03:33:45 +0000 Subject: [PATCH 2/2] fix: deflake e2e dry-run check (grep -q SIGPIPE under pipefail) grep -q exits on the first match; if the plugin is still writing its ranking output, the write hits a closed pipe, Go's runtime raises SIGPIPE for stdout, and with pipefail the whole check fails. Capture the output first, as the fallback check already does. Claude-Session: https://claude.ai/code/session_01D383U8kkQkJc1yzyC5H5Nk --- e2e/run.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/e2e/run.sh b/e2e/run.sh index bbdbb30..ea6548b 100755 --- a/e2e/run.sh +++ b/e2e/run.sh @@ -94,8 +94,11 @@ check_pull "${WORKDIR}/gpu126.json" cu126 check_pull "${WORKDIR}/cpu.json" null echo "==> dry-run ranks without pulling" -"${BIN}" variant pull "${REPO}:1.0.0" --properties-file "${WORKDIR}/gpu128.json" --dry-run \ - | grep -q 'Selected variant "cu128"' || fail "dry-run did not select cu128" +# Capture instead of piping into grep -q: early grep exit would SIGPIPE the +# plugin mid-output and (with pipefail) flake the test. +out="$("${BIN}" variant pull "${REPO}:1.0.0" --properties-file "${WORKDIR}/gpu128.json" --dry-run)" \ + || fail "dry-run pull failed" +echo "${out}" | grep -q 'Selected variant "cu128"' || fail "dry-run did not select cu128" echo "==> fallback to plain tag when no index exists" dir="${WORKDIR}/img-plain"