diff --git a/cli/cmd/channel_releases.go b/cli/cmd/channel_releases.go index c50ec052e..5acb51bb0 100644 --- a/cli/cmd/channel_releases.go +++ b/cli/cmd/channel_releases.go @@ -9,12 +9,26 @@ import ( func (r *runners) InitChannelReleases(parent *cobra.Command) { cmd := &cobra.Command{ - Use: "releases CHANNEL_ID", + Use: "releases CHANNEL_ID_OR_NAME", Short: "List all releases in a channel", - Long: "List all releases in a channel", + Long: "List all releases promoted to a channel, including demoted releases. Accepts a channel ID or name.", + Example: `# List releases for a channel by name +replicated channel releases Stable + +# List releases for a channel by ID +replicated channel releases 2abc123 + +# JSON output for scripting or AI agents +replicated channel releases Stable --output json + +# Paginate (second page of 50) +replicated channel releases Stable --page 1 --page-size 50`, } - cmd.Hidden = true // Not supported in KOTS parent.AddCommand(cmd) + cmd.Flags().StringVarP(&r.outputFormat, "output", "o", "table", "The output format to use. One of: json|table") + cmd.Flags().IntVar(&r.args.channelReleasesPage, "page", 0, "The page to fetch (KOTS apps only).") + cmd.Flags().IntVar(&r.args.channelReleasesPageSize, "page-size", 0, "The number of releases per page (KOTS apps only).") + cmd.RunE = r.channelReleases } @@ -24,23 +38,34 @@ func (r *runners) channelReleases(cmd *cobra.Command, args []string) error { } if len(args) != 1 { - return errors.New("channel ID is required") + return errors.New("channel name or ID is required") } - chanID := args[0] + channelNameOrID := args[0] - if r.appType == "platform" { + if r.args.channelReleasesPage != 0 && r.args.channelReleasesPageSize == 0 { + return errors.New("--page requires --page-size") + } - _, releases, err := r.platformAPI.GetChannel(r.appID, chanID) + channel, err := r.api.GetChannelByName(r.appID, r.appType, channelNameOrID) + if err != nil { + return err + } + + if r.appType == "platform" { + _, releases, err := r.platformAPI.GetChannel(r.appID, channel.ID) if err != nil { return err } - if err = print.ChannelReleases(r.w, releases); err != nil { + return print.ChannelReleases(r.outputFormat, r.w, releases) + } else if r.appType == "kots" { + releases, err := r.api.ListChannelReleasesPaged(r.appID, r.appType, channel.ID, "", r.args.channelReleasesPage, r.args.channelReleasesPageSize) + if err != nil { return err } - } else if r.appType == "kots" { - return errors.New("This feature is not supported for Kots applications.") + + return print.KotsChannelReleases(r.outputFormat, r.w, releases) } - return nil + return errors.New("unknown app type") } diff --git a/cli/cmd/runner.go b/cli/cmd/runner.go index d210a53ef..1ab110ad6 100644 --- a/cli/cmd/runner.go +++ b/cli/cmd/runner.go @@ -41,6 +41,9 @@ type runnerArgs struct { channelCreateName string channelCreateDescription string + channelReleasesPage int + channelReleasesPageSize int + releaseImageLSChannel string releaseImageLSVersion string releaseImageLSKeepProxy bool diff --git a/cli/print/channel_releases.go b/cli/print/channel_releases.go index bc3bd60bf..03e9f08aa 100644 --- a/cli/print/channel_releases.go +++ b/cli/print/channel_releases.go @@ -1,11 +1,13 @@ package print import ( + "encoding/json" "fmt" "text/tabwriter" "text/template" channels "github.com/replicatedhq/replicated/gen/go/v1" + "github.com/replicatedhq/replicated/pkg/types" ) var channelReleasesTmplSrc = `CHANNEL_SEQUENCE RELEASE_SEQUENCE RELEASED VERSION REQUIRED AIRGAP_STATUS RELEASE_NOTES @@ -15,7 +17,15 @@ var channelReleasesTmplSrc = `CHANNEL_SEQUENCE RELEASE_SEQUENCE RELEASED VERSION var channelReleasesTmpl = template.Must(template.New("ChannelReleases").Funcs(funcs).Parse(channelReleasesTmplSrc)) -func ChannelReleases(w *tabwriter.Writer, releases []channels.ChannelRelease) error { +func ChannelReleases(outputFormat string, w *tabwriter.Writer, releases []channels.ChannelRelease) error { + if outputFormat == "json" { + out, _ := json.MarshalIndent(releases, "", " ") + if _, err := fmt.Fprintln(w, string(out)); err != nil { + return err + } + return w.Flush() + } + if len(releases) == 0 { if _, err := fmt.Fprintln(w, "No releases in channel"); err != nil { return err @@ -29,3 +39,49 @@ func ChannelReleases(w *tabwriter.Writer, releases []channels.ChannelRelease) er return w.Flush() } + +var kotsChannelReleasesTmplSrc = `CHANNEL_SEQUENCE RELEASE_SEQUENCE VERSION CREATED RELEASED STATE +{{ range . -}} +{{ .ChannelSequence }} {{ .Sequence }} {{ .Semver }} {{ time .Created }} {{ time .ReleasedAt }} {{ .State }} +{{ end }}` + +var kotsChannelReleasesTmpl = template.Must(template.New("KotsChannelReleases").Funcs(funcs).Parse(kotsChannelReleasesTmplSrc)) + +func KotsChannelReleases(outputFormat string, w *tabwriter.Writer, releases []*types.ChannelRelease) error { + if outputFormat == "json" { + out, _ := json.MarshalIndent(releases, "", " ") + if _, err := fmt.Fprintln(w, string(out)); err != nil { + return err + } + return w.Flush() + } + + if len(releases) == 0 { + if _, err := fmt.Fprintln(w, "No releases in channel"); err != nil { + return err + } + return w.Flush() + } + + rows := make([]map[string]interface{}, len(releases)) + for i, r := range releases { + state := "active" + if r.IsDemoted { + state = "demoted" + } + rows[i] = map[string]interface{}{ + "ChannelSequence": r.ChannelSequence, + "Sequence": r.Sequence, + "Semver": r.Semver, + "Created": r.Created, + "ReleasedAt": r.ReleasedAt, + "State": state, + } + } + + if err := kotsChannelReleasesTmpl.Execute(w, rows); err != nil { + return err + } + + return w.Flush() +} diff --git a/cli/print/channel_releases_test.go b/cli/print/channel_releases_test.go new file mode 100644 index 000000000..13f755362 --- /dev/null +++ b/cli/print/channel_releases_test.go @@ -0,0 +1,80 @@ +package print + +import ( + "bytes" + "encoding/json" + "testing" + "text/tabwriter" + "time" + + "github.com/replicatedhq/replicated/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestKotsChannelReleases_Table(t *testing.T) { + demoted := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC) + releases := []*types.ChannelRelease{ + { + ChannelSequence: 5, + Sequence: 12, + Semver: "1.2.0", + Created: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), + ReleasedAt: time.Date(2026, 2, 2, 0, 0, 0, 0, time.UTC), + }, + { + ChannelSequence: 4, + Sequence: 11, + Semver: "1.1.0", + Created: time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC), + ReleasedAt: time.Date(2026, 1, 16, 0, 0, 0, 0, time.UTC), + IsDemoted: true, + DemotedAt: &demoted, + }, + } + + var out bytes.Buffer + w := tabwriter.NewWriter(&out, 0, 8, 4, ' ', tabwriter.TabIndent) + require.NoError(t, KotsChannelReleases("table", w, releases)) + + got := out.String() + assert.Contains(t, got, "CHANNEL_SEQUENCE") + assert.Contains(t, got, "RELEASE_SEQUENCE") + assert.Contains(t, got, "VERSION") + assert.Contains(t, got, "STATE") + assert.Contains(t, got, "1.2.0") + assert.Contains(t, got, "active") + assert.Contains(t, got, "demoted") +} + +func TestKotsChannelReleases_JSON(t *testing.T) { + releases := []*types.ChannelRelease{ + { + ChannelSequence: 5, + Sequence: 12, + Semver: "1.2.0", + }, + } + + var out bytes.Buffer + w := tabwriter.NewWriter(&out, 0, 8, 4, ' ', tabwriter.TabIndent) + require.NoError(t, KotsChannelReleases("json", w, releases)) + + var decoded []*types.ChannelRelease + require.NoError(t, json.Unmarshal(out.Bytes(), &decoded)) + require.Len(t, decoded, 1) + assert.Equal(t, "1.2.0", decoded[0].Semver) + assert.Equal(t, int32(12), decoded[0].Sequence) + + // isDemoted and demotedAt must always appear so agents can do + // `release.isDemoted === false` instead of seeing undefined. + assert.Contains(t, out.String(), `"isDemoted": false`) + assert.Contains(t, out.String(), `"demotedAt": null`) +} + +func TestKotsChannelReleases_Empty(t *testing.T) { + var out bytes.Buffer + w := tabwriter.NewWriter(&out, 0, 8, 4, ' ', tabwriter.TabIndent) + require.NoError(t, KotsChannelReleases("table", w, nil)) + assert.Contains(t, out.String(), "No releases in channel") +} diff --git a/cli/print/release.go b/cli/print/release.go index 0dc95fbb2..35f512dc3 100644 --- a/cli/print/release.go +++ b/cli/print/release.go @@ -12,7 +12,8 @@ import ( var releaseTmplSrc = `SEQUENCE: {{ .Sequence }} CREATED: {{ time .CreatedAt }} EDITED: {{ time .EditedAt }} -{{if .CompatibilityResults}}COMPATIBILITY RESULTS: +{{if .Channels}}CHANNELS: {{ range $i, $c := .Channels }}{{if $i}}, {{end}}{{ $c.Name }}{{ end }} +{{end}}{{if .CompatibilityResults}}COMPATIBILITY RESULTS: DISTRIBUTION VERSION SUCCESS_AT SUCCESS_NOTES FAILURE_AT FAILURE_NOTES {{ range .CompatibilityResults -}} {{ .Distribution }} {{ .Version }} {{if .SuccessAt}}{{ time .SuccessAt }}{{else}}-{{end}} {{ .SuccessNotes }} {{if .FailureAt}}{{ time .FailureAt }}{{else}}-{{end}} {{ .FailureNotes }} diff --git a/cli/print/release_test.go b/cli/print/release_test.go new file mode 100644 index 000000000..0b6ae7f1b --- /dev/null +++ b/cli/print/release_test.go @@ -0,0 +1,59 @@ +package print + +import ( + "bytes" + "encoding/json" + "testing" + "text/tabwriter" + "time" + + "github.com/replicatedhq/replicated/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRelease_Table_RendersChannels(t *testing.T) { + release := &types.AppRelease{ + Sequence: 42, + CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + EditedAt: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC), + Channels: []*types.Channel{ + {ID: "c1", Name: "Stable"}, + {ID: "c2", Name: "Beta"}, + }, + Config: "spec: yaml", + } + + var out bytes.Buffer + w := tabwriter.NewWriter(&out, 0, 8, 4, ' ', tabwriter.TabIndent) + require.NoError(t, Release("table", w, release)) + + got := out.String() + assert.Contains(t, got, "CHANNELS:") + assert.Contains(t, got, "Stable") + assert.Contains(t, got, "Beta") +} + +func TestRelease_Table_NoChannelsSection_WhenEmpty(t *testing.T) { + release := &types.AppRelease{Sequence: 1, Config: "x"} + var out bytes.Buffer + w := tabwriter.NewWriter(&out, 0, 8, 4, ' ', tabwriter.TabIndent) + require.NoError(t, Release("table", w, release)) + assert.NotContains(t, out.String(), "CHANNELS:") +} + +func TestRelease_JSON_IncludesChannels(t *testing.T) { + release := &types.AppRelease{ + Sequence: 7, + Channels: []*types.Channel{{ID: "c1", Name: "Stable"}}, + } + + var out bytes.Buffer + w := tabwriter.NewWriter(&out, 0, 8, 4, ' ', tabwriter.TabIndent) + require.NoError(t, Release("json", w, release)) + + var decoded types.AppRelease + require.NoError(t, json.Unmarshal(out.Bytes(), &decoded)) + require.Len(t, decoded.Channels, 1) + assert.Equal(t, "Stable", decoded.Channels[0].Name) +} diff --git a/client/channel.go b/client/channel.go index a0390021f..f352f4d1e 100644 --- a/client/channel.go +++ b/client/channel.go @@ -197,6 +197,15 @@ func (c *Client) ListChannelReleases(appID string, appType string, channelID str return nil, errors.Errorf("unknown app type %q", appType) } +func (c *Client) ListChannelReleasesPaged(appID string, appType string, channelID string, includeInstallerImages string, page int, pageSize int) ([]*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.ListChannelReleasesPaged(appID, channelID, includeInstallerImages, page, pageSize) + } + 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.") diff --git a/pkg/integration/channel_test.go b/pkg/integration/channel_test.go new file mode 100644 index 000000000..8e0853b88 --- /dev/null +++ b/pkg/integration/channel_test.go @@ -0,0 +1,277 @@ +package integration + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/gorilla/mux" + "github.com/replicatedhq/replicated/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestChannelReleases(t *testing.T) { + tests := []struct { + name string + cliArgs []string + appType string // "kots" or "platform" — controls which apps endpoint returns the app + channels []map[string]interface{} + releases interface{} + wantFormat format + wantLines int + wantOutput string // when set, asserted exactly + wantContain []string // substrings the output must contain + wantQuery map[string]string // query params asserted on the releases request + wantExit int // expected non-zero exit; 0 means must succeed + assertJSON func(t *testing.T, raw []byte) + }{ + { + name: "kots channel releases by ID, table, with demoted state", + cliArgs: []string{"channel", "releases", "chan-1"}, + appType: "kots", + channels: []map[string]interface{}{ + {"id": "chan-1", "name": "Stable", "channelSlug": "stable"}, + }, + releases: []map[string]interface{}{ + {"channelSequence": 5, "sequence": 12, "semver": "1.2.0", "isDemoted": false}, + {"channelSequence": 4, "sequence": 11, "semver": "1.1.0", "isDemoted": true, "demotedAt": "2026-05-01T00:00:00Z"}, + }, + wantFormat: FormatTable, + wantContain: []string{ + "CHANNEL_SEQUENCE", "RELEASE_SEQUENCE", "VERSION", "STATE", + "1.2.0", "active", + "1.1.0", "demoted", + }, + }, + { + // Exercises GetChannelByName's fallthrough: GET /v3/app/.../channel/Stable + // returns 404 (not an ID), then GET /v3/app/.../channels?channelName=Stable + // returns the list, and we match by name. + name: "kots channel releases by name resolves through ListChannels", + cliArgs: []string{"channel", "releases", "Stable"}, + appType: "kots", + channels: []map[string]interface{}{ + {"id": "chan-1", "name": "Stable", "channelSlug": "stable"}, + }, + releases: []map[string]interface{}{ + {"channelSequence": 1, "sequence": 1, "semver": "1.0.0"}, + }, + wantFormat: FormatTable, + wantContain: []string{"1.0.0", "active"}, + }, + { + name: "--page without --page-size errors", + cliArgs: []string{"channel", "releases", "chan-1", "--page", "2"}, + appType: "kots", + channels: []map[string]interface{}{ + {"id": "chan-1", "name": "Stable", "channelSlug": "stable"}, + }, + releases: []map[string]interface{}{}, + wantExit: 1, + wantContain: []string{"--page requires --page-size"}, + }, + { + name: "kots channel releases JSON includes isDemoted/demotedAt", + cliArgs: []string{"channel", "releases", "chan-1", "--output", "json"}, + appType: "kots", + channels: []map[string]interface{}{ + {"id": "chan-1", "name": "Stable", "channelSlug": "stable"}, + }, + releases: []map[string]interface{}{ + {"channelSequence": 4, "sequence": 11, "semver": "1.1.0", "isDemoted": true, "demotedAt": "2026-05-01T00:00:00Z"}, + }, + wantFormat: FormatJSON, + assertJSON: func(t *testing.T, raw []byte) { + var got []*types.ChannelRelease + require.NoError(t, json.Unmarshal(raw, &got)) + require.Len(t, got, 1) + assert.Equal(t, "1.1.0", got[0].Semver) + assert.True(t, got[0].IsDemoted) + require.NotNil(t, got[0].DemotedAt) + }, + }, + { + name: "kots channel releases pagination sends currentPage=0 explicitly", + cliArgs: []string{"channel", "releases", "chan-1", "--page", "0", "--page-size", "5"}, + appType: "kots", + channels: []map[string]interface{}{ + {"id": "chan-1", "name": "Stable", "channelSlug": "stable"}, + }, + releases: []map[string]interface{}{ + {"channelSequence": 5, "sequence": 12, "semver": "1.2.0"}, + }, + wantQuery: map[string]string{ + "currentPage": "0", + "pageSize": "5", + }, + wantFormat: FormatTable, + wantContain: []string{"1.2.0", "active"}, + }, + { + name: "kots channel releases empty channel", + cliArgs: []string{"channel", "releases", "chan-1"}, + appType: "kots", + channels: []map[string]interface{}{ + {"id": "chan-1", "name": "Stable", "channelSlug": "stable"}, + }, + releases: []map[string]interface{}{}, + wantFormat: FormatTable, + wantOutput: "No releases in channel\n", + }, + { + name: "platform channel releases table — legacy app regression guard", + cliArgs: []string{"channel", "releases", "chan-1"}, + appType: "platform", + releases: map[string]interface{}{ + "channel": map[string]interface{}{ + "Id": "chan-1", + "Name": "Stable", + }, + "releases": []map[string]interface{}{ + { + "channel_sequence": 2, + "release_sequence": 42, + "created": "2026-04-01T00:00:00Z", + "version": "1.0.0", + "required": false, + "airgap_build_status": "built", + "release_notes": "first", + }, + }, + }, + wantFormat: FormatTable, + wantContain: []string{ + "CHANNEL_SEQUENCE", "RELEASE_SEQUENCE", "RELEASED", "VERSION", "REQUIRED", "AIRGAP_STATUS", + "42", "1.0.0", "built", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var capturedQuery sync.Map + server := setupChannelTestServer(t, tt.appType, tt.channels, tt.releases, &capturedQuery) + defer server.Close() + + cmd := getCommand(tt.cliArgs, server) + // Per-test HOME so the app-type cache from one case doesn't bleed into the next + // (getCommand defaults HOME to os.TempDir(), which is shared across cases). + cmd.Env = append(cmd.Env, "REPLICATED_APP=test-app", "HOME="+t.TempDir()) + + out, err := cmd.CombinedOutput() + if tt.wantExit != 0 { + assert.Error(t, err, "expected non-zero exit; output:\n%s", string(out)) + for _, want := range tt.wantContain { + assert.Contains(t, string(out), want) + } + return + } + assert.NoError(t, err, "cli failed: %s", string(out)) + + if tt.wantOutput != "" { + require.Equal(t, tt.wantOutput, string(out)) + return + } + + if tt.wantFormat == FormatJSON && tt.assertJSON != nil { + tt.assertJSON(t, out) + return + } + + for _, want := range tt.wantContain { + assert.Contains(t, string(out), want, "missing %q in output:\n%s", want, out) + } + + if tt.wantQuery != nil { + for k, v := range tt.wantQuery { + got, ok := capturedQuery.Load(k) + assert.True(t, ok, "expected query param %q not sent", k) + assert.Equal(t, v, got, "query param %q mismatch", k) + } + } + }) + } +} + +// setupChannelTestServer wires the minimum endpoints needed to drive +// `replicated channel releases` end-to-end for either appType. +func setupChannelTestServer(t *testing.T, appType string, channels []map[string]interface{}, releases interface{}, capturedQuery *sync.Map) *httptest.Server { + r := mux.NewRouter() + + switch appType { + case "kots": + // /v1/apps must 404-equivalent so PlatformClient.GetApp fails and GetAppType falls through to kots. + r.Methods(http.MethodGet).Path("/v1/apps").HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`[]`)) + }) + + r.Methods(http.MethodGet).Path("/v3/apps").HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"apps":[{"id":"app-123","name":"Test App","slug":"test-app"}]}`)) + }) + + // GetChannelByName first tries GetChannel by ID. Returns the channel if the + // arg matches a known ID, else 404 to trigger the ListChannels fallthrough. + r.Methods(http.MethodGet).Path("/v3/app/{appID}/channel/{channelID}").HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + vars := mux.Vars(req) + for _, c := range channels { + if c["id"] == vars["channelID"] { + body, _ := json.Marshal(map[string]interface{}{"channel": c}) + w.WriteHeader(http.StatusOK) + w.Write(body) + return + } + } + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"error":"not found"}`)) + }) + + // ListChannels — fallthrough path when GetChannel-by-ID returned 404 because + // the arg was actually a name. + r.Methods(http.MethodGet).Path("/v3/app/{appID}/channels").HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + body, _ := json.Marshal(map[string]interface{}{"channels": channels}) + w.WriteHeader(http.StatusOK) + w.Write(body) + }) + + r.Methods(http.MethodGet).Path("/v3/app/{appID}/channel/{channelID}/releases").HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + for k, v := range req.URL.Query() { + if len(v) > 0 { + capturedQuery.Store(k, v[0]) + } + } + body, _ := json.Marshal(map[string]interface{}{"releases": releases}) + w.WriteHeader(http.StatusOK) + w.Write(body) + }) + + case "platform": + r.Methods(http.MethodGet).Path("/v1/apps").HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`[{"app":{"Id":"app-123","Name":"Test App","Slug":"test-app","Scheduler":"native"}}]`)) + }) + + // Platform GetChannel returns channel + releases in one shot. Hit twice per command + // (once for GetChannelByName, once by the command itself); same handler serves both. + r.Methods(http.MethodGet).Path("/v1/app/{appID}/channel/{channelID}/releases").HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + body, _ := json.Marshal(releases) + w.WriteHeader(http.StatusOK) + w.Write(body) + }) + + default: + t.Fatalf("unknown appType %q", appType) + } + + // Catch-all to surface unexpected calls in test output. + r.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + t.Logf("unexpected %s %s?%s", req.Method, req.URL.Path, req.URL.RawQuery) + http.NotFound(w, req) + }) + + return httptest.NewServer(r) +} diff --git a/pkg/kotsclient/channel.go b/pkg/kotsclient/channel.go index 167ee68fa..b2162f525 100644 --- a/pkg/kotsclient/channel.go +++ b/pkg/kotsclient/channel.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/url" + "strconv" "github.com/pkg/errors" "github.com/replicatedhq/replicated/pkg/types" @@ -154,14 +155,30 @@ 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) +} + +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"` } response := listChannelReleasesResponse{} - reqURL := fmt.Sprintf("/v3/app/%s/channel/%s/releases", appID, url.QueryEscape(channelID)) + v := url.Values{} if includeInstallerImages != "" { - reqURL = fmt.Sprintf("%s?includeInstallerImages=%s", reqURL, url.QueryEscape(includeInstallerImages)) + v.Set("includeInstallerImages", includeInstallerImages) + } + // Pagination is opt-in via pageSize. Once enabled, currentPage is sent + // alongside it (0-indexed, matching the API and other callers like + // ListReleases). page is only meaningful when pageSize is set. + if pageSize > 0 { + v.Set("currentPage", strconv.Itoa(page)) + v.Set("pageSize", strconv.Itoa(pageSize)) + } + + 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 { diff --git a/pkg/kotsclient/release.go b/pkg/kotsclient/release.go index 26eedeae3..cb2a8f161 100644 --- a/pkg/kotsclient/release.go +++ b/pkg/kotsclient/release.go @@ -61,6 +61,7 @@ func (c *VendorV3Client) GetRelease(appID string, sequence int64) (*types.AppRel Sequence: resp.Release.Sequence, Charts: resp.Release.Charts, CompatibilityResults: resp.Release.CompatibilityResults, + Channels: resp.Release.Channels, IsHelmOnly: resp.Release.IsHelmOnly, } diff --git a/pkg/types/channel.go b/pkg/types/channel.go index 894f4372b..cbe95ea55 100644 --- a/pkg/types/channel.go +++ b/pkg/types/channel.go @@ -78,6 +78,11 @@ type ChannelRelease struct { Semver string `json:"semver,omitempty"` Sequence int32 `json:"sequence,omitempty"` Updated time.Time `json:"updated,omitempty"` + // IsDemoted and DemotedAt intentionally omit `omitempty`: agents consuming + // the JSON need to distinguish "explicitly not demoted" from "field absent", + // and Go's omitempty on a bool would drop `false`. + IsDemoted bool `json:"isDemoted"` + DemotedAt *time.Time `json:"demotedAt"` InstallationTypes InstallationTypes `json:"installationTypes,omitempty"` } diff --git a/pkg/types/release.go b/pkg/types/release.go index 74979c487..d78365836 100644 --- a/pkg/types/release.go +++ b/pkg/types/release.go @@ -118,5 +118,6 @@ type AppRelease struct { Sequence int64 `json:"sequence,omitempty"` Charts []Chart `json:"charts,omitempty"` CompatibilityResults []CompatibilityResult `json:"compatibilityResults,omitempty"` + Channels []*Channel `json:"channels,omitempty"` IsHelmOnly bool `json:"isHelmOnly,omitempty"` }