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
2 changes: 1 addition & 1 deletion internal/plugins/rpp_source_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 10 additions & 4 deletions internal/rpp/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion internal/rpp/client_http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"runtime"
"strings"
"testing"

Expand Down Expand Up @@ -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)
Expand Down
9 changes: 8 additions & 1 deletion internal/rpp/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<image>/tags and /v1/images/<image>/tags/<tag>.
// routes:
//
// - GET /v1/images/<image>/tags - list available versions
// - GET /v1/images/<image>/images/<version> - 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=<os>-<arch> 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
14 changes: 9 additions & 5 deletions internal/rpp/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<image>/images/<version>).
imagesPathSegment = "images"
)

// pluginNamePattern is the OCI repository path-component grammar (lowercase
Expand Down Expand Up @@ -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/<tag> 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 == "" {
Expand Down
4 changes: 2 additions & 2 deletions internal/rpp/image_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
12 changes: 7 additions & 5 deletions internal/rpp/platform.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ package rpp

import "runtime"

// PlatformSuffix is the "-<os>-<arch>" tag suffix of the current platform
// (e.g. "-linux-amd64"). Matches the publishing convention: one single-platform
// image per tag (e.g. "<tag>-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
}
18 changes: 9 additions & 9 deletions internal/selfupdate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<tag>-<os>-<arch>` first and falls back to the bare
`<tag>` (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=<os>/<arch>`
(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

Expand Down
53 changes: 10 additions & 43 deletions internal/selfupdate/rpp_source.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@ package selfupdate

import (
"context"
"errors"
"strings"

"github.com/deckhouse/deckhouse-cli/internal/rpp"
)
Expand All @@ -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
}
Expand All @@ -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-<os>-<arch>" 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
// ("<tag>-<os>-<arch>") 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
Expand Down
74 changes: 17 additions & 57 deletions internal/selfupdate/rpp_source_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -71,50 +72,40 @@ 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
// "-<os>-<arch>" 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)
assert.True(t, newer)
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)
})

Expand All @@ -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) {
Expand Down
5 changes: 2 additions & 3 deletions internal/selfupdate/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down