Skip to content

feat(dl): tag/digest-aware download endpoint#4

Merged
CMGS merged 2 commits into
mainfrom
feat-dl-tag-digest
May 11, 2026
Merged

feat(dl): tag/digest-aware download endpoint#4
CMGS merged 2 commits into
mainfrom
feat-dl-tag-digest

Conversation

@CMGS
Copy link
Copy Markdown
Contributor

@CMGS CMGS commented May 11, 2026

Summary

  • Promote /dl/{name}/{ref} as the canonical download URL form. ref is either a tag (latest, 22h2-20260510) or a digest reference (sha256:...), dispatched via the existing isDigestRef predicate the /v2/ manifest endpoint already uses.
  • Keep /dl/{name} as a transitional route (single-segment URLs continue to mean latest).
  • Add a handler-side fallback for the pre-2026-05 multi-segment form /dl/simular/win11: under the new route this initially splits as name=simular, ref=win11 and 404s; the handler then retries name=simular/win11, ref=latest. Successful fallbacks attach Deprecation: true + Link: </dl/{name}/{ref}>; rel="successor-version" so callers can migrate without breakage.

Why

Hot snapshots pin their base image by tag (e.g. simular/win10:22h2-20260510), but the existing /dl/ handler hardcoded ManifestJSON(name, "latest"). Tagged base images were unreachable via the cloudimg HTTP-stream channel that cocoon's cloudimg backend relies on, which surfaced as the clone-time Backing file I/O error reported in cocoonstack/cocoon#37 and #38.

The /v2/ registry endpoint already handles tags and digests fine — this PR closes the gap on the cloudimg-stream side. cocoon's cloudimg backend stays a plain HTTP fetcher (no OCI awareness required), so non-epoch sources (https://cloud-images.ubuntu.com/...) keep working unchanged.

Test plan

  • go test ./server/... -run 'TestDLRoute|TestDLPublicPath' passes — covers route resolution for all five URL forms (1-seg, 1-seg+ref, multi-seg, multi-seg+ref, digest)
  • Full go test ./server/... passes (no existing regressions)
  • go vet ./... clean
  • Live integration against the testing epoch: cocoon image pull https://epoch.simular.cloud/dl/simular/win10/22h2-20260510 and .../sha256:<hex> (covered in cocoon-specs/tests/vk-epoch-pull-tests.md per existing convention)

Caller migration

Old URL Behavior under this PR
https://epoch.../dl/win11 Same as before (single-segment → implicit latest)
https://epoch.../dl/simular/win11 Resolves via fallback, response carries Deprecation: true header. Migrate to /dl/simular/win11/latest
https://epoch.../dl/simular/win11/latest Canonical — preferred form for "always-latest"
https://epoch.../dl/simular/win11/22h2-20260510 Canonical — preferred for tag-pinned
https://epoch.../dl/simular/win11/sha256:adafd938... Canonical — preferred for digest-pinned (immutable, perfect for snapshot baseimage refs)

Companion changes (separate PRs, not blocked by this one):

  • cocoonstack/cocoon: cmd/core/helpers.go:EnsureImage — pass force=true when vmCfg.ImageDigest != "" so the URL-level short-circuit at images/cloudimg/pull.go:62 doesn't mask a stale local blob (issue 37)
  • cocoonstack/vk-cocoon: write cocoonstack.snapshot.baseimage annotations as the new digest-pinned URL form (issue 38)

Promote /dl/{name}/{ref} as the canonical form. ref is a tag (e.g.
"latest", "22h2-20260510") or a digest reference ("sha256:..."), routed
via the existing isDigestRef check that the /v2/ manifest endpoint
already uses.

Backward compat: the legacy /dl/{name} form (latest hardcoded) used to
also accept multi-segment URLs like /dl/simular/win11 — under the new
two-segment route, those split as name=simular, ref=win11. The handler
catches the resulting 404 and retries name+"/"+ref with implicit
"latest", then flags the response with a Deprecation header and a
successor-version Link so callers know the URL form needs to be updated.

Motivation: hot snapshots pin their base image by tag (e.g.
simular/win10:22h2-20260510), but the previous /dl/ endpoint hardcoded
ManifestJSON(name, "latest"). That made tagged base images
unreachable through the cloudimg HTTP-stream channel that cocoon's
cloudimg backend relies on, which surfaced as the clone-time
"Backing file I/O error" reported in cocoonstack/cocoon#37 #38.

The OCI registry on /v2/ already supports tags and digests fine. This
patch closes the gap on the cloudimg-stream side without teaching
cocoon's HTTP-only cloudimg backend to speak OCI.
Drop WHAT-style enumerations and inline comments that restated the code.
The handler godoc keeps the only non-obvious bit (the legacy fallback
WHY); the route registration in server.go and the field-by-field comment
on the Deprecation header lose redundant prose. Helper godoc shrinks to
one line about the resolved-pair return convention.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the server’s unauthenticated /dl/ download endpoint to support tag- and digest-qualified downloads (aligning behavior with the existing /v2/ manifest handling), while preserving backward compatibility for older /dl/ URL shapes.

Changes:

  • Add canonical /dl/{name}/{ref} routing where ref may be a tag or sha256:... digest reference, while keeping /dl/{name} as a transitional “latest” default.
  • Implement handler-side legacy fallback to reinterpret older multi-segment-name URLs when the initial lookup 404s, and emit deprecation/migration response headers when fallback is used.
  • Add gorilla/mux route-resolution tests for the supported /dl/ URL forms and a public-path check for the new canonical URLs.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
server/server.go Adds the canonical /dl/{name}/{ref} route (tag/digest aware) ahead of the existing /dl/{name} route.
server/download.go Updates the download handler to accept ref, fetch manifests by tag/digest, and implement legacy fallback + deprecation headers.
server/dl_routes_test.go Adds unit tests validating mux route splitting for /dl/ paths and confirming new canonical paths remain auth-exempt.
Comments suppressed due to low confidence (3)

server/download.go:117

  • In fetchManifestWithLegacyFallback, if the legacy retry (legacyName, "latest") fails, the function returns the original (name, ref) error (err) instead of the legacy error (lerr). This can incorrectly turn a legacy-path internal error into a 404, and it also makes debugging harder. Return lerr (or prefer it when it’s non-404) so callers get the correct status/cause.
	StreamBlob(ctx context.Context, digest string) (io.ReadCloser, int64, error)
}

type manifestStreamer interface {
	blobStreamer

server/download.go:115

  • The legacy fallback currently triggers for any 404, including when ref is a digest reference (e.g. sha256:...). That means a digest-pinned URL that should be immutable can silently fall back to fetching a different repository (name/ref:latest) if it exists, returning unexpected content instead of a 404. Consider disabling the fallback when isDigestRef(ref) is true (and possibly tightening when fallback is allowed) so digest lookups fail deterministically.
		return nil, name, ref, err
	}
	legacyName := name + "/" + ref
	legacy, lerr := s.loadManifestRaw(r, legacyName, "latest")
	if lerr != nil {
		return nil, name, ref, err
	}
	return legacy, legacyName, "latest", nil
}

type blobStreamer interface {
	StreamBlob(ctx context.Context, digest string) (io.ReadCloser, int64, error)
}

server/server.go:151

  • The comment above the /dl routes is misleading about which route covers the legacy /dl/simular/win11 form. With /dl/{name:.+}/{ref} registered first, /dl/simular/win11 will match the 2-arg route (name=simular, ref=win11) and rely on the handler fallback; it will not hit the single-arg /dl/{name:.+} route. Updating the comment to reflect the actual match behavior would avoid confusion for future maintainers.
	s.router.HandleFunc("/dl/{name:.+}/{ref}", s.handleArtifactDownload).Methods(http.MethodGet)
	s.router.HandleFunc("/dl/{name:.+}", s.handleArtifactDownload).Methods(http.MethodGet)

	s.router.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("ok"))
	}).Methods(http.MethodGet)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants