From 9bb0f25d00ef6b16e5f8e5bbd7fc546a8daec5d8 Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Mon, 22 Jun 2026 10:21:12 +0300 Subject: [PATCH] Select the running platform's image on registry-proxy pulls - Image downloads move to `//images/`; `PullImage` sends `?platform=-`, so the proxy picks the matching child manifest of a multi-platform index. The dash keeps the value a single unescaped URL token. - Releases ship as one multi-platform index per version tag, so `ListTags` and `ExtractBinary` work with plain version tags. - A proxy too old to honor the query serves its default platform (linux/amd64). Signed-off-by: Roman Berezkin --- internal/plugins/rpp_source_test.go | 2 +- internal/rpp/client.go | 14 +++-- internal/rpp/client_http_test.go | 4 +- internal/rpp/doc.go | 9 +++- internal/rpp/image.go | 14 +++-- internal/rpp/image_test.go | 4 +- internal/rpp/platform.go | 12 +++-- internal/selfupdate/README.md | 18 +++---- internal/selfupdate/rpp_source.go | 53 ++++-------------- internal/selfupdate/rpp_source_test.go | 74 ++++++-------------------- internal/selfupdate/update.go | 5 +- 11 files changed, 78 insertions(+), 131 deletions(-) diff --git a/internal/plugins/rpp_source_test.go b/internal/plugins/rpp_source_test.go index 8ec0a874..4acb4af1 100644 --- a/internal/plugins/rpp_source_test.go +++ b/internal/plugins/rpp_source_test.go @@ -68,7 +68,7 @@ func newTestRppSource(t *testing.T, body []byte) *rppPluginSource { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/v1/images/deckhouse-cli/plugins/stronghold/tags/v1.0.0", r.URL.Path) + assert.Equal(t, "/v1/images/deckhouse-cli/plugins/stronghold/images/v1.0.0", r.URL.Path) w.Header().Set("Content-Type", "application/x-gzip") _, _ = w.Write(body) diff --git a/internal/rpp/client.go b/internal/rpp/client.go index 1a444202..d25aac41 100644 --- a/internal/rpp/client.go +++ b/internal/rpp/client.go @@ -214,18 +214,24 @@ func (c *Client) ListTags(ctx context.Context, ref ImageRef) ([]string, error) { // No integrity check: the proxy exposes only a manifest digest, not a hash of // the gzip-tar body. Trust rests on the TLS-authenticated proxy channel. // The caller may want to cap the read with an io.LimitReader. -func (c *Client) PullImage(ctx context.Context, ref ImageRef, tag string) (io.ReadCloser, error) { - if err := validateTag(tag); err != nil { +func (c *Client) PullImage(ctx context.Context, ref ImageRef, version string) (io.ReadCloser, error) { + if err := validateTag(version); err != nil { return nil, err } - c.logger.Debug("pulling image", slog.String("image", ref.String()), slog.String("tag", tag)) + platform := currentPlatform() + c.logger.Debug("pulling image", slog.String("image", ref.String()), slog.String("version", version), slog.String("platform", platform)) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+ref.tagPath(tag), nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+ref.imagePath(version), nil) if err != nil { return nil, fmt.Errorf("build pull request: %w", err) } + // Select this platform's image from a multi-platform index. The proxy resolves + // the matching child manifest; without it the proxy would fall back to its + // default platform (linux/amd64). + req.URL.RawQuery = url.Values{"platform": {platform}}.Encode() + resp, err := c.do(req) if err != nil { return nil, err diff --git a/internal/rpp/client_http_test.go b/internal/rpp/client_http_test.go index 8f1c5521..a9880b94 100644 --- a/internal/rpp/client_http_test.go +++ b/internal/rpp/client_http_test.go @@ -21,6 +21,7 @@ import ( "io" "net/http" "net/http/httptest" + "runtime" "strings" "testing" @@ -73,7 +74,8 @@ func TestClientPullImage(t *testing.T) { client := testClient(t, func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, http.MethodGet, r.Method) - assert.Equal(t, "/v1/images/deckhouse-cli/tags/v0.13.1", r.URL.Path) + assert.Equal(t, "/v1/images/deckhouse-cli/images/v0.13.1", r.URL.Path) + assert.Equal(t, runtime.GOOS+"-"+runtime.GOARCH, r.URL.Query().Get("platform")) w.Header().Set("Content-Type", "application/x-gzip") _, _ = io.WriteString(w, payload) diff --git a/internal/rpp/doc.go b/internal/rpp/doc.go index 65053234..df5419e0 100644 --- a/internal/rpp/doc.go +++ b/internal/rpp/doc.go @@ -15,11 +15,18 @@ limitations under the License. */ // Package rpp is a client for the Deckhouse registry-packages-proxy (RPP) CLI -// routes: GET /v1/images//tags and /v1/images//tags/. +// routes: +// +// - GET /v1/images//tags - list available versions +// - GET /v1/images//images/ - download a version's image // // It lets deckhouse-cli list available versions of itself and its plugins and // download their images. All traffic goes to the in-cluster proxy and is // authenticated with the caller's kubeconfig identity; no separate registry // credentials are needed, because the proxy fetches from the backing registry // on the CLI's behalf. +// +// Pulls carry a ?platform=- query (from the running binary's +// GOOS/GOARCH) so the proxy selects the matching image from a multi-platform +// index. A proxy too old to honor it falls back to its default platform. package rpp diff --git a/internal/rpp/image.go b/internal/rpp/image.go index 0606b7ee..fb6f048b 100644 --- a/internal/rpp/image.go +++ b/internal/rpp/image.go @@ -34,8 +34,12 @@ const ( // imagesPathPrefix is the proxy route prefix for CLI image operations. imagesPathPrefix = "/v1/images/" - // tagsPathSegment is the route segment that lists or addresses tags. + // tagsPathSegment is the route segment that lists the image tags. tagsPathSegment = "tags" + + // imagesPathSegment is the route segment that addresses a single version's + // image for download (/v1/images//images/). + imagesPathSegment = "images" ) // pluginNamePattern is the OCI repository path-component grammar (lowercase @@ -86,15 +90,15 @@ func (r ImageRef) tagsPath() string { return imagesPathPrefix + r.path + "/" + tagsPathSegment } -// tagPath is the route that addresses a single tag of the image. The tag is +// imagePath is the route that downloads a single version's image. The version is // path-escaped as defense in depth; after validateTag this is a no-op, but it // keeps URL metacharacters out of the route even if validation ever loosens. -func (r ImageRef) tagPath(tag string) string { - return r.tagsPath() + "/" + url.PathEscape(tag) +func (r ImageRef) imagePath(version string) string { + return imagesPathPrefix + r.path + "/" + imagesPathSegment + "/" + url.PathEscape(version) } // validateTag rejects strings that cannot be a registry tag, so the proxy route -// (anchored on the final /tags/ segment) cannot be altered by a crafted +// (the final version/ref path segment) cannot be altered by a crafted // --version value (slashes, ?, #, leading dots). func validateTag(tag string) error { if tag == "" { diff --git a/internal/rpp/image_test.go b/internal/rpp/image_test.go index 85688cb6..537929f9 100644 --- a/internal/rpp/image_test.go +++ b/internal/rpp/image_test.go @@ -28,7 +28,7 @@ func TestCLIImage(t *testing.T) { assert.Equal(t, "deckhouse-cli", ref.String()) assert.Equal(t, "/v1/images/deckhouse-cli/tags", ref.tagsPath()) - assert.Equal(t, "/v1/images/deckhouse-cli/tags/v1.2.3", ref.tagPath("v1.2.3")) + assert.Equal(t, "/v1/images/deckhouse-cli/images/v1.2.3", ref.imagePath("v1.2.3")) } func TestPluginImage(t *testing.T) { @@ -38,7 +38,7 @@ func TestPluginImage(t *testing.T) { assert.Equal(t, "deckhouse-cli/plugins/stronghold", ref.String()) assert.Equal(t, "/v1/images/deckhouse-cli/plugins/stronghold/tags", ref.tagsPath()) - assert.Equal(t, "/v1/images/deckhouse-cli/plugins/stronghold/tags/v2.0.0", ref.tagPath("v2.0.0")) + assert.Equal(t, "/v1/images/deckhouse-cli/plugins/stronghold/images/v2.0.0", ref.imagePath("v2.0.0")) }) t.Run("empty name is rejected", func(t *testing.T) { diff --git a/internal/rpp/platform.go b/internal/rpp/platform.go index 27ef360e..5726159a 100644 --- a/internal/rpp/platform.go +++ b/internal/rpp/platform.go @@ -18,9 +18,11 @@ package rpp import "runtime" -// PlatformSuffix is the "--" tag suffix of the current platform -// (e.g. "-linux-amd64"). Matches the publishing convention: one single-platform -// image per tag (e.g. "-darwin-arm64"). -func PlatformSuffix() string { - return "-" + runtime.GOOS + "-" + runtime.GOARCH +// currentPlatform is the platform string of the running binary ("os-arch", +// e.g. "linux-amd64"). It is sent as the ?platform= query on a pull so the proxy +// picks the matching child manifest from a multi-platform image index. The dash +// separator keeps the value a single unescaped URL token (a slash would be +// percent-encoded and split path routers). +func currentPlatform() string { + return runtime.GOOS + "-" + runtime.GOARCH } diff --git a/internal/selfupdate/README.md b/internal/selfupdate/README.md index 1e6ed046..439ecca5 100644 --- a/internal/selfupdate/README.md +++ b/internal/selfupdate/README.md @@ -107,15 +107,15 @@ Version selection: - pre-releases (`rc`/`alpha`/`beta`) are installed only explicitly via `--version` (which also allows a downgrade). -Platform tags (`rpp_source.go`): - -- releases may be published per platform, one single-platform image per tag - (`v1.2.3-linux-amd64` - the same convention the plugin CI uses); -- `ListTags` reports this platform's tags as their bare version (so the Updater - selects them) and passes other platforms' tags through raw - their suffix - parses as a semver pre-release and is never auto-selected; -- `ExtractBinary` downloads `--` first and falls back to the bare - `` (legacy / platform-neutral publishing) on 404. +Platforms (`rpp_source.go`): + +- releases are published as multi-platform OCI image indexes under plain version + tags (`v1.2.3`); +- `ListTags` returns those plain version tags as-is; +- `ExtractBinary` pulls the plain tag; the client attaches `?platform=/` + (see `internal/rpp`), so the proxy resolves the matching per-platform child + manifest from the index. The proxy must be recent enough to honor the query - + see [internal/rpp](../rpp). ## Switches diff --git a/internal/selfupdate/rpp_source.go b/internal/selfupdate/rpp_source.go index 3d108ee0..6d1cc36e 100644 --- a/internal/selfupdate/rpp_source.go +++ b/internal/selfupdate/rpp_source.go @@ -18,8 +18,6 @@ package selfupdate import ( "context" - "errors" - "strings" "github.com/deckhouse/deckhouse-cli/internal/rpp" ) @@ -30,11 +28,9 @@ const cliBinaryEntryName = "d8" // rppSource extracts deckhouse-cli releases through the registry-packages-proxy. // -// Tags may be published per platform ("v1.2.3-linux-amd64", one single-platform -// image per tag - the same convention the plugin CI uses). The source hides that -// from the Updater: ListTags reports such tags as their bare version, and -// ExtractBinary resolves the bare version back to the platform tag, falling back -// to the bare tag itself for platform-neutral/legacy publishing. +// Releases are published as multi-platform image indexes under plain version tags +// ("v1.2.3"). The proxy selects the current platform's image from the index when +// PullImage sends its ?platform= query, so tags are plain versions here. type rppSource struct { client *rpp.Client } @@ -46,46 +42,17 @@ func NewRPPSource(client *rpp.Client) Source { return &rppSource{client: client} } -// ListTags returns the available release tags normalized for the current -// platform: "v1.2.3--" of THIS platform becomes "v1.2.3", tags of other -// platforms pass through raw (their suffix parses as a semver pre-release, so the -// Updater never auto-selects them). +// ListTags returns the available release tags. Tags are plain version strings; +// the proxy selects the per-platform image at pull time (PullImage's ?platform= +// query), so there is nothing to normalize here. func (s *rppSource) ListTags(ctx context.Context) ([]string, error) { - tags, err := s.client.ListTags(ctx, rpp.CLIImage()) - if err != nil { - return nil, err - } - - suffix := rpp.PlatformSuffix() - seen := make(map[string]struct{}, len(tags)) - normalized := make([]string, 0, len(tags)) - - for _, tag := range tags { - tag = strings.TrimSuffix(tag, suffix) - if _, ok := seen[tag]; ok { - continue - } - - seen[tag] = struct{}{} - normalized = append(normalized, tag) - } - - return normalized, nil + return s.client.ListTags(ctx, rpp.CLIImage()) } -// ExtractBinary downloads the d8 binary for tag, preferring the per-platform tag -// ("--") and falling back to the bare tag when the platform tag is -// not published. +// ExtractBinary downloads the d8 binary for tag. The proxy resolves the current +// platform's image from the tag's multi-platform index (PullImage attaches the +// ?platform= query). func (s *rppSource) ExtractBinary(ctx context.Context, tag, destination string) error { - err := s.extract(ctx, tag+rpp.PlatformSuffix(), destination) - if errors.Is(err, rpp.ErrNotFound) { - return s.extract(ctx, tag, destination) - } - - return err -} - -func (s *rppSource) extract(ctx context.Context, tag, destination string) error { body, err := s.client.PullImage(ctx, rpp.CLIImage(), tag) if err != nil { return err diff --git a/internal/selfupdate/rpp_source_test.go b/internal/selfupdate/rpp_source_test.go index 792db097..3b065851 100644 --- a/internal/selfupdate/rpp_source_test.go +++ b/internal/selfupdate/rpp_source_test.go @@ -23,6 +23,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "runtime" "testing" "github.com/stretchr/testify/assert" @@ -71,33 +72,22 @@ func TestVersionsSortsNewestFirstAndSkipsGarbage(t *testing.T) { assert.Equal(t, []string{"v0.14.0", "v0.13.1", "v0.13.0"}, got) } -// TestRPPSourceListTagsNormalizesPlatformTags checks that this platform's -// "--" suffix is stripped (so the Updater can select the version), -// while foreign-platform and bare tags pass through untouched. -func TestRPPSourceListTagsNormalizesPlatformTags(t *testing.T) { +// TestRPPSourceListTagsReturnsPlainVersions checks that tags pass through as plain +// version strings - platform selection happens at pull time, not in the tag. +func TestRPPSourceListTagsReturnsPlainVersions(t *testing.T) { source := newTestRPPSource(t, func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "/v1/images/deckhouse-cli/tags", r.URL.Path) _ = json.NewEncoder(w).Encode(map[string]any{ "name": "deckhouse-cli", - "tags": []string{ - "v0.13.1", // legacy bare tag - "v0.14.0" + rpp.PlatformSuffix(), // ours -> normalized - "v0.14.0-windows-amd64", // foreign -> raw passthrough - "v0.14.1" + rpp.PlatformSuffix(), // ours -> normalized - "v0.14.1", // bare duplicate of ours -> deduped - }, + "tags": []string{"v0.13.1", "v0.14.0", "v0.14.1"}, }) }) tags, err := source.ListTags(context.Background()) require.NoError(t, err) - assert.ElementsMatch(t, - []string{"v0.13.1", "v0.14.0", "v0.14.0-windows-amd64", "v0.14.1"}, - tags, - ) + assert.Equal(t, []string{"v0.13.1", "v0.14.0", "v0.14.1"}, tags) - // End to end through the Updater: the platform tag must win the selection, - // and the foreign platform tag must never be picked (parses as pre-release). + // End to end through the Updater: the newest stable version wins. updater := NewUpdater(source, nil, dkplog.NewNop()) latest, newer, err := updater.LatestVersion(context.Background(), "v0.13.1") require.NoError(t, err) @@ -105,16 +95,17 @@ func TestRPPSourceListTagsNormalizesPlatformTags(t *testing.T) { assert.Equal(t, "v0.14.1", latest) } -// TestRPPSourceExtractPrefersPlatformTag checks that the bare version selected by -// the Updater is resolved back to this platform's tag on download. -func TestRPPSourceExtractPrefersPlatformTag(t *testing.T) { +// TestRPPSourceExtractPullsTagWithPlatformQuery checks that the binary is fetched +// by its plain tag, with the current platform carried on the ?platform= query. +func TestRPPSourceExtractPullsTagWithPlatformQuery(t *testing.T) { tarball := gzipTarWithD8(t, "PLATFORM-BINARY") var requested []string + var gotPlatform string source := newTestRPPSource(t, func(w http.ResponseWriter, r *http.Request) { requested = append(requested, r.URL.Path) - require.Equal(t, "/v1/images/deckhouse-cli/tags/v0.14.0"+rpp.PlatformSuffix(), r.URL.Path) + gotPlatform = r.URL.Query().Get("platform") _, _ = w.Write(tarball) }) @@ -124,44 +115,13 @@ func TestRPPSourceExtractPrefersPlatformTag(t *testing.T) { got, err := os.ReadFile(destination) require.NoError(t, err) assert.Equal(t, "PLATFORM-BINARY", string(got)) - assert.Len(t, requested, 1, "the platform tag must be fetched directly, no extra round-trips") -} - -// TestRPPSourceExtractFallsBackToBareTag checks legacy/platform-neutral publishing: -// when the platform tag is absent (404), the bare tag is downloaded instead. -func TestRPPSourceExtractFallsBackToBareTag(t *testing.T) { - tarball := gzipTarWithD8(t, "BARE-BINARY") - - var requested []string - - source := newTestRPPSource(t, func(w http.ResponseWriter, r *http.Request) { - requested = append(requested, r.URL.Path) - - if r.URL.Path == "/v1/images/deckhouse-cli/tags/v0.13.1" { - _, _ = w.Write(tarball) - - return - } - - http.Error(w, "not found", http.StatusNotFound) - }) - - destination := filepath.Join(t.TempDir(), "d8.new") - require.NoError(t, source.ExtractBinary(context.Background(), "v0.13.1", destination)) - - got, err := os.ReadFile(destination) - require.NoError(t, err) - assert.Equal(t, "BARE-BINARY", string(got)) - assert.Equal(t, []string{ - "/v1/images/deckhouse-cli/tags/v0.13.1" + rpp.PlatformSuffix(), - "/v1/images/deckhouse-cli/tags/v0.13.1", - }, requested, "platform tag tried first, bare tag second") + assert.Equal(t, []string{"/v1/images/deckhouse-cli/images/v0.14.0"}, requested) + assert.Equal(t, runtime.GOOS+"-"+runtime.GOARCH, gotPlatform) } -// TestRPPSourceExtractPropagatesNonNotFoundErrors checks that the fallback fires -// only on 404: a 403 on the platform tag must surface as-is, not mask itself with -// a second request. -func TestRPPSourceExtractPropagatesNonNotFoundErrors(t *testing.T) { +// TestRPPSourceExtractPropagatesErrors checks that a non-2xx from the proxy +// surfaces as-is in a single request. +func TestRPPSourceExtractPropagatesErrors(t *testing.T) { var requests int source := newTestRPPSource(t, func(w http.ResponseWriter, _ *http.Request) { diff --git a/internal/selfupdate/update.go b/internal/selfupdate/update.go index ed3e7f5a..634d05ab 100644 --- a/internal/selfupdate/update.go +++ b/internal/selfupdate/update.go @@ -68,9 +68,8 @@ func NewUpdater(source Source, store *Store, logger *dkplog.Logger) *Updater { } // Versions returns the published release versions sorted newest-first. Tags that -// are not valid semver are skipped (foreign platforms' suffixed tags survive the -// source normalization as raw strings, but they parse fine and are kept - they -// carry their suffix as a pre-release marker). +// are not valid semver are skipped. Tags are plain version strings (one +// multi-platform index per release); the proxy picks the platform at pull time. func (u *Updater) Versions(ctx context.Context) ([]*semver.Version, error) { tags, err := u.source.ListTags(ctx) if err != nil {