diff --git a/cli/cmd/release_create.go b/cli/cmd/release_create.go index 338a932bb..8362b6e14 100644 --- a/cli/cmd/release_create.go +++ b/cli/cmd/release_create.go @@ -74,6 +74,8 @@ func (r *runners) InitReleaseCreate(parent *cobra.Command) error { cmd.Flags().BoolVar(&r.args.createReleaseLint, "lint", false, "Lint a manifests directory prior to creation of the KOTS Release.") cmd.Flags().BoolVar(&r.args.createReleasePromoteRequired, "required", false, "When used with --promote , marks this release as required during upgrades.") cmd.Flags().BoolVar(&r.args.createReleasePromoteEnsureChannel, "ensure-channel", false, "When used with --promote , will create the channel if it doesn't exist") + cmd.Flags().BoolVar(&r.args.createReleasePromoteWaitForAirgap, "wait-for-airgap", false, "When used with --promote , wait for airgap bundle builds to complete (KOTS apps only)") + cmd.Flags().DurationVar(&r.args.createReleasePromoteWaitForAirgapTimeout, "wait-for-airgap-timeout", 30*time.Minute, "Timeout for waiting on airgap bundle builds") cmd.Flags().BoolVar(&r.args.createReleaseAutoDefaults, "auto", false, "generate default values for use in CI") cmd.Flags().BoolVarP(&r.args.createReleaseAutoDefaultsAccept, "confirm-auto", "y", false, "auto-accept the configuration generated by the --auto flag") cmd.Flags().StringVar(&r.args.createReleaseOutputDir, "output-dir", "", "Stage the release artifacts (packaged charts and manifests) to this directory. Existing contents of the directory are removed before each run. The directory is preserved after the command completes.") @@ -231,6 +233,9 @@ func (r *runners) releaseCreate(cmd *cobra.Command, args []string) (err error) { defer func() { printIfError(cmd, err) }() + // Flush the tabwriter on every return path so buffered output (including + // airgap waiter messages on the error path) actually reaches stdout. + defer r.w.Flush() log := logger.NewLogger(r.w).SetIsTerminal(r.stdoutIsTTY) if r.outputFormat == "json" { @@ -469,7 +474,7 @@ Prepared to create release with defaults: if promoteChanID != "" { log.ActionWithSpinner("Promoting") - if err = r.api.PromoteRelease( + promoteResp, err := r.api.PromoteRelease( r.appID, r.appType, release.Sequence, @@ -477,7 +482,8 @@ Prepared to create release with defaults: r.args.createReleasePromoteNotes, r.args.createReleasePromoteRequired, promoteChanID, - ); err != nil { + ) + if err != nil { log.FinishSpinnerWithError() return errors.Wrap(err, "promote release") } @@ -485,6 +491,12 @@ Prepared to create release with defaults: // ignore error since operation was successful log.ChildActionWithoutSpinner("Channel %s successfully set to release %d\n", promoteChanID, release.Sequence) + + if r.appType == "kots" && r.args.createReleasePromoteWaitForAirgap { + if err := r.waitForAirgapBuilds(promoteResp, r.args.createReleasePromoteWaitForAirgapTimeout, log); err != nil { + return err + } + } } return nil diff --git a/cli/cmd/release_promote.go b/cli/cmd/release_promote.go index 5b700e88f..37798c9a0 100644 --- a/cli/cmd/release_promote.go +++ b/cli/cmd/release_promote.go @@ -3,9 +3,12 @@ package cmd import ( "fmt" "strconv" + "time" "github.com/pkg/errors" "github.com/replicatedhq/replicated/client" + "github.com/replicatedhq/replicated/pkg/logger" + "github.com/replicatedhq/replicated/pkg/types" "github.com/spf13/cobra" ) @@ -24,14 +27,40 @@ func (r *runners) InitReleasePromote(parent *cobra.Command) { cmd.Flags().BoolVar(&r.args.releaseOptional, "optional", false, "If set, this release can be skipped") cmd.Flags().BoolVar(&r.args.releaseRequired, "required", false, "If set, this release can't be skipped") cmd.Flags().StringVar(&r.args.releaseVersion, "version", "", "A version label for the release in this channel") + cmd.Flags().BoolVar(&r.args.releasePromoteWaitForAirgap, "wait-for-airgap", false, "Wait for airgap bundle builds to complete (KOTS apps only)") + cmd.Flags().DurationVar(&r.args.releasePromoteWaitForAirgapTimeout, "wait-for-airgap-timeout", 30*time.Minute, "Timeout for waiting on airgap bundle builds") cmd.RunE = r.releasePromote } +var inFlightAirgapStates = map[string]bool{ + "pending": true, + "building": true, + "building_bundle": true, + "metadata": true, + "unknown": true, +} + +// terminalFailureStates are the airgap-build outcomes that should fail the +// --wait-for-airgap waiter. "warn" is intentionally NOT in this map — the +// bundle still exists, just with soft warnings about unresolvable image +// references; the waiter surfaces those messages but exits 0. See the +// dedicated "warn" branch in waitForAirgapBuilds. +var terminalFailureStates = map[string]bool{ + "failed": true, + "failed_with_metadata": true, + "cancelled": true, +} + func (r *runners) releasePromote(cmd *cobra.Command, args []string) (err error) { defer func() { printIfError(cmd, err) }() + // Flush the tabwriter on every return path. Without this, returning early + // from waitForAirgapBuilds (e.g. on terminal failure) would discard the + // "Channel successfully set" line and the per-channel failure messages + // the waiter wrote, leaving the user with only the generic error output. + defer r.w.Flush() if !r.hasApp() { return errors.New("no app specified") @@ -68,13 +97,83 @@ func (r *runners) releasePromote(cmd *cobra.Command, args []string) (err error) required = r.args.releaseRequired } - if err = r.api.PromoteRelease(r.appID, r.appType, seq, r.args.releaseVersion, r.args.releaseNotes, required, newID); err != nil { + promoteResp, err := r.api.PromoteRelease(r.appID, r.appType, seq, r.args.releaseVersion, r.args.releaseNotes, required, newID) + if err != nil { return errors.Wrapf(err, "failed to promote release") } - // ignore error since operation was successful fmt.Fprintf(r.w, "Channel %s successfully set to release %d\n", channelName, seq) - r.w.Flush() + + if r.appType == "kots" && r.args.releasePromoteWaitForAirgap { + log := logger.NewLogger(r.w).SetIsTerminal(r.stdoutIsTTY) + if err := r.waitForAirgapBuilds(promoteResp, r.args.releasePromoteWaitForAirgapTimeout, log); err != nil { + return err + } + } return nil } + +func (r *runners) waitForAirgapBuilds(promoteResp *types.PromoteReleaseResponse, timeout time.Duration, log *logger.Logger) error { + // Short-circuit before the range below if the response is nil or carries no + // airgap builds — both would otherwise nil-pointer-deref or no-op the loop. + if promoteResp == nil || len(promoteResp.AirgapBuilds) == 0 { + log.ActionWithoutSpinner("No airgap builds reported for this promotion") + return nil + } + + log.ActionWithSpinner("Waiting for airgap builds") + + start := time.Now() + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + if time.Since(start) > timeout { + log.FinishSpinnerWithError() + return errors.New("timed out waiting for airgap builds") + } + + allTerminal := true + var failures []string + var warnings []string + for _, build := range promoteResp.AirgapBuilds { + status, err := r.kotsAPI.GetAirgapBuildStatus(r.appID, build.ChannelID, build.ChannelSequence) + if err != nil { + // Log the error but continue polling other channels — one channel's + // transient API failure shouldn't mask the status of the rest. + log.ChildActionWithoutSpinner("Warning: could not check airgap status for channel %s: %v", build.ChannelName, err) + allTerminal = false + continue + } + + if inFlightAirgapStates[status.AirgapBuildStatus] { + allTerminal = false + } else if terminalFailureStates[status.AirgapBuildStatus] { + failures = append(failures, fmt.Sprintf("channel %s (%s): %s", status.ChannelName, status.AirgapBuildStatus, status.AirgapBuildError)) + } else if status.AirgapBuildStatus == "warn" { + warnings = append(warnings, fmt.Sprintf("channel %s (warn): %s", status.ChannelName, status.AirgapBuildError)) + } + } + + if allTerminal { + if len(failures) > 0 { + log.FinishSpinnerWithError() + for _, f := range failures { + log.ChildActionWithoutSpinner("%s", f) + } + return errors.New("one or more airgap builds failed") + } + log.FinishSpinner() + if len(warnings) > 0 { + for _, w := range warnings { + log.ChildActionWithoutSpinner("%s", w) + } + } + log.ChildActionWithoutSpinner("Airgap builds complete") + return nil + } + + <-ticker.C + } +} diff --git a/cli/cmd/runner.go b/cli/cmd/runner.go index 1ab110ad6..00dac30b2 100644 --- a/cli/cmd/runner.go +++ b/cli/cmd/runner.go @@ -66,19 +66,21 @@ type runnerArgs struct { createReleasePromoteVersion string createReleasePromoteEnsureChannel bool // Add Create Release Lint - createReleaseLint bool - lintReleaseYamlDir string - lintReleaseChart string - lintReleaseFailOn string - lintVerbose bool - releaseOptional bool - releaseRequired bool - releaseNotes string - releaseVersion string - updateReleaseYaml string - updateReleaseYamlDir string - updateReleaseYamlFile string - updateReleaseChart string + createReleaseLint bool + lintReleaseYamlDir string + lintReleaseChart string + lintReleaseFailOn string + lintVerbose bool + releaseOptional bool + releaseRequired bool + releaseNotes string + releaseVersion string + releasePromoteWaitForAirgap bool + releasePromoteWaitForAirgapTimeout time.Duration + updateReleaseYaml string + updateReleaseYamlDir string + updateReleaseYamlFile string + updateReleaseChart string instanceInspectCustomer string instanceInspectInstance string @@ -93,10 +95,12 @@ type runnerArgs struct { createInstallerPromote string createInstallerPromoteEnsureChannel bool - createReleaseAutoDefaults bool - createReleaseAutoDefaultsAccept bool - createReleaseOutputDir string - createReleaseNoUpload bool + createReleaseAutoDefaults bool + createReleaseAutoDefaultsAccept bool + createReleaseOutputDir string + createReleaseNoUpload bool + createReleasePromoteWaitForAirgap bool + createReleasePromoteWaitForAirgapTimeout time.Duration releaseDownloadDest string releaseDownloadChannel string diff --git a/cli/print/channel_releases.go b/cli/print/channel_releases.go index 03e9f08aa..fee98bf3a 100644 --- a/cli/print/channel_releases.go +++ b/cli/print/channel_releases.go @@ -40,9 +40,9 @@ func ChannelReleases(outputFormat string, w *tabwriter.Writer, releases []channe return w.Flush() } -var kotsChannelReleasesTmplSrc = `CHANNEL_SEQUENCE RELEASE_SEQUENCE VERSION CREATED RELEASED STATE +var kotsChannelReleasesTmplSrc = `CHANNEL_SEQUENCE RELEASE_SEQUENCE VERSION CREATED RELEASED STATE AIRGAP_STATUS AIRGAP_ERROR {{ range . -}} -{{ .ChannelSequence }} {{ .Sequence }} {{ .Semver }} {{ time .Created }} {{ time .ReleasedAt }} {{ .State }} +{{ .ChannelSequence }} {{ .Sequence }} {{ .Semver }} {{ time .Created }} {{ time .ReleasedAt }} {{ .State }} {{ .AirgapBuildStatus }} {{ .AirgapBuildError }} {{ end }}` var kotsChannelReleasesTmpl = template.Must(template.New("KotsChannelReleases").Funcs(funcs).Parse(kotsChannelReleasesTmplSrc)) @@ -70,12 +70,14 @@ func KotsChannelReleases(outputFormat string, w *tabwriter.Writer, releases []*t state = "demoted" } rows[i] = map[string]interface{}{ - "ChannelSequence": r.ChannelSequence, - "Sequence": r.Sequence, - "Semver": r.Semver, - "Created": r.Created, - "ReleasedAt": r.ReleasedAt, - "State": state, + "ChannelSequence": r.ChannelSequence, + "Sequence": r.Sequence, + "Semver": r.Semver, + "Created": r.Created, + "ReleasedAt": r.ReleasedAt, + "State": state, + "AirgapBuildStatus": r.AirgapBuildStatus, + "AirgapBuildError": r.AirgapBuildError, } } diff --git a/cli/print/channel_releases_test.go b/cli/print/channel_releases_test.go index 13f755362..6316c52fd 100644 --- a/cli/print/channel_releases_test.go +++ b/cli/print/channel_releases_test.go @@ -72,6 +72,39 @@ func TestKotsChannelReleases_JSON(t *testing.T) { assert.Contains(t, out.String(), `"demotedAt": null`) } +func TestKotsChannelReleases_AirgapColumns(t *testing.T) { + 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), + AirgapBuildStatus: "failed", + AirgapBuildError: "unauthorized to pull image nginx:latest", + }, + { + 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), + AirgapBuildStatus: "built", + }, + } + + 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, "AIRGAP_STATUS") + assert.Contains(t, got, "AIRGAP_ERROR") + assert.Contains(t, got, "failed") + assert.Contains(t, got, "unauthorized to pull image nginx:latest") + assert.Contains(t, got, "built") +} + func TestKotsChannelReleases_Empty(t *testing.T) { var out bytes.Buffer w := tabwriter.NewWriter(&out, 0, 8, 4, ' ', tabwriter.TabIndent) diff --git a/cli/print/release.go b/cli/print/release.go index 35f512dc3..f71e3877a 100644 --- a/cli/print/release.go +++ b/cli/print/release.go @@ -17,6 +17,11 @@ EDITED: {{ time .EditedAt }} 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 }} + {{ end }}{{end}}{{if .AirgapBuilds}} +AIRGAP BUNDLES: + CHANNEL STATUS ERROR + {{ range .AirgapBuilds -}} + {{ .ChannelName }} {{ .AirgapBuildStatus }} {{if .AirgapBuildError}}{{ .AirgapBuildError }}{{else}}-{{end}} {{ end }}{{end}} CONFIG: {{ .Config }} diff --git a/client/release.go b/client/release.go index 958a24df8..dd63fcbb0 100644 --- a/client/release.go +++ b/client/release.go @@ -118,14 +118,15 @@ func (c *Client) GetRelease(appID string, appType string, sequence int64) (*type return nil, errors.Errorf("unknown app type %q", appType) } -func (c *Client) PromoteRelease(appID string, appType string, sequence int64, label string, notes string, required bool, channelIDs ...string) error { +func (c *Client) PromoteRelease(appID string, appType string, sequence int64, label string, notes string, required bool, channelIDs ...string) (*types.PromoteReleaseResponse, error) { if appType == "platform" { - return c.PlatformClient.PromoteRelease(appID, sequence, label, notes, required, channelIDs...) + err := c.PlatformClient.PromoteRelease(appID, sequence, label, notes, required, channelIDs...) + return nil, err } else if appType == "kots" { return c.KotsClient.PromoteRelease(appID, sequence, label, notes, required, channelIDs...) } - return errors.Errorf("unknown app type %q", appType) + return nil, errors.Errorf("unknown app type %q", appType) } // data is a []byte describing a tarred yaml-dir, created by tarYAMLDir() diff --git a/pact/kotsclient/release_test.go b/pact/kotsclient/release_test.go index 929e163f4..206205492 100644 --- a/pact/kotsclient/release_test.go +++ b/pact/kotsclient/release_test.go @@ -197,7 +197,7 @@ func Test_PromoteRelease(t *testing.T) { api := platformclient.NewHTTPClient(u, "replicated-cli-promote-release-token") client := realkotsclient.VendorV3Client{HTTPClient: *api} - err = client.PromoteRelease("replicated-cli-promote-release-app", 1, "v0.0.1", "releasenotes", false, "replicated-cli-promote-release-unstable") + _, err = client.PromoteRelease("replicated-cli-promote-release-app", 1, "v0.0.1", "releasenotes", false, "replicated-cli-promote-release-unstable") assert.NoError(t, err) return nil @@ -226,6 +226,13 @@ func Test_PromoteRelease(t *testing.T) { }). WillRespondWith(dsl.Response{ Status: 200, + Body: map[string]interface{}{ + "release": map[string]interface{}{ + "appId": "replicated-cli-promote-release-app", + "sequence": int64(1), + }, + "airgapBuilds": []interface{}{}, + }, }) if err := pact.Verify(test); err != nil { diff --git a/pacts/replicated-cli-vendor-api.json b/pacts/replicated-cli-vendor-api.json index 006b95fa7..4623839ab 100644 --- a/pacts/replicated-cli-vendor-api.json +++ b/pacts/replicated-cli-vendor-api.json @@ -6,119 +6,6 @@ "name": "vendor-api" }, "interactions": [ - { - "description": "A request to create a new release for cli-create-release-app-id", - "providerState": "Create a release for cli-create-release-app-id", - "request": { - "method": "POST", - "path": "/v1/app/cli-create-release-app-id/release", - "headers": { - "Authorization": "cli-create-release-auth", - "Content-Type": "application/json" - }, - "body": { - "source": "latest", - "sourcedata": 0 - } - }, - "response": { - "status": 201, - "headers": { - }, - "body": { - "Config": "", - "CreatedAt": "2006-01-02T15:04:05Z", - "Editable": true, - "EditedAt": "2006-01-02T15:04:05Z", - "Sequence": 10 - }, - "matchingRules": { - "$.body.Config": { - "match": "type" - }, - "$.body.CreatedAt": { - "match": "type" - }, - "$.body.EditedAt": { - "match": "type" - }, - "$.body.Sequence": { - "match": "type" - } - } - } - }, - { - "description": "An empty request to create a new release for cli-create-release-app-id", - "providerState": "Empty create a release for cli-create-release-app-id", - "request": { - "method": "POST", - "path": "/v1/app/cli-create-release-app-id/release", - "headers": { - "Authorization": "cli-create-release-auth" - } - }, - "response": { - "status": 201, - "headers": { - }, - "body": { - "Config": "", - "CreatedAt": "2006-01-02T15:04:05Z", - "Editable": true, - "EditedAt": "2006-01-02T15:04:05Z", - "Sequence": 10 - }, - "matchingRules": { - "$.body.Config": { - "match": "type" - }, - "$.body.CreatedAt": { - "match": "type" - }, - "$.body.EditedAt": { - "match": "type" - }, - "$.body.Sequence": { - "match": "type" - } - } - } - }, - { - "description": "A request to get an existing release for cli-create-release-app-id", - "providerState": "Get a release for cli-create-release-app-id", - "request": { - "method": "GET", - "path": "/v1/app/cli-create-release-app-id/2/properties", - "headers": { - "Authorization": "cli-create-release-auth" - } - }, - "response": { - "status": 200, - "headers": { - }, - "body": { - "Config": "there might be a config here", - "CreatedAt": "2006-01-02T15:04:05Z", - "Editable": true, - "EditedAt": "2006-01-02T15:04:05Z", - "Sequence": 2 - }, - "matchingRules": { - "$.body.Config": { - "match": "type" - }, - "$.body.CreatedAt": { - "match": "type" - }, - "$.body.EditedAt": { - "match": "type" - } - } - } - }, { "description": "A request to add a kots app", "providerState": "Add a kots app", @@ -1881,6 +1768,14 @@ "response": { "status": 200, "headers": { + }, + "body": { + "airgapBuilds": [ + ], + "release": { + "appId": "replicated-cli-promote-release-app", + "sequence": 1 + } } } }, @@ -1933,6 +1828,119 @@ } } } + }, + { + "description": "A request to create a new release for cli-create-release-app-id", + "providerState": "Create a release for cli-create-release-app-id", + "request": { + "method": "POST", + "path": "/v1/app/cli-create-release-app-id/release", + "headers": { + "Authorization": "cli-create-release-auth", + "Content-Type": "application/json" + }, + "body": { + "source": "latest", + "sourcedata": 0 + } + }, + "response": { + "status": 201, + "headers": { + }, + "body": { + "Config": "", + "CreatedAt": "2006-01-02T15:04:05Z", + "Editable": true, + "EditedAt": "2006-01-02T15:04:05Z", + "Sequence": 10 + }, + "matchingRules": { + "$.body.Config": { + "match": "type" + }, + "$.body.CreatedAt": { + "match": "type" + }, + "$.body.EditedAt": { + "match": "type" + }, + "$.body.Sequence": { + "match": "type" + } + } + } + }, + { + "description": "An empty request to create a new release for cli-create-release-app-id", + "providerState": "Empty create a release for cli-create-release-app-id", + "request": { + "method": "POST", + "path": "/v1/app/cli-create-release-app-id/release", + "headers": { + "Authorization": "cli-create-release-auth" + } + }, + "response": { + "status": 201, + "headers": { + }, + "body": { + "Config": "", + "CreatedAt": "2006-01-02T15:04:05Z", + "Editable": true, + "EditedAt": "2006-01-02T15:04:05Z", + "Sequence": 10 + }, + "matchingRules": { + "$.body.Config": { + "match": "type" + }, + "$.body.CreatedAt": { + "match": "type" + }, + "$.body.EditedAt": { + "match": "type" + }, + "$.body.Sequence": { + "match": "type" + } + } + } + }, + { + "description": "A request to get an existing release for cli-create-release-app-id", + "providerState": "Get a release for cli-create-release-app-id", + "request": { + "method": "GET", + "path": "/v1/app/cli-create-release-app-id/2/properties", + "headers": { + "Authorization": "cli-create-release-auth" + } + }, + "response": { + "status": 200, + "headers": { + }, + "body": { + "Config": "there might be a config here", + "CreatedAt": "2006-01-02T15:04:05Z", + "Editable": true, + "EditedAt": "2006-01-02T15:04:05Z", + "Sequence": 2 + }, + "matchingRules": { + "$.body.Config": { + "match": "type" + }, + "$.body.CreatedAt": { + "match": "type" + }, + "$.body.EditedAt": { + "match": "type" + } + } + } } ], "metadata": { diff --git a/pkg/kotsclient/release.go b/pkg/kotsclient/release.go index cb2a8f161..cf482c197 100644 --- a/pkg/kotsclient/release.go +++ b/pkg/kotsclient/release.go @@ -63,6 +63,7 @@ func (c *VendorV3Client) GetRelease(appID string, sequence int64) (*types.AppRel CompatibilityResults: resp.Release.CompatibilityResults, Channels: resp.Release.Channels, IsHelmOnly: resp.Release.IsHelmOnly, + AirgapBuilds: resp.Release.AirgapBuilds, } return &appRelease, nil @@ -174,7 +175,7 @@ func (c *VendorV3Client) ListReleases(appID string) ([]types.ReleaseInfo, error) return allReleases, nil } -func (c *VendorV3Client) PromoteRelease(appID string, sequence int64, label string, notes string, required bool, channelIDs ...string) error { +func (c *VendorV3Client) PromoteRelease(appID string, sequence int64, label string, notes string, required bool, channelIDs ...string) (*types.PromoteReleaseResponse, error) { request := types.KotsPromoteReleaseRequest{ ReleaseNotes: notes, VersionLabel: label, @@ -183,11 +184,26 @@ func (c *VendorV3Client) PromoteRelease(appID string, sequence int64, label stri OmitDetailsInResponse: true, } + resp := types.PromoteReleaseResponse{} path := fmt.Sprintf("/v3/app/%s/release/%v/promote", appID, sequence) - err := c.DoJSON(context.TODO(), "POST", path, http.StatusOK, request, nil) + err := c.DoJSON(context.TODO(), "POST", path, http.StatusOK, request, &resp) if err != nil { - return err + return nil, err } - return nil + return &resp, nil +} + +// GetAirgapBuildStatus polls the dedicated airgap build status endpoint for a +// single channel-release. The server returns "pending" (synthesized) until the +// airgap-builder writes its first row, real status after that, and 404 if the +// channel-release does not exist. +func (c *VendorV3Client) GetAirgapBuildStatus(appID string, channelID string, channelSequence int64) (*types.AirgapBuildSummary, error) { + resp := types.AirgapBuildSummary{} + path := fmt.Sprintf("/v3/app/%s/channel/%s/release/%d/airgap/status", appID, channelID, channelSequence) + err := c.DoJSON(context.TODO(), "GET", path, http.StatusOK, nil, &resp) + if err != nil { + return nil, errors.Wrap(err, "failed to get airgap build status") + } + return &resp, nil } diff --git a/pkg/types/release.go b/pkg/types/release.go index d78365836..03ab39251 100644 --- a/pkg/types/release.go +++ b/pkg/types/release.go @@ -74,6 +74,24 @@ type KotsPromoteReleaseRequest struct { OmitDetailsInResponse bool `json:"omitDetailsInResponse"` } +type AirgapBuildSummary struct { + ChannelID string `json:"channelId"` + ChannelSequence int64 `json:"channelSequence"` + ChannelName string `json:"channelName"` + AirgapBuildStatus string `json:"airgapBuildStatus"` + AirgapBuildError string `json:"airgapBuildError,omitempty"` + // FullAirgapBuild is true when the channel has automatic airgap builds enabled + // (the worker will produce a full bundle). When false, the worker still runs + // metadata generation for the channel-release and that step can fail — so + // callers should still surface this entry's status to vendors. + FullAirgapBuild bool `json:"fullAirgapBuild"` +} + +type PromoteReleaseResponse struct { + Release KotsAppRelease `json:"release"` + AirgapBuilds []AirgapBuildSummary `json:"airgapBuilds"` +} + type KotsAppRelease struct { AppID string `json:"appId"` Sequence int64 `json:"sequence"` @@ -86,6 +104,7 @@ type KotsAppRelease struct { Charts []Chart `json:"charts"` CompatibilityResults []CompatibilityResult `json:"compatibilityResults"` IsHelmOnly bool `json:"isHelmOnly"` + AirgapBuilds []AirgapBuildSummary `json:"airgapBuilds,omitempty"` } type Chart struct { @@ -120,4 +139,5 @@ type AppRelease struct { CompatibilityResults []CompatibilityResult `json:"compatibilityResults,omitempty"` Channels []*Channel `json:"channels,omitempty"` IsHelmOnly bool `json:"isHelmOnly,omitempty"` + AirgapBuilds []AirgapBuildSummary `json:"airgapBuilds,omitempty"` }