Skip to content

Commit df66dfe

Browse files
committed
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
1 parent 9005178 commit df66dfe

5 files changed

Lines changed: 236 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,13 @@ jobs:
1919
run: go vet ./...
2020
- name: unit tests
2121
run: go test ./...
22+
23+
e2e:
24+
runs-on: ubuntu-latest
25+
steps:
26+
- uses: actions/checkout@v4
27+
- uses: actions/setup-go@v5
28+
with:
29+
go-version-file: go.mod
30+
- name: end-to-end tests
31+
run: ./e2e/run.sh

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
GO ?= go
22

3-
.PHONY: test vet fmt tidy
3+
.PHONY: test vet fmt tidy e2e
4+
5+
e2e:
6+
./e2e/run.sh
47

58
test:
69
$(GO) test ./...

cmd/docker-variant/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ func newVariantCommand() *cobra.Command {
6262
}
6363
cmd.AddCommand(
6464
newDetectCommand(),
65+
newPullCommand(),
6566
newPushCommand(),
6667
newListCommand(),
6768
newInspectCommand(),

cmd/docker-variant/pull.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package main
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
7+
"github.com/spf13/cobra"
8+
9+
"github.com/achimnol/docker-variant/pkg/docker"
10+
"github.com/achimnol/docker-variant/pkg/oci"
11+
"github.com/achimnol/docker-variant/pkg/variant"
12+
)
13+
14+
func newPullCommand() *cobra.Command {
15+
var (
16+
regFlags registryFlags
17+
propertiesFile string
18+
dryRun bool
19+
noFallback bool
20+
)
21+
cmd := &cobra.Command{
22+
Use: "pull REGISTRY/REPOSITORY:VERSION",
23+
Short: "Pull the best-matching variant of a base version for this system",
24+
Long: "Fetches the repository's variant index, detects this system's\n" +
25+
"variant properties, ranks the compatible variants, and pulls the\n" +
26+
"best match by digest. The result is tagged as both the base tag\n" +
27+
"and the selected variant tag. Without an index or a compatible\n" +
28+
"variant, falls back to pulling the plain base tag.",
29+
Args: cobra.ExactArgs(1),
30+
RunE: func(cmd *cobra.Command, args []string) error {
31+
ctx := cmd.Context()
32+
out := cmd.OutOrStdout()
33+
ref, err := versionRef(args[0])
34+
if err != nil {
35+
return err
36+
}
37+
client, err := regFlags.client()
38+
if err != nil {
39+
return err
40+
}
41+
42+
fallback := func(reason string) error {
43+
if noFallback {
44+
return fmt.Errorf("%s (and --no-fallback is set)", reason)
45+
}
46+
fmt.Fprintf(out, "%s; falling back to %s\n", reason, args[0])
47+
if dryRun {
48+
fmt.Fprintf(out, "Would pull %s\n", args[0])
49+
return nil
50+
}
51+
return docker.Pull(ctx, args[0])
52+
}
53+
54+
ix, err := client.FetchIndex(ctx, ref, ref.Tag)
55+
if errors.Is(err, oci.ErrNoIndex) {
56+
return fallback(fmt.Sprintf("No variant index for %s version %s", ref.Name(), ref.Tag))
57+
} else if err != nil {
58+
return err
59+
}
60+
sys, err := systemProperties(cmd, propertiesFile)
61+
if err != nil {
62+
return err
63+
}
64+
ranked := variant.Rank(ix, sys)
65+
if len(ranked) == 0 {
66+
return fallback("No variant is compatible with this system")
67+
}
68+
69+
best := ranked[0]
70+
fmt.Fprintf(out, "Selected variant %q of %s version %s\n", best.Label, ref.Name(), ref.Tag)
71+
if dryRun {
72+
for i, r := range ranked {
73+
fmt.Fprintf(out, " #%d %-10s %s (%s)\n", i+1, r.Label, r.Entry.Digest, r.Entry.Tag)
74+
}
75+
fmt.Fprintf(out, "Would pull %s and tag it as %s and %s\n",
76+
ref.WithDigest(best.Entry.Digest), args[0], ref.WithTag(best.Entry.Tag))
77+
return nil
78+
}
79+
if best.Entry.Digest == "" {
80+
return fmt.Errorf("index entry %q has no digest; run `docker variant index update`", best.Label)
81+
}
82+
if err := docker.Pull(ctx, ref.WithDigest(best.Entry.Digest)); err != nil {
83+
return err
84+
}
85+
// ADR-6: the base tag is what the user asked for; the variant tag
86+
// records what was actually selected.
87+
for _, tag := range []string{ref.Tag, best.Entry.Tag} {
88+
if tag == "" {
89+
continue
90+
}
91+
if err := docker.Tag(ctx, ref.WithDigest(best.Entry.Digest), ref.WithTag(tag)); err != nil {
92+
return err
93+
}
94+
}
95+
fmt.Fprintf(out, "Tagged %s and %s\n", args[0], ref.WithTag(best.Entry.Tag))
96+
return nil
97+
},
98+
}
99+
regFlags.add(cmd)
100+
cmd.Flags().StringVar(&propertiesFile, "properties-file", "", "read system properties from a JSON file instead of detecting")
101+
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print the selection without pulling")
102+
cmd.Flags().BoolVar(&noFallback, "no-fallback", false, "fail instead of falling back to the plain base tag")
103+
return cmd
104+
}

e2e/run.sh

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
#!/usr/bin/env bash
2+
# End-to-end test: variant push / index update / list / pull against a
3+
# disposable local registry:2 container, with detection mocked via
4+
# properties files. Requires docker and the Go toolchain.
5+
set -euo pipefail
6+
7+
cd "$(dirname "$0")/.."
8+
PORT="${E2E_REGISTRY_PORT:-5591}"
9+
REGISTRY="127.0.0.1:${PORT}"
10+
REPO="${REGISTRY}/e2e/app"
11+
PLAIN_REPO="${REGISTRY}/e2e/plain"
12+
WORKDIR="$(mktemp -d)"
13+
BIN="${WORKDIR}/docker-variant"
14+
CONTAINER="variant-e2e-registry-${PORT}"
15+
16+
fail() { echo "FAIL: $*" >&2; exit 1; }
17+
18+
cleanup() {
19+
docker rm -f "${CONTAINER}" >/dev/null 2>&1 || true
20+
docker rmi -f \
21+
"${REPO}:1.0.0" "${REPO}:1.0.0-cu128" "${REPO}:1.0.0-cu126" "${REPO}:1.0.0-null" \
22+
"${PLAIN_REPO}:1.0.0" >/dev/null 2>&1 || true
23+
rm -rf "${WORKDIR}"
24+
}
25+
trap cleanup EXIT
26+
27+
echo "==> building plugin"
28+
go build -o "${BIN}" ./cmd/docker-variant
29+
30+
echo "==> starting registry on ${REGISTRY}"
31+
docker rm -f "${CONTAINER}" >/dev/null 2>&1 || true
32+
docker run -d --name "${CONTAINER}" -p "127.0.0.1:${PORT}:5000" registry:2 >/dev/null
33+
for _ in $(seq 1 30); do
34+
curl -fsS "http://${REGISTRY}/v2/" >/dev/null 2>&1 && break
35+
sleep 0.5
36+
done
37+
38+
build_variant() { # label, extra LABEL lines...
39+
local label="$1"; shift
40+
local dir="${WORKDIR}/img-${label}"
41+
mkdir -p "${dir}"
42+
echo "payload for ${label}" > "${dir}/payload.txt"
43+
{
44+
echo 'FROM scratch'
45+
echo 'COPY payload.txt /payload.txt'
46+
echo "LABEL dev.pep817.variant-label=\"${label}\""
47+
for l in "$@"; do echo "LABEL ${l}"; done
48+
} > "${dir}/Dockerfile"
49+
docker build -q -t "${REPO}:1.0.0-${label}" "${dir}" >/dev/null
50+
}
51+
52+
echo "==> building and pushing variants"
53+
build_variant cu128 'dev.pep817.variant.nvidia.cuda_version_lower_bound="12.8"'
54+
build_variant cu126 'dev.pep817.variant.nvidia.cuda_version_lower_bound="12.6"'
55+
build_variant null
56+
"${BIN}" variant push "${REPO}:1.0.0-cu128" >/dev/null
57+
"${BIN}" variant push "${REPO}:1.0.0-cu126" >/dev/null
58+
# The null variant goes in via plain docker push + index update (exercises
59+
# the registry-scan reconciliation path).
60+
docker push -q "${REPO}:1.0.0-null" >/dev/null
61+
"${BIN}" variant index update "${REPO}:1.0.0" >/dev/null
62+
63+
echo "==> index contents"
64+
"${BIN}" variant inspect "${REPO}:1.0.0" | grep -q '"cu128"' || fail "index missing cu128"
65+
"${BIN}" variant inspect "${REPO}:1.0.0" | grep -q '"null"' || fail "index missing null (index update did not discover it)"
66+
67+
cat > "${WORKDIR}/gpu128.json" <<'EOF'
68+
{"nvidia": {"cuda_version_lower_bound": ["12.8", "12.6", "12.4", "12.2", "12.0"]}}
69+
EOF
70+
cat > "${WORKDIR}/gpu126.json" <<'EOF'
71+
{"nvidia": {"cuda_version_lower_bound": ["12.6", "12.4", "12.2", "12.0"]}}
72+
EOF
73+
cat > "${WORKDIR}/cpu.json" <<'EOF'
74+
{"x86_64": {"level": ["v3", "v2", "v1"]}}
75+
EOF
76+
77+
pulled_label() { # what the local base tag resolved to
78+
docker image inspect --format '{{ index .Config.Labels "dev.pep817.variant-label" }}' "${REPO}:1.0.0"
79+
}
80+
81+
check_pull() { # properties-file, expected label
82+
local props="$1" expected="$2"
83+
docker rmi -f "${REPO}:1.0.0" >/dev/null 2>&1 || true
84+
"${BIN}" variant pull "${REPO}:1.0.0" --properties-file "${props}" >/dev/null
85+
local got
86+
got="$(pulled_label)"
87+
[ "${got}" = "${expected}" ] || fail "pull with $(basename "${props}"): got variant ${got}, want ${expected}"
88+
echo " pull with $(basename "${props}") -> ${got} (ok)"
89+
}
90+
91+
echo "==> pull selects per mocked hardware"
92+
check_pull "${WORKDIR}/gpu128.json" cu128
93+
check_pull "${WORKDIR}/gpu126.json" cu126
94+
check_pull "${WORKDIR}/cpu.json" null
95+
96+
echo "==> dry-run ranks without pulling"
97+
"${BIN}" variant pull "${REPO}:1.0.0" --properties-file "${WORKDIR}/gpu128.json" --dry-run \
98+
| grep -q 'Selected variant "cu128"' || fail "dry-run did not select cu128"
99+
100+
echo "==> fallback to plain tag when no index exists"
101+
dir="${WORKDIR}/img-plain"
102+
mkdir -p "${dir}"
103+
echo plain > "${dir}/payload.txt"
104+
printf 'FROM scratch\nCOPY payload.txt /payload.txt\n' > "${dir}/Dockerfile"
105+
docker build -q -t "${PLAIN_REPO}:1.0.0" "${dir}" >/dev/null
106+
docker push -q "${PLAIN_REPO}:1.0.0" >/dev/null
107+
docker rmi -f "${PLAIN_REPO}:1.0.0" >/dev/null
108+
out="$("${BIN}" variant pull "${PLAIN_REPO}:1.0.0")" || fail "fallback pull failed"
109+
echo "${out}" | grep -q 'falling back' || fail "expected fallback notice"
110+
docker image inspect "${PLAIN_REPO}:1.0.0" >/dev/null || fail "fallback did not pull the plain tag"
111+
112+
echo "==> --no-fallback fails cleanly"
113+
if "${BIN}" variant pull "${PLAIN_REPO}:1.0.0" --no-fallback >/dev/null 2>&1; then
114+
fail "--no-fallback should have failed"
115+
fi
116+
117+
echo "PASS"

0 commit comments

Comments
 (0)