Skip to content
Open
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
28 changes: 21 additions & 7 deletions cli/cmd/release_image_ls.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,9 @@ func (r *runners) releaseImageLS(cmd *cobra.Command, args []string) error {
var proxyDomain string

if r.args.releaseImageLSVersion != "" {
// For specific versions, we need to get all releases
channelReleases, err := r.api.ListChannelReleases(r.appID, r.appType, channel.ID, r.args.releaseImageLSIncludeInstallerImages)
if err != nil {
return fmt.Errorf("failed to list channel releases: %w", err)
}

targetRelease, err = findTargetRelease(channelReleases, r.args.releaseImageLSVersion)
// For specific versions, try the server-side versionLabel filter first.
// This avoids downloading every page of a large channel history.
targetRelease, err = r.findReleaseByVersion(channel.ID, r.args.releaseImageLSVersion)
if err != nil {
return err
}
Expand Down Expand Up @@ -173,6 +169,24 @@ func cleanImageName(image string, proxyRegistryDomain string) string {
return cleaned
}

// findReleaseByVersion tries the API's versionLabel filter first, and falls back
// to listing all releases when the filter errors or returns no semver match.
func (r *runners) findReleaseByVersion(channelID string, version string) (*types.ChannelRelease, error) {
filteredReleases, err := r.api.ListChannelReleasesByVersion(r.appID, r.appType, channelID, version, r.args.releaseImageLSIncludeInstallerImages)
if err == nil {
if release, err := findTargetRelease(filteredReleases, version); err == nil {

@divolgin divolgin Jul 28, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

findTargetRelease should be deleted and not used anywhere.

  • ListChannelReleasesByVersion returns releases sorted by channel sequence and findTargetRelease will always return the first item on the list.
  • if ListChannelReleasesByVersion does not match a release, ListChannelReleases will not return any matching releases either and findTargetRelease will always return nil error.

There is no need for a fallback here since version string is never empty. But also ListChannelReleases will be very slow when there are a lot of releases on a channel.

return release, nil
}
}

// Fallback: list all releases and search by semver.
allReleases, err := r.api.ListChannelReleases(r.appID, r.appType, channelID, r.args.releaseImageLSIncludeInstallerImages)
if err != nil {
return nil, fmt.Errorf("failed to list channel releases: %w", err)
}
return findTargetRelease(allReleases, version)
}

// findTargetRelease finds the target release from a list of releases
// If requestedVersion is empty, returns the current release (highest channel sequence)
// If requestedVersion is specified, returns the release with matching semver
Expand Down
9 changes: 9 additions & 0 deletions client/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,15 @@ func (c *Client) ListChannelReleasesPaged(appID string, appType string, channelI
return nil, errors.Errorf("unknown app type %q", appType)
}

func (c *Client) ListChannelReleasesByVersion(appID string, appType string, channelID string, versionLabel string, includeInstallerImages string) ([]*types.ChannelRelease, error) {
if appType == "platform" {
return nil, errors.New("This feature is not currently supported for Platform applications.")
} else if appType == "kots" {
return c.KotsClient.ListChannelReleasesByVersion(appID, channelID, versionLabel, includeInstallerImages)
}
return nil, errors.Errorf("unknown app type %q", appType)
}

func (c *Client) GetCustomHostnames(appID string, appType string, channelID string) (*types.CustomHostNameOverrides, error) {
if appType == "platform" {
return nil, errors.New("This feature is not currently supported for Platform applications.")
Expand Down
48 changes: 44 additions & 4 deletions pkg/kotsclient/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ type ListChannelsResponse struct {
Channels []*types.KotsChannel `json:"channels"`
}

type listChannelReleasesResponse struct {
Releases []*types.ChannelRelease `json:"releases"`
}

func (c *VendorV3Client) ListKotsChannels(appID string, channelName string, excludeDetails bool) ([]*types.KotsChannel, error) {
var response = ListChannelsResponse{}
v := url.Values{}
Expand Down Expand Up @@ -155,14 +159,50 @@ func (c *VendorV3Client) UnDemoteChannelRelease(appID string, channelID string,
}

func (c *VendorV3Client) ListChannelReleases(appID string, channelID string, includeInstallerImages string) ([]*types.ChannelRelease, error) {
return c.ListChannelReleasesPaged(appID, channelID, includeInstallerImages, 0, 0)
allReleases := []*types.ChannelRelease{}
page := 0
pageSize := 20

for {
releases, err := c.ListChannelReleasesPaged(appID, channelID, includeInstallerImages, page, pageSize)
if err != nil {
return nil, err
}
if len(releases) == 0 {
break
}
allReleases = append(allReleases, releases...)
page += 1
}

return allReleases, nil
}

func (c *VendorV3Client) ListChannelReleasesPaged(appID string, channelID string, includeInstallerImages string, page int, pageSize int) ([]*types.ChannelRelease, error) {
type listChannelReleasesResponse struct {
Releases []*types.ChannelRelease `json:"releases"`
func (c *VendorV3Client) ListChannelReleasesByVersion(appID string, channelID string, versionLabel string, includeInstallerImages string) ([]*types.ChannelRelease, error) {
response := listChannelReleasesResponse{}
v := url.Values{}
v.Set("versionLabel", versionLabel)
if includeInstallerImages != "" {
v.Set("includeInstallerImages", includeInstallerImages)
}
// A versionLabel filter should return a small number of releases, so a
// single page is sufficient. This avoids the cost of paginating through a
// large channel history when looking for a specific version.
v.Set("pageSize", "20")

reqURL := fmt.Sprintf("/v3/app/%s/channel/%s/releases", appID, url.QueryEscape(channelID))
if encoded := v.Encode(); encoded != "" {
reqURL = fmt.Sprintf("%s?%s", reqURL, encoded)
}
err := c.DoJSON(context.TODO(), "GET", reqURL, http.StatusOK, nil, &response)
if err != nil {
return nil, errors.Wrap(err, "list channel releases by version")
}

return response.Releases, nil
}

func (c *VendorV3Client) ListChannelReleasesPaged(appID string, channelID string, includeInstallerImages string, page int, pageSize int) ([]*types.ChannelRelease, error) {
response := listChannelReleasesResponse{}
v := url.Values{}
if includeInstallerImages != "" {
Expand Down
108 changes: 108 additions & 0 deletions pkg/kotsclient/channel_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package kotsclient

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/replicatedhq/replicated/pkg/platformclient"
"github.com/replicatedhq/replicated/pkg/types"
"github.com/stretchr/testify/require"
)

func TestListChannelReleases_Pagination(t *testing.T) {
pageCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/v3/app/app-id/channel/channel-id/releases", r.URL.Path)
require.Equal(t, "test-token", r.Header.Get("Authorization"))

page := r.URL.Query().Get("currentPage")
pageSize := r.URL.Query().Get("pageSize")
require.Equal(t, "20", pageSize)
pageCount += 1

releases := []types.ChannelRelease{}
switch page {
case "0":
for i := 0; i < 20; i++ {
releases = append(releases, types.ChannelRelease{Semver: "1.0.0"})
}
case "1":
releases = append(releases, types.ChannelRelease{Semver: "2.17.12"})
default:
// Empty page signals end of results
}

_ = json.NewEncoder(w).Encode(map[string]interface{}{
"releases": releases,
})
}))
defer server.Close()

api := platformclient.NewHTTPClient(server.URL, "test-token")
client := VendorV3Client{HTTPClient: *api}

releases, err := client.ListChannelReleases("app-id", "channel-id", "")
require.NoError(t, err)
require.Len(t, releases, 21)
require.Equal(t, "2.17.12", releases[20].Semver)
require.Equal(t, 3, pageCount)
}

func TestListChannelReleases_Pagination_ExactPageSize(t *testing.T) {
pageCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/v3/app/app-id/channel/channel-id/releases", r.URL.Path)

page := r.URL.Query().Get("currentPage")
pageCount += 1

releases := []types.ChannelRelease{}
switch page {
case "0", "1":
for i := 0; i < 20; i++ {
releases = append(releases, types.ChannelRelease{Semver: "1.0.0"})
}
default:
// Empty page signals end of results
}

_ = json.NewEncoder(w).Encode(map[string]interface{}{
"releases": releases,
})
}))
defer server.Close()

api := platformclient.NewHTTPClient(server.URL, "test-token")
client := VendorV3Client{HTTPClient: *api}

releases, err := client.ListChannelReleases("app-id", "channel-id", "")
require.NoError(t, err)
require.Len(t, releases, 40)
require.Equal(t, 3, pageCount)
}

func TestListChannelReleasesByVersion(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/v3/app/app-id/channel/channel-id/releases", r.URL.Path)
require.Equal(t, "test-token", r.Header.Get("Authorization"))
require.Equal(t, "2.17.12", r.URL.Query().Get("versionLabel"))
require.Equal(t, "20", r.URL.Query().Get("pageSize"))

_ = json.NewEncoder(w).Encode(map[string]interface{}{
"releases": []types.ChannelRelease{
{Semver: "2.17.12"},
},
})
}))
defer server.Close()

api := platformclient.NewHTTPClient(server.URL, "test-token")
client := VendorV3Client{HTTPClient: *api}

releases, err := client.ListChannelReleasesByVersion("app-id", "channel-id", "2.17.12", "")
require.NoError(t, err)
require.Len(t, releases, 1)
require.Equal(t, "2.17.12", releases[0].Semver)
}