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
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
GO ?= go

.PHONY: test vet fmt tidy
.PHONY: test vet fmt tidy e2e

e2e:
./e2e/run.sh

test:
$(GO) test ./...
Expand Down
1 change: 1 addition & 0 deletions cmd/docker-variant/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ func newVariantCommand() *cobra.Command {
}
cmd.AddCommand(
newDetectCommand(),
newPullCommand(),
newPushCommand(),
newListCommand(),
newInspectCommand(),
Expand Down
104 changes: 104 additions & 0 deletions cmd/docker-variant/pull.go
Original file line number Diff line number Diff line change
@@ -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
}
120 changes: 120 additions & 0 deletions e2e/run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
#!/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"
# 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"
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"
Loading