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
112 changes: 112 additions & 0 deletions cmd/variant-proxy/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// variant-proxy is the Phase B registry-side component (DESIGN.md §6): a
// stateless reverse proxy in front of any V2 registry that additionally
// serves computed variant indexes at
//
// GET /v2/<name>/_variants/<version>
//
// The index is built on demand by scanning the upstream repository's
// "<version>-<label>" tags and reading each image's config labels, so plain
// `docker push` of labeled images is sufficient — no client-side index
// maintenance. All other paths pass through to the upstream registry
// unchanged.
package main

import (
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"net/http/httputil"
"net/url"
"os"
"regexp"

"github.com/achimnol/docker-variant/pkg/oci"
"github.com/achimnol/docker-variant/pkg/variant"
)

var variantPathRe = regexp.MustCompile(`^/v2/(.+)/_variants/([^/]+)$`)

func main() {
var (
listen = flag.String("listen", ":5599", "address to listen on")
upstream = flag.String("upstream", "", "host:port of the backing registry (required)")
upstreamHTTPS = flag.Bool("upstream-https", false, "reach the upstream via HTTPS (default: plain HTTP)")
)
flag.Parse()
if *upstream == "" {
fmt.Fprintln(os.Stderr, "variant-proxy: --upstream is required")
os.Exit(2)
}
scheme := "http"
if *upstreamHTTPS {
scheme = "https"
}
upstreamURL := &url.URL{Scheme: scheme, Host: *upstream}
client, err := oci.NewClient(!*upstreamHTTPS)
if err != nil {
log.Fatalf("variant-proxy: %v", err)
}

proxy := httputil.NewSingleHostReverseProxy(upstreamURL)
baseDirector := proxy.Director
proxy.Director = func(req *http.Request) {
baseDirector(req)
req.Host = *upstream
}

handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if m := variantPathRe.FindStringSubmatch(r.URL.Path); m != nil && (r.Method == http.MethodGet || r.Method == http.MethodHead) {
serveVariants(client, *upstream, m[1], m[2], w, r)
return
}
proxy.ServeHTTP(w, r)
})

log.Printf("variant-proxy: listening on %s, upstream %s", *listen, upstreamURL)
log.Fatal(http.ListenAndServe(*listen, handler))
}

func serveVariants(client *oci.Client, upstream, name, version string, w http.ResponseWriter, r *http.Request) {
ref := oci.Ref{Registry: upstream, Repository: name}
ix, warnings, err := client.BuildIndex(r.Context(), ref, version)
for _, warning := range warnings {
log.Printf("variant-proxy: %s@%s: %s", name, version, warning)
}
if err != nil {
log.Printf("variant-proxy: building index for %s@%s: %v", name, version, err)
writeOCIError(w, http.StatusBadGateway, "UNAVAILABLE", "failed to build variant index from upstream")
return
}
if len(ix.Variants) == 0 {
writeOCIError(w, http.StatusNotFound, "MANIFEST_UNKNOWN", "no variants found for this version")
return
}
// Advertise the proxy's own authority as the repository so clients pull
// through the proxy (keeping digest-addressed pulls on the same host
// they queried).
ix.Repository = r.Host + "/" + name
data, err := ix.Marshal()
if err != nil {
writeOCIError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
return
}
w.Header().Set("Content-Type", variant.IndexArtifactType)
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
w.Write(append(data, '\n'))
log.Printf("variant-proxy: served index %s@%s (%d variants)", name, version, len(ix.Variants))
}

// writeOCIError responds in the OCI distribution error envelope so registry
// clients show something sensible.
func writeOCIError(w http.ResponseWriter, status int, code, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]any{
"errors": []map[string]string{{"code": code, "message": message}},
})
}
43 changes: 42 additions & 1 deletion e2e/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,18 @@ CONTAINER="variant-e2e-registry-${PORT}"

fail() { echo "FAIL: $*" >&2; exit 1; }

PROXY_PORT="${E2E_PROXY_PORT:-5592}"
PROXY="127.0.0.1:${PROXY_PORT}"
PROXY_REPO="${PROXY}/e2e/proxied"
PROXY_PID=""

cleanup() {
[ -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" \
"${PLAIN_REPO}:1.0.0" >/dev/null 2>&1 || true
"${PLAIN_REPO}:1.0.0" \
"${PROXY_REPO}:1.0.0" "${PROXY_REPO}:1.0.0-cu128" "${PROXY_REPO}:1.0.0-null" >/dev/null 2>&1 || true
rm -rf "${WORKDIR}"
}
trap cleanup EXIT
Expand Down Expand Up @@ -117,4 +124,38 @@ if "${BIN}" variant pull "${PLAIN_REPO}:1.0.0" --no-fallback >/dev/null 2>&1; th
fail "--no-fallback should have failed"
fi

echo "==> proxy: server-computed index over plain docker pushes"
go build -o "${WORKDIR}/variant-proxy" ./cmd/variant-proxy
"${WORKDIR}/variant-proxy" --listen "127.0.0.1:${PROXY_PORT}" --upstream "${REGISTRY}" \
> "${WORKDIR}/proxy.log" 2>&1 &
PROXY_PID=$!
for _ in $(seq 1 30); do
curl -fsS "http://${PROXY}/v2/" >/dev/null 2>&1 && break
sleep 0.5
done

# Push variants THROUGH the proxy with plain `docker push` only — no index
# artifact is ever created for this repository.
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"
docker push -q "${PROXY_REPO}:1.0.0-cu128" >/dev/null
docker push -q "${PROXY_REPO}:1.0.0-null" >/dev/null
curl -fsS "http://${REGISTRY}/v2/e2e/proxied/tags/list" | grep -q '1.0.0-variants' \
&& fail "proxied repo unexpectedly has an index artifact tag"

echo " endpoint serves computed index"
curl -fsS "http://${PROXY}/v2/e2e/proxied/_variants/1.0.0" | grep -q '"cu128"' \
|| fail "_variants endpoint missing cu128"
curl -sS -o /dev/null -w '%{http_code}' "http://${PROXY}/v2/e2e/proxied/_variants/9.9.9" \
| grep -q 404 || fail "_variants for unknown version should be 404"

proxied_pull_label() {
docker rmi -f "${PROXY_REPO}:1.0.0" >/dev/null 2>&1 || true
"${BIN}" variant pull "${PROXY_REPO}:1.0.0" --properties-file "$1" >/dev/null
docker image inspect --format '{{ index .Config.Labels "dev.pep817.variant-label" }}' "${PROXY_REPO}:1.0.0"
}
[ "$(proxied_pull_label "${WORKDIR}/gpu128.json")" = cu128 ] || fail "proxied pull (gpu) should select cu128"
[ "$(proxied_pull_label "${WORKDIR}/cpu.json")" = null ] || fail "proxied pull (cpu) should select null"
echo " variant pull via proxy works without any index artifact"

echo "PASS"
42 changes: 40 additions & 2 deletions pkg/oci/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sort"

ocispec "github.com/opencontainers/image-spec/specs-go/v1"
Expand All @@ -20,16 +23,51 @@ import (
// requested base version.
var ErrNoIndex = errors.New("no variant index found")

// FetchIndex retrieves and parses the variant index artifact for
// (repository, version) via the "<version>-variants" tag.
// FetchIndex retrieves the variant index for (repository, version): first
// via the "/v2/<name>/_variants/<version>" extension endpoint (served by
// variant-proxy or a native registry implementation), then falling back to
// the "<version>-variants" tag artifact.
func (c *Client) FetchIndex(ctx context.Context, ref Ref, version string) (*variant.Index, error) {
if ix, err := c.fetchIndexExtension(ctx, ref, version); err == nil {
return ix, nil
}
repo, err := c.Repo(ref)
if err != nil {
return nil, err
}
return FetchIndexFrom(ctx, repo, version)
}

// maxIndexSize bounds the accepted index document size (defense against a
// misbehaving server).
const maxIndexSize = 4 << 20

// fetchIndexExtension queries the _variants extension endpoint.
func (c *Client) fetchIndexExtension(ctx context.Context, ref Ref, version string) (*variant.Index, error) {
scheme := "https"
if c.PlainHTTP || isLoopback(ref.Registry) {
scheme = "http"
}
u := fmt.Sprintf("%s://%s/v2/%s/_variants/%s", scheme, ref.Registry, ref.Repository, url.PathEscape(version))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
resp, err := c.authClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GET %s: %s", u, resp.Status)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxIndexSize))
if err != nil {
return nil, err
}
return variant.ParseIndex(data)
}

// ReadOnlyTarget is the storage interface FetchIndexFrom needs: tag
// resolution plus content fetch (satisfied by *remote.Repository and the
// in-memory stores used in tests).
Expand Down
Loading