Skip to content
Draft
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -890,6 +890,9 @@ proxy mirror --dry-run pkg:npm/lodash

# Control parallelism
proxy mirror --concurrency 8 pkg:npm/lodash@4.17.21

# Mirror a container image for one platform
proxy mirror --platform linux/amd64 pkg:docker/library/alpine@3.20
```

The mirror command accepts the same storage and database flags as `serve`. Already-cached artifacts are skipped.
Expand Down
30 changes: 21 additions & 9 deletions cmd/proxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,9 @@ import (
"github.com/git-pkgs/proxy/internal/config"
"github.com/git-pkgs/proxy/internal/database"
"github.com/git-pkgs/proxy/internal/denylist"
"github.com/git-pkgs/proxy/internal/handler"
"github.com/git-pkgs/proxy/internal/mirror"
"github.com/git-pkgs/proxy/internal/server"
"github.com/git-pkgs/proxy/internal/storage"
"github.com/git-pkgs/registries/fetch"
)

const defaultTopN = 10
Expand Down Expand Up @@ -421,20 +419,30 @@ func runMirror() {
sbomPath := fs.String("sbom", "", "Path to CycloneDX or SPDX SBOM file")
concurrency := fs.Int("concurrency", 4, "Number of parallel downloads") //nolint:mnd // default concurrency
dryRun := fs.Bool("dry-run", false, "Show what would be mirrored without downloading")
platforms := fs.String("platform", "", "Comma-separated os/arch[/variant] list limiting which platforms of container images are mirrored")

fs.Usage = func() {
fmt.Fprintf(os.Stderr, "git-pkgs proxy - Pre-populate cache\n\n")
fmt.Fprintf(os.Stderr, "Usage: proxy mirror [flags] [purl...]\n\n")
fmt.Fprintf(os.Stderr, "Examples:\n")
fmt.Fprintf(os.Stderr, " proxy mirror pkg:npm/lodash@4.17.21\n")
fmt.Fprintf(os.Stderr, " proxy mirror --sbom sbom.cdx.json\n")
fmt.Fprintf(os.Stderr, " proxy mirror pkg:npm/lodash # all versions\n\n")
fmt.Fprintf(os.Stderr, " proxy mirror pkg:npm/lodash # all versions\n")
fmt.Fprintf(os.Stderr, " proxy mirror --platform linux/amd64 pkg:docker/library/alpine@3.20\n\n")
fmt.Fprintf(os.Stderr, "Flags:\n")
fs.PrintDefaults()
}

_ = fs.Parse(os.Args[1:])
purls := fs.Args()
var platformList []string
if *platforms != "" {
platformList = []string{*platforms}
}
if _, err := mirror.ParsePlatforms(platformList); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}

// Determine source
var source mirror.Source
Expand All @@ -445,9 +453,9 @@ func runMirror() {
fmt.Fprintf(os.Stderr, "error reading SBOM %s: %v\n", *sbomPath, err)
os.Exit(1)
}
source = &mirror.SBOMSource{Data: data, Name: *sbomPath}
source = &mirror.SBOMSource{Data: data, Name: *sbomPath, Platforms: platformList}
case len(purls) > 0:
source = &mirror.PURLSource{PURLs: purls}
source = &mirror.PURLSource{PURLs: purls, Platforms: platformList}
default:
fmt.Fprintf(os.Stderr, "error: provide PURLs or --sbom\n")
fs.Usage()
Expand Down Expand Up @@ -518,10 +526,8 @@ func runMirror() {
os.Exit(1) //nolint:gocritic // db closed above
}

// Build proxy (reuses same pipeline as serve)
fetcher := fetch.NewFetcher()
resolver := fetch.NewResolver()
proxy := handler.NewProxy(db, store, fetcher, resolver, logger)
// Build proxy with the same upstream clients and authentication as serve
proxy, images := server.NewMirrorProxy(cfg, db, store, logger)
proxy.Denylist, err = denylist.New(cfg.Denylist.Packages)
if err != nil {
_ = db.Close()
Expand All @@ -533,6 +539,12 @@ func runMirror() {
proxy.MetadataMaxSize = cfg.ParseMetadataMaxSize()

m := mirror.New(proxy, db, store, logger, *concurrency)
m.Images = images
if cfg.Scanning.Enabled {
// Scanners fetch staged layers from the running server, which this
// command does not start.
m.Images = mirror.UnavailableImages{Reason: "container images cannot be mirrored by this command while scanning is enabled; use the mirror API of the running server"}
}

ctx, cancel := context.WithCancel(context.Background())
go func() {
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ Package metadata enrichment. Fetches license, description, homepage, repository

### `internal/mirror`

Selective package mirroring for pre-populating the proxy cache. Supports multiple input sources: individual PURLs (versioned or unversioned), CycloneDX/SPDX SBOM files, and full registry enumeration. Uses a bounded worker pool backed by `errgroup` to download artifacts in parallel, reusing `handler.Proxy.GetOrFetchArtifact()` for the actual fetch-and-cache work.
Selective package mirroring for pre-populating the proxy cache. Supports multiple input sources: individual PURLs (versioned or unversioned), CycloneDX/SPDX SBOM files, and full registry enumeration. Uses a bounded worker pool backed by `errgroup` to download artifacts in parallel, reusing `handler.Proxy.GetOrFetchArtifact()` for the actual fetch-and-cache work. Container images (`pkg:docker`, `pkg:oci`) go through `handler.ContainerHandler.MirrorImage()` instead, which walks index, platform manifests and blobs and stores them under the keys a pull reads.

The package also provides a `MetadataCache` for storing raw upstream metadata blobs so the proxy can serve metadata responses offline. The `JobStore` manages async mirror jobs exposed via the `/api/mirror` endpoints.

Expand Down
27 changes: 27 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -829,6 +829,14 @@ curl -X POST http://localhost:8080/api/mirror \
-d '{"sbom":{"bomFormat":"CycloneDX","components":[{"purl":"pkg:npm/lodash@4.17.21"}]}}'
```

`platforms` limits which platforms of container images are mirrored, like the command's `--platform` flag:

```bash
curl -X POST http://localhost:8080/api/mirror \
-H "Content-Type: application/json" \
-d '{"purls":["pkg:docker/library/alpine@3.20"],"platforms":["linux/amd64"]}'
```

## Mirror Command

The `proxy mirror` command pre-populates the cache from various sources. It accepts the same storage and database flags as `serve`.
Expand All @@ -838,6 +846,7 @@ The `proxy mirror` command pre-populates the cache from various sources. It acce
| `--sbom` | | Path to CycloneDX or SPDX SBOM file |
| `--concurrency` | `4` | Number of parallel downloads |
| `--dry-run` | `false` | Show what would be mirrored without downloading |
| `--platform` | | Comma-separated `os/arch[/variant]` list limiting which platforms of container images are mirrored |
| `--config` | | Path to configuration file |
| `--storage-url` | | Storage URL |
| `--database-driver` | | Database driver |
Expand All @@ -850,6 +859,24 @@ Positional arguments are treated as PURLs:
proxy mirror pkg:npm/lodash@4.17.21 pkg:cargo/serde@1.0.0
```

### Container images

`pkg:docker` and `pkg:oci` PURLs mirror a container image the way a pull does: the manifest or index, the platform manifests, then config and layer blobs. Everything is stored under the keys a pull through `/v2` reads, so a later `docker pull` or containerd pull is served from cache, also when the upstream is unreachable.

```bash
proxy mirror --platform linux/amd64,linux/arm64 pkg:docker/library/alpine@3.20
proxy mirror 'pkg:docker/containerd/alpine@3.14.0?repository_url=ghcr.io'
proxy mirror 'pkg:oci/debian@sha256:...?repository_url=docker.io/library/debian&tag=bookworm'
```

- The version is a tag or a digest. Unversioned image PURLs fail; mirroring all tags is not supported.
- Without `repository_url` the name is routed like a pull without containerd's `ns` parameter: the default OCI registry, repository prefix routes and `upstream/{name}/`. Single-segment Docker Hub names get the `library/` prefix.
- `repository_url` is resolved like containerd's `ns` parameter, so it has to name the default registry, Docker Hub, a configured `upstream.oci` host or a host listed in `upstream.oci_mirrors`. Other hosts are refused.
- `pkg:oci` needs `repository_url`. With `tag=` the tag is mirrored as well; if it now points to another digest, both are mirrored and a warning is logged. `arch=` limits the platforms when `--platform` is not given.
- Without `--platform` every platform of an index is mirrored, including Windows images, which can be large. Attestation manifests of the selected platforms are mirrored too, because Docker fetches them on every pull.
- Non-distributable (foreign) layers are skipped.
- While `scanning.enabled` is set, the command refuses images, because scanners fetch staged layers from the running server. Use the mirror API instead.

## Docker

### SQLite with Local Storage
Expand Down
95 changes: 70 additions & 25 deletions internal/handler/container_manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"mime"
"net/http"
"regexp"
"slices"
"sort"
"strconv"
"strings"
Expand All @@ -23,6 +24,14 @@ const (
containerAcceptExactSpecificity
)

const (
// containerManifestIndexCacheClass replaces the Accept header in tag cache
// keys for clients that accept an index type.
containerManifestIndexCacheClass = "index"
ociImageIndexMediaType = "application/vnd.oci.image.index.v1+json"
dockerManifestListMediaType = "application/vnd.docker.distribution.manifest.list.v2+json"
)

var manifestDigestReferencePattern = regexp.MustCompile(`^[a-z0-9]+:[a-f0-9]+$`)

type cachedContainerManifest struct {
Expand All @@ -38,8 +47,7 @@ type cachedContainerManifest struct {
func (h *ContainerHandler) serveManifest(w http.ResponseWriter, r *http.Request, registryURL, name, reference string) {
accept := containerManifestAccept(r)
cacheAccept := normalizeContainerManifestAccept(accept)
cacheKey := h.containerManifestCacheKey(registryURL, name, reference, cacheAccept)
cached := h.loadContainerManifestForAccept(r.Context(), registryURL, name, reference, accept, cacheKey)
cached := h.loadContainerManifestForAccept(r.Context(), registryURL, name, reference, accept, cacheAccept)

immutable := manifestDigestReferencePattern.MatchString(reference)
if cached != nil && (immutable || h.containerManifestFresh(cached)) {
Expand Down Expand Up @@ -140,7 +148,44 @@ func (h *ContainerHandler) containerManifestCacheKey(registryURL, name, referenc
return hex.EncodeToString(sum[:])
}

func (h *ContainerHandler) loadContainerManifestForAccept(ctx context.Context, registryURL, name, reference, accept, cacheKey string) *cachedContainerManifest {
// containerManifestSharedCacheKey is the key clients with different Accept
// headers share. A digest names exactly one document. A tag is stored once for
// all clients that accept an index type, whatever upstream returned for it, so
// Docker, containerd and Podman read the same entry. Clients without index
// support keep a key per normalized Accept header, because registries may
// convert an index into a single platform manifest for them.
func (h *ContainerHandler) containerManifestSharedCacheKey(registryURL, name, reference, cacheAccept string) string {
if manifestDigestReferencePattern.MatchString(reference) {
identity := strings.Join([]string{registryURL, name, reference}, "\x00")
sum := sha256.Sum256([]byte(identity))
return hex.EncodeToString(sum[:])
}
if containerManifestAcceptsIndex(cacheAccept) {
return h.containerManifestCacheKey(registryURL, name, reference, containerManifestIndexCacheClass)
}
return h.containerManifestCacheKey(registryURL, name, reference, cacheAccept)
}

func containerManifestAcceptsIndex(accept string) bool {
return containerManifestAccepts(accept, ociImageIndexMediaType) || containerManifestAccepts(accept, dockerManifestListMediaType)
}

// containerManifestLegacyCacheKeys are the per-Accept keys older versions
// used. They are still written so a rollback finds the cache, and read when
// the shared key has no entry yet.
func (h *ContainerHandler) containerManifestLegacyCacheKeys(registryURL, name, reference, accept, cacheAccept, sharedKey string) []string {
var keys []string
for _, variant := range []string{cacheAccept, accept} {
key := h.containerManifestCacheKey(registryURL, name, reference, variant)
if key != sharedKey && !slices.Contains(keys, key) {
keys = append(keys, key)
}
}
return keys
}

func (h *ContainerHandler) loadContainerManifestForAccept(ctx context.Context, registryURL, name, reference, accept, cacheAccept string) *cachedContainerManifest {
cacheKey := h.containerManifestSharedCacheKey(registryURL, name, reference, cacheAccept)
cached, err := h.loadContainerManifest(ctx, cacheKey)
if err != nil {
h.proxy.Logger.Warn("failed to read cached container manifest", "error", err)
Expand All @@ -153,36 +198,36 @@ func (h *ContainerHandler) loadContainerManifestForAccept(ctx context.Context, r
return nil
}

legacyCacheKey := h.containerManifestCacheKey(registryURL, name, reference, accept)
if legacyCacheKey == cacheKey {
return nil
}
cached, err = h.loadContainerManifest(ctx, legacyCacheKey)
if err != nil {
h.proxy.Logger.Warn("failed to read legacy cached container manifest", "error", err)
return nil
}
if cached == nil || !containerManifestCacheCompatible(accept, cached) {
return nil
}
if err := h.storeContainerManifest(ctx, cacheKey, cached); err != nil {
h.proxy.Logger.Warn("failed to migrate cached container manifest", "error", err)
for _, legacyCacheKey := range h.containerManifestLegacyCacheKeys(registryURL, name, reference, accept, cacheAccept, cacheKey) {
cached, err = h.loadContainerManifest(ctx, legacyCacheKey)
if err != nil {
h.proxy.Logger.Warn("failed to read legacy cached container manifest", "error", err)
return nil
}
if cached == nil {
continue
}
if !containerManifestCacheCompatible(accept, cached) {
return nil
}
if err := h.storeContainerManifest(ctx, cacheKey, cached); err != nil {
h.proxy.Logger.Warn("failed to migrate cached container manifest", "error", err)
}
return cached
}
return cached
return nil
}

func (h *ContainerHandler) storeContainerManifestForAccept(ctx context.Context, registryURL, name, reference, accept, cacheAccept string, manifest *cachedContainerManifest) {
cacheKey := h.containerManifestCacheKey(registryURL, name, reference, cacheAccept)
cacheKey := h.containerManifestSharedCacheKey(registryURL, name, reference, cacheAccept)
if err := h.storeContainerManifest(ctx, cacheKey, manifest); err != nil {
h.proxy.Logger.Warn("failed to cache container manifest", "error", err)
}

legacyCacheKey := h.containerManifestCacheKey(registryURL, name, reference, accept)
if legacyCacheKey == cacheKey {
return
}
if err := h.storeContainerManifest(ctx, legacyCacheKey, manifest); err != nil {
h.proxy.Logger.Warn("failed to cache legacy container manifest", "error", err)
for _, legacyCacheKey := range h.containerManifestLegacyCacheKeys(registryURL, name, reference, accept, cacheAccept, cacheKey) {
if err := h.storeContainerManifest(ctx, legacyCacheKey, manifest); err != nil {
h.proxy.Logger.Warn("failed to cache legacy container manifest", "error", err)
}
}
}

Expand Down
Loading
Loading