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
16 changes: 14 additions & 2 deletions cli/cmd/release_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <channel>, marks this release as required during upgrades.")
cmd.Flags().BoolVar(&r.args.createReleasePromoteEnsureChannel, "ensure-channel", false, "When used with --promote <channel>, will create the channel if it doesn't exist")
cmd.Flags().BoolVar(&r.args.createReleasePromoteWaitForAirgap, "wait-for-airgap", false, "When used with --promote <channel>, 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.")
Expand Down Expand Up @@ -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" {
Expand Down Expand Up @@ -469,22 +474,29 @@ 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,
r.args.createReleasePromoteVersion,
r.args.createReleasePromoteNotes,
r.args.createReleasePromoteRequired,
promoteChanID,
); err != nil {
)
if err != nil {
log.FinishSpinnerWithError()
return errors.Wrap(err, "promote release")
}
log.FinishSpinner()

// 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
Expand Down
105 changes: 102 additions & 3 deletions cli/cmd/release_promote.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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,
}
Comment thread
cursor[bot] marked this conversation as resolved.

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")
Expand Down Expand Up @@ -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
}
}
Comment thread
cursor[bot] marked this conversation as resolved.

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
}
Comment thread
cursor[bot] marked this conversation as resolved.

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))
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

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
}
}
38 changes: 21 additions & 17 deletions cli/cmd/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
18 changes: 10 additions & 8 deletions cli/print/channel_releases.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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,
}
}

Expand Down
33 changes: 33 additions & 0 deletions cli/print/channel_releases_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions cli/print/release.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
7 changes: 4 additions & 3 deletions client/release.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
9 changes: 8 additions & 1 deletion pact/kotsclient/release_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading