Skip to content

Commit 20b4025

Browse files
committed
feat(upgrade): add verification release is deployable
1 parent 0b55f28 commit 20b4025

File tree

5 files changed

+411
-2
lines changed

5 files changed

+411
-2
lines changed

cmd/installer/cli/upgrade.go

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/replicatedhq/embedded-cluster/cmd/installer/goods"
1717
"github.com/replicatedhq/embedded-cluster/cmd/installer/kotscli"
1818
ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1"
19+
"github.com/replicatedhq/embedded-cluster/pkg-new/replicatedapi"
1920
"github.com/replicatedhq/embedded-cluster/pkg/airgap"
2021
"github.com/replicatedhq/embedded-cluster/pkg/helpers"
2122
"github.com/replicatedhq/embedded-cluster/pkg/kubeutils"
@@ -24,6 +25,7 @@ import (
2425
"github.com/replicatedhq/embedded-cluster/pkg/release"
2526
"github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig"
2627
rcutil "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig/util"
28+
"github.com/replicatedhq/embedded-cluster/pkg/validation"
2729
"github.com/replicatedhq/embedded-cluster/pkg/versions"
2830
"github.com/replicatedhq/embedded-cluster/web"
2931
kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1"
@@ -59,6 +61,8 @@ type upgradeConfig struct {
5961
managerPort int
6062
requiresInfraUpgrade bool
6163
kotsadmNamespace string
64+
currentAppVersion *kotscli.AppVersionInfo
65+
replicatedAPIClient replicatedapi.Client
6266
}
6367

6468
// UpgradeCmd returns a cobra command for upgrading the embedded cluster application.
@@ -110,7 +114,7 @@ func UpgradeCmd(ctx context.Context, appSlug, appTitle string) *cobra.Command {
110114
if err := preRunUpgrade(ctx, flags, &upgradeConfig, existingRC, kcli, appSlug); err != nil {
111115
return err
112116
}
113-
if err := verifyAndPromptUpgrade(ctx, flags, upgradeConfig, prompts.New()); err != nil {
117+
if err := verifyAndPromptUpgrade(ctx, flags, upgradeConfig, prompts.New(), kcli, appSlug); err != nil {
114118
return err
115119
}
116120

@@ -267,6 +271,7 @@ func preRunUpgrade(ctx context.Context, flags UpgradeCmdFlags, upgradeConfig *up
267271
}
268272
upgradeConfig.license = updatedLicense
269273
upgradeConfig.licenseBytes = licenseBytes
274+
upgradeConfig.replicatedAPIClient = replicatedAPI
270275
}
271276

272277
// Continue using "kotsadm" namespace if it exists for backwards compatibility, otherwise use the appSlug
@@ -337,10 +342,17 @@ func preRunUpgrade(ctx context.Context, flags UpgradeCmdFlags, upgradeConfig *up
337342
}
338343
upgradeConfig.requiresInfraUpgrade = requiresInfraUpgrade
339344

345+
// Get current app version for deployability validation
346+
currentAppVersion, err := kotscli.GetCurrentAppVersion(appSlug, upgradeConfig.kotsadmNamespace)
347+
if err != nil {
348+
return fmt.Errorf("failed to get current app version: %w", err)
349+
}
350+
upgradeConfig.currentAppVersion = currentAppVersion
351+
340352
return nil
341353
}
342354

343-
func verifyAndPromptUpgrade(ctx context.Context, flags UpgradeCmdFlags, upgradeConfig upgradeConfig, prompt prompts.Prompt) error {
355+
func verifyAndPromptUpgrade(ctx context.Context, flags UpgradeCmdFlags, upgradeConfig upgradeConfig, prompt prompts.Prompt, kcli client.Client, appSlug string) error {
344356
isAirgap := flags.airgapBundle != ""
345357

346358
err := verifyChannelRelease("upgrade", isAirgap, flags.assumeYes)
@@ -355,6 +367,11 @@ func verifyAndPromptUpgrade(ctx context.Context, flags UpgradeCmdFlags, upgradeC
355367
}
356368
}
357369

370+
// Validate release deployability
371+
if err := validateReleaseDeployability(ctx, upgradeConfig, kcli, appSlug, isAirgap); err != nil {
372+
return fmt.Errorf("upgrade validation failed: %w", err)
373+
}
374+
358375
if !isAirgap {
359376
if err := maybePromptForAppUpdate(ctx, prompt, upgradeConfig.license, flags.assumeYes); err != nil {
360377
if errors.As(err, &ErrorNothingElseToAdd{}) {
@@ -543,3 +560,64 @@ func checkRequiresInfraUpgrade(ctx context.Context) (bool, error) {
543560

544561
return !bytes.Equal(currentJSON, targetJSON), nil
545562
}
563+
564+
// validateReleaseDeployability validates that the target release can be safely deployed
565+
func validateReleaseDeployability(ctx context.Context, upgradeConfig upgradeConfig, kcli client.Client, appSlug string, isAirgap bool) error {
566+
// Get current installation for version information
567+
currentInstallation, err := kubeutils.GetLatestInstallation(ctx, kcli)
568+
if err != nil {
569+
return fmt.Errorf("get current installation: %w", err)
570+
}
571+
572+
// Get target release data
573+
releaseData := release.GetReleaseData()
574+
if releaseData == nil {
575+
return fmt.Errorf("release data not found")
576+
}
577+
578+
// Get channel release info
579+
channelRelease := releaseData.ChannelRelease
580+
if channelRelease == nil {
581+
return fmt.Errorf("channel release not found in release data")
582+
}
583+
584+
// Get current and target EC/K8s versions
585+
var currentECVersion string
586+
if currentInstallation.Spec.Config != nil {
587+
currentECVersion = currentInstallation.Spec.Config.Version
588+
}
589+
590+
targetECVersion := versions.Version
591+
592+
// Build validation options
593+
opts := validation.UpgradableOptions{
594+
IsAirgap: isAirgap,
595+
CurrentECVersion: currentECVersion,
596+
TargetECVersion: targetECVersion,
597+
License: upgradeConfig.license,
598+
AirgapMetadata: upgradeConfig.airgapMetadata,
599+
}
600+
601+
// Add current app version info if available
602+
if upgradeConfig.currentAppVersion != nil {
603+
opts.CurrentAppVersion = upgradeConfig.currentAppVersion.VersionLabel
604+
opts.CurrentAppSequence = upgradeConfig.currentAppVersion.ChannelSequence
605+
}
606+
607+
// Add target app version info
608+
opts.TargetAppVersion = channelRelease.VersionLabel
609+
opts.TargetAppSequence = channelRelease.ChannelSequence
610+
611+
// For online upgrades, add the replicated API client and channel ID
612+
if !isAirgap && upgradeConfig.replicatedAPIClient != nil {
613+
opts.ReplicatedAPI = upgradeConfig.replicatedAPIClient
614+
opts.ChannelID = channelRelease.ChannelID
615+
}
616+
617+
// Perform validation
618+
if err := validation.ValidateIsReleaseUpgradable(ctx, opts); err != nil {
619+
return err
620+
}
621+
622+
return nil
623+
}

cmd/installer/kotscli/kotscli.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package kotscli
33
import (
44
"bytes"
55
"context"
6+
"encoding/json"
67
"fmt"
78
"io"
89
"os"
@@ -437,6 +438,56 @@ func createLicenseFile(license []byte) (string, error) {
437438
return licenseFile.Name(), nil
438439
}
439440

441+
// AppVersionInfo holds information about a deployed app version
442+
type AppVersionInfo struct {
443+
VersionLabel string `json:"versionLabel"`
444+
ChannelSequence int64 `json:"channelSequence"`
445+
Sequence int64 `json:"sequence"`
446+
Status string `json:"status"`
447+
}
448+
449+
// GetCurrentAppVersion retrieves the currently deployed app version and sequence
450+
func GetCurrentAppVersion(appSlug string, namespace string) (*AppVersionInfo, error) {
451+
kotsBinPath, err := goods.InternalBinary("kubectl-kots")
452+
if err != nil {
453+
return nil, fmt.Errorf("materialize kubectl-kots binary: %w", err)
454+
}
455+
defer os.Remove(kotsBinPath)
456+
457+
// Build command arguments: kots get versions <appSlug> -n <namespace> -o json
458+
args := []string{
459+
"get", "versions",
460+
appSlug,
461+
"-n", namespace,
462+
"-o", "json",
463+
}
464+
465+
// Execute the command and capture output
466+
var outputBuffer bytes.Buffer
467+
runCommandOpts := helpers.RunCommandOptions{
468+
Stdout: &outputBuffer,
469+
}
470+
471+
if err := helpers.RunCommandWithOptions(runCommandOpts, kotsBinPath, args...); err != nil {
472+
return nil, fmt.Errorf("get versions from kots: %w", err)
473+
}
474+
475+
// Parse JSON output
476+
var versions []AppVersionInfo
477+
if err := json.Unmarshal(outputBuffer.Bytes(), &versions); err != nil {
478+
return nil, fmt.Errorf("unmarshal versions output: %w", err)
479+
}
480+
481+
// Find the deployed version
482+
for _, v := range versions {
483+
if v.Status == "deployed" {
484+
return &v, nil
485+
}
486+
}
487+
488+
return nil, fmt.Errorf("no deployed version found for app %s", appSlug)
489+
}
490+
440491
// GetConfigValuesOptions holds options for getting config values
441492
type GetConfigValuesOptions struct {
442493
AppSlug string

pkg-new/replicatedapi/client.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,13 @@ import (
1515
kyaml "sigs.k8s.io/yaml"
1616
)
1717

18+
var _ Client = (*client)(nil)
19+
1820
var defaultHTTPClient = newRetryableHTTPClient()
1921

2022
type Client interface {
2123
SyncLicense(ctx context.Context, reportingInfo *ReportingInfo) (*kotsv1beta1.License, []byte, error)
24+
GetPendingReleases(ctx context.Context, channelID string, currentSequence int64, opts *PendingReleasesOptions) (*PendingReleasesResponse, error)
2225
}
2326

2427
type client struct {
@@ -156,3 +159,47 @@ func basicAuth(username, password string) string {
156159
auth := username + ":" + password
157160
return base64.StdEncoding.EncodeToString([]byte(auth))
158161
}
162+
163+
// GetPendingReleases fetches pending releases from the Replicated API
164+
func (c *client) GetPendingReleases(ctx context.Context, channelID string, currentSequence int64, opts *PendingReleasesOptions) (*PendingReleasesResponse, error) {
165+
u := fmt.Sprintf("%s/release/%s/pending", c.replicatedAppURL, c.license.Spec.AppSlug)
166+
167+
params := url.Values{}
168+
params.Set("selectedChannelId", channelID)
169+
params.Set("currentSequence", fmt.Sprintf("%d", currentSequence))
170+
params.Set("isSemverSupported", fmt.Sprintf("%t", opts.IsSemverSupported))
171+
if opts.SortOrder != "" {
172+
params.Set("sortOrder", string(opts.SortOrder))
173+
}
174+
u = fmt.Sprintf("%s?%s", u, params.Encode())
175+
176+
req, err := c.newRetryableRequest(ctx, http.MethodGet, u, nil, nil)
177+
if err != nil {
178+
return nil, fmt.Errorf("create request: %w", err)
179+
}
180+
181+
req.Header.Set("Accept", "application/json")
182+
183+
resp, err := c.httpClient.Do(req)
184+
if err != nil {
185+
return nil, fmt.Errorf("execute request: %w", err)
186+
}
187+
defer resp.Body.Close()
188+
189+
if resp.StatusCode != http.StatusOK {
190+
body, _ := io.ReadAll(resp.Body)
191+
return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body))
192+
}
193+
194+
body, err := io.ReadAll(resp.Body)
195+
if err != nil {
196+
return nil, fmt.Errorf("read response body: %w", err)
197+
}
198+
199+
var pendingReleases PendingReleasesResponse
200+
if err := kyaml.Unmarshal(body, &pendingReleases); err != nil {
201+
return nil, fmt.Errorf("unmarshal pending releases response: %w", err)
202+
}
203+
204+
return &pendingReleases, nil
205+
}

pkg-new/replicatedapi/types.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package replicatedapi
2+
3+
import "time"
4+
5+
// PendingReleasesResponse represents the response from the /release/{appSlug}/pending API endpoint
6+
type PendingReleasesResponse struct {
7+
ChannelReleases []ChannelRelease `json:"channelReleases"`
8+
}
9+
10+
// SortOrder represents the order in which to sort releases
11+
type SortOrder string
12+
13+
const SortOrderAscending SortOrder = "asc"
14+
const SortOrderDescending SortOrder = "desc"
15+
16+
// PendingReleasesOptions represents options for fetching pending releases
17+
type PendingReleasesOptions struct {
18+
IsSemverSupported bool
19+
SortOrder SortOrder
20+
}
21+
22+
// ChannelRelease represents a single release in a channel
23+
type ChannelRelease struct {
24+
ChannelID string `json:"channelId"`
25+
ChannelSequence int64 `json:"channelSequence"`
26+
ReleaseSequence int64 `json:"releaseSequence"`
27+
VersionLabel string `json:"versionLabel"`
28+
IsRequired bool `json:"isRequired"`
29+
CreatedAt time.Time `json:"createdAt"`
30+
}

0 commit comments

Comments
 (0)