diff --git a/cmd/installer/cli/upgrade.go b/cmd/installer/cli/upgrade.go index 23afe25b35..5abae9fead 100644 --- a/cmd/installer/cli/upgrade.go +++ b/cmd/installer/cli/upgrade.go @@ -16,6 +16,8 @@ import ( "github.com/replicatedhq/embedded-cluster/cmd/installer/goods" "github.com/replicatedhq/embedded-cluster/cmd/installer/kotscli" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg-new/replicatedapi" + "github.com/replicatedhq/embedded-cluster/pkg-new/validation" "github.com/replicatedhq/embedded-cluster/pkg/airgap" "github.com/replicatedhq/embedded-cluster/pkg/helpers" "github.com/replicatedhq/embedded-cluster/pkg/kubeutils" @@ -59,6 +61,8 @@ type upgradeConfig struct { managerPort int requiresInfraUpgrade bool kotsadmNamespace string + currentAppVersion *kotscli.AppVersionInfo + replicatedAPIClient replicatedapi.Client } // 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 { if err := preRunUpgrade(ctx, flags, &upgradeConfig, existingRC, kcli, appSlug); err != nil { return err } - if err := verifyAndPromptUpgrade(ctx, flags, upgradeConfig, prompts.New()); err != nil { + if err := verifyAndPromptUpgrade(ctx, flags, upgradeConfig, prompts.New(), kcli); err != nil { return err } @@ -254,8 +258,8 @@ func preRunUpgrade(ctx context.Context, flags UpgradeCmdFlags, upgradeConfig *up } upgradeConfig.license = l - // sync the license if a license is provided and we are not in airgap mode - if upgradeConfig.license != nil && flags.airgapBundle == "" { + // sync the license and initialize the replicated api client if we are not in airgap mode + if flags.airgapBundle == "" { replicatedAPI, err := newReplicatedAPIClient(upgradeConfig.license, upgradeConfig.clusterID) if err != nil { return fmt.Errorf("failed to create replicated API client: %w", err) @@ -267,6 +271,7 @@ func preRunUpgrade(ctx context.Context, flags UpgradeCmdFlags, upgradeConfig *up } upgradeConfig.license = updatedLicense upgradeConfig.licenseBytes = licenseBytes + upgradeConfig.replicatedAPIClient = replicatedAPI } // 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 } upgradeConfig.requiresInfraUpgrade = requiresInfraUpgrade + // Get current app version for deployability validation + currentAppVersion, err := kotscli.GetCurrentAppVersion(appSlug, upgradeConfig.kotsadmNamespace) + if err != nil { + return fmt.Errorf("failed to get current app version: %w", err) + } + upgradeConfig.currentAppVersion = currentAppVersion + return nil } -func verifyAndPromptUpgrade(ctx context.Context, flags UpgradeCmdFlags, upgradeConfig upgradeConfig, prompt prompts.Prompt) error { +func verifyAndPromptUpgrade(ctx context.Context, flags UpgradeCmdFlags, upgradeConfig upgradeConfig, prompt prompts.Prompt, kcli client.Client) error { isAirgap := flags.airgapBundle != "" err := verifyChannelRelease("upgrade", isAirgap, flags.assumeYes) @@ -355,6 +367,16 @@ func verifyAndPromptUpgrade(ctx context.Context, flags UpgradeCmdFlags, upgradeC } } + // Validate release upgradable + if err := validateIsReleaseUpgradable(ctx, upgradeConfig, kcli, isAirgap); err != nil { + var ve *validation.ValidationError + if errors.As(err, &ve) { + // This is a validation error that prevents the upgrade from proceeding, expose the error directly + return ve + } + return fmt.Errorf("upgrade validation execution failed: %w", err) + } + if !isAirgap { if err := maybePromptForAppUpdate(ctx, prompt, upgradeConfig.license, flags.assumeYes); err != nil { if errors.As(err, &ErrorNothingElseToAdd{}) { @@ -554,3 +576,67 @@ func checkRequiresInfraUpgrade(ctx context.Context) (bool, error) { return !bytes.Equal(currentJSON, targetJSON), nil } + +// validateIsReleaseUpgradable validates that the target release can be safely deployed +func validateIsReleaseUpgradable(ctx context.Context, upgradeConfig upgradeConfig, kcli client.Client, isAirgap bool) error { + // Get current installation for version information + currentInstallation, err := kubeutils.GetLatestInstallation(ctx, kcli) + if err != nil { + return fmt.Errorf("get current installation: %w", err) + } + + // Get target release data + releaseData := release.GetReleaseData() + if releaseData == nil { + return fmt.Errorf("release data not found") + } + + // Get channel release info + channelRelease := releaseData.ChannelRelease + if channelRelease == nil { + return fmt.Errorf("channel release not found in release data") + } + + // Get current and target EC/K8s versions + var currentECVersion string + if currentInstallation.Spec.Config != nil { + currentECVersion = currentInstallation.Spec.Config.Version + } + + targetECVersion := versions.Version + + // Build validation options + opts := validation.UpgradableOptions{ + CurrentECVersion: currentECVersion, + TargetECVersion: targetECVersion, + License: upgradeConfig.license, + } + + // Add current app version info if available + if upgradeConfig.currentAppVersion != nil { + opts.CurrentAppVersion = upgradeConfig.currentAppVersion.VersionLabel + opts.CurrentAppSequence = upgradeConfig.currentAppVersion.ChannelSequence + } + + // Add target app version info + opts.TargetAppVersion = channelRelease.VersionLabel + opts.TargetAppSequence = channelRelease.ChannelSequence + + // Extract the required releases depending on if it's airgap or online + if isAirgap { + if err := opts.WithAirgapRequiredReleases(upgradeConfig.airgapMetadata); err != nil { + return fmt.Errorf("failed to extract required releases from airgap metadata: %w", err) + } + } else { + if err := opts.WithOnlineRequiredReleases(ctx, upgradeConfig.replicatedAPIClient); err != nil { + return fmt.Errorf("failed to extract required releases from replicated API's pending release call: %w", err) + } + } + + // Perform validation + if err := validation.ValidateIsReleaseUpgradable(ctx, opts); err != nil { + return err + } + + return nil +} diff --git a/cmd/installer/kotscli/kotscli.go b/cmd/installer/kotscli/kotscli.go index 057ef81b62..3887099e70 100644 --- a/cmd/installer/kotscli/kotscli.go +++ b/cmd/installer/kotscli/kotscli.go @@ -3,6 +3,7 @@ package kotscli import ( "bytes" "context" + "encoding/json" "fmt" "io" "os" @@ -437,6 +438,65 @@ func createLicenseFile(license []byte) (string, error) { return licenseFile.Name(), nil } +// AppVersionInfo holds information about a deployed app version +type AppVersionInfo struct { + VersionLabel string `json:"versionLabel"` + ChannelSequence int64 `json:"channelSequence"` + Sequence int64 `json:"sequence"` + Status string `json:"status"` +} + +// GetCurrentAppVersion retrieves the currently deployed app version and sequence +func GetCurrentAppVersion(appSlug string, namespace string) (*AppVersionInfo, error) { + kotsBinPath, err := goods.InternalBinary("kubectl-kots") + if err != nil { + return nil, fmt.Errorf("materialize kubectl-kots binary: %w", err) + } + defer os.Remove(kotsBinPath) + + // Build command arguments: kots get versions -n -o json + args := []string{ + "get", "versions", + appSlug, + "-n", namespace, + "-o", "json", + } + + // Execute the command and capture output + var outputBuffer bytes.Buffer + runCommandOpts := helpers.RunCommandOptions{ + Stdout: &outputBuffer, + } + + if err := helpers.RunCommandWithOptions(runCommandOpts, kotsBinPath, args...); err != nil { + return nil, fmt.Errorf("get versions from kots: %w", err) + } + + // Parse JSON output + var versions []AppVersionInfo + if err := json.Unmarshal(outputBuffer.Bytes(), &versions); err != nil { + return nil, fmt.Errorf("unmarshal versions output: %w", err) + } + + version, err := getLastDeployedAppVersion(versions) + if err != nil { + return nil, fmt.Errorf("no deployed version found for app %s", appSlug) + } + return version, nil +} + +// getLastDeployedAppVersion finds the last deployed version from a slice of versions +func getLastDeployedAppVersion(versions []AppVersionInfo) (*AppVersionInfo, error) { + // Find the last deployed version. This can be either successful or failed deploys. + for _, v := range versions { + if v.Status == "deployed" || v.Status == "failed" { + return &v, nil + } + } + + return nil, fmt.Errorf("no deployed version found") +} + // GetConfigValuesOptions holds options for getting config values type GetConfigValuesOptions struct { AppSlug string diff --git a/cmd/installer/kotscli/kotscli_test.go b/cmd/installer/kotscli/kotscli_test.go new file mode 100644 index 0000000000..937300f493 --- /dev/null +++ b/cmd/installer/kotscli/kotscli_test.go @@ -0,0 +1,193 @@ +package kotscli + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_getLastDeployedAppVersion(t *testing.T) { + tests := []struct { + name string + versions []AppVersionInfo + want *AppVersionInfo + wantErr bool + }{ + { + name: "should return first deployed version", + versions: []AppVersionInfo{ + { + VersionLabel: "1.0.0", + ChannelSequence: 1, + Sequence: 1, + Status: "deployed", + }, + { + VersionLabel: "0.9.0", + ChannelSequence: 2, + Sequence: 2, + Status: "pending", + }, + }, + want: &AppVersionInfo{ + VersionLabel: "1.0.0", + ChannelSequence: 1, + Sequence: 1, + Status: "deployed", + }, + wantErr: false, + }, + { + name: "should return failed deployment", + versions: []AppVersionInfo{ + { + VersionLabel: "1.0.0", + ChannelSequence: 1, + Sequence: 1, + Status: "failed", + }, + { + VersionLabel: "0.9.0", + ChannelSequence: 2, + Sequence: 2, + Status: "pending", + }, + }, + want: &AppVersionInfo{ + VersionLabel: "1.0.0", + ChannelSequence: 1, + Sequence: 1, + Status: "failed", + }, + wantErr: false, + }, + { + name: "should return deployed before failed when both exist", + versions: []AppVersionInfo{ + { + VersionLabel: "2.0.0", + ChannelSequence: 3, + Sequence: 3, + Status: "deployed", + }, + { + VersionLabel: "1.5.0", + ChannelSequence: 2, + Sequence: 2, + Status: "failed", + }, + }, + want: &AppVersionInfo{ + VersionLabel: "2.0.0", + ChannelSequence: 3, + Sequence: 3, + Status: "deployed", + }, + wantErr: false, + }, + { + name: "should return failed when it comes before deployed", + versions: []AppVersionInfo{ + { + VersionLabel: "2.0.0", + ChannelSequence: 3, + Sequence: 3, + Status: "failed", + }, + { + VersionLabel: "1.5.0", + ChannelSequence: 2, + Sequence: 2, + Status: "deployed", + }, + }, + want: &AppVersionInfo{ + VersionLabel: "2.0.0", + ChannelSequence: 3, + Sequence: 3, + Status: "failed", + }, + wantErr: false, + }, + { + name: "should return error when no deployed or failed versions exist", + versions: []AppVersionInfo{ + { + VersionLabel: "1.0.0", + ChannelSequence: 1, + Sequence: 1, + Status: "pending", + }, + { + VersionLabel: "0.9.0", + ChannelSequence: 2, + Sequence: 2, + Status: "pending_download", + }, + }, + want: nil, + wantErr: true, + }, + { + name: "should return error when versions slice is empty", + versions: []AppVersionInfo{}, + want: nil, + wantErr: true, + }, + { + name: "should return error when versions slice is nil", + versions: nil, + want: nil, + wantErr: true, + }, + { + name: "should handle versions with other statuses", + versions: []AppVersionInfo{ + { + VersionLabel: "3.0.0", + ChannelSequence: 5, + Sequence: 5, + Status: "pending_config", + }, + { + VersionLabel: "2.5.0", + ChannelSequence: 4, + Sequence: 4, + Status: "pending_download", + }, + { + VersionLabel: "2.0.0", + ChannelSequence: 3, + Sequence: 3, + Status: "deployed", + }, + { + VersionLabel: "1.0.0", + ChannelSequence: 1, + Sequence: 1, + Status: "unknown", + }, + }, + want: &AppVersionInfo{ + VersionLabel: "2.0.0", + ChannelSequence: 3, + Sequence: 3, + Status: "deployed", + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := getLastDeployedAppVersion(tt.versions) + if tt.wantErr { + require.Error(t, err) + assert.Nil(t, got) + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + }) + } +} diff --git a/pkg-new/replicatedapi/client.go b/pkg-new/replicatedapi/client.go index 758d068b5d..551af9b56e 100644 --- a/pkg-new/replicatedapi/client.go +++ b/pkg-new/replicatedapi/client.go @@ -15,6 +15,8 @@ import ( kyaml "sigs.k8s.io/yaml" ) +var _ Client = (*client)(nil) + var defaultHTTPClient = newRetryableHTTPClient() // ClientFactory is a function type for creating replicatedapi clients @@ -29,6 +31,7 @@ func SetClientFactory(factory ClientFactory) { type Client interface { SyncLicense(ctx context.Context) (*kotsv1beta1.License, []byte, error) + GetPendingReleases(ctx context.Context, channelID string, currentSequence int64, opts *PendingReleasesOptions) (*PendingReleasesResponse, error) } type client struct { @@ -172,3 +175,47 @@ func basicAuth(username, password string) string { auth := username + ":" + password return base64.StdEncoding.EncodeToString([]byte(auth)) } + +// GetPendingReleases fetches pending releases from the Replicated API +func (c *client) GetPendingReleases(ctx context.Context, channelID string, currentSequence int64, opts *PendingReleasesOptions) (*PendingReleasesResponse, error) { + u := fmt.Sprintf("%s/release/%s/pending", c.replicatedAppURL, c.license.Spec.AppSlug) + + params := url.Values{} + params.Set("selectedChannelId", channelID) + params.Set("channelSequence", fmt.Sprintf("%d", currentSequence)) + params.Set("isSemverSupported", fmt.Sprintf("%t", opts.IsSemverSupported)) + if opts.SortOrder != "" { + params.Set("sortOrder", string(opts.SortOrder)) + } + u = fmt.Sprintf("%s?%s", u, params.Encode()) + + req, err := c.newRetryableRequest(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body)) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response body: %w", err) + } + + var pendingReleases PendingReleasesResponse + if err := kyaml.Unmarshal(body, &pendingReleases); err != nil { + return nil, fmt.Errorf("unmarshal pending releases response: %w", err) + } + + return &pendingReleases, nil +} diff --git a/pkg-new/replicatedapi/client_test.go b/pkg-new/replicatedapi/client_test.go index 3b8e97a8df..ce252ae08e 100644 --- a/pkg-new/replicatedapi/client_test.go +++ b/pkg-new/replicatedapi/client_test.go @@ -389,3 +389,355 @@ func TestInjectHeaders(t *testing.T) { req.Equal(versions.Version, header.Get("X-Replicated-EmbeddedClusterVersion")) req.Equal("false", header.Get("X-Replicated-IsKurl")) } + +func TestGetPendingReleases(t *testing.T) { + tests := []struct { + name string + channelID string + channelSequence int64 + opts *PendingReleasesOptions + serverHandler func(t *testing.T) http.HandlerFunc + expectedResponse *PendingReleasesResponse + wantErr string + }{ + { + name: "successful pending releases fetch with multiple releases", + channelID: "test-channel-123", + channelSequence: 10, + opts: &PendingReleasesOptions{ + IsSemverSupported: true, + SortOrder: SortOrderAscending, + }, + serverHandler: func(t *testing.T) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Validate request + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/release/test-app/pending", r.URL.Path) + assert.Equal(t, "test-channel-123", r.URL.Query().Get("selectedChannelId")) + assert.Equal(t, "10", r.URL.Query().Get("channelSequence")) + assert.Equal(t, "true", r.URL.Query().Get("isSemverSupported")) + assert.Equal(t, "asc", r.URL.Query().Get("sortOrder")) + assert.Equal(t, "application/json", r.Header.Get("Accept")) + + // Validate auth header + authHeader := r.Header.Get("Authorization") + assert.NotEmpty(t, authHeader) + assert.Contains(t, authHeader, "Basic ") + + // Return response as JSON + resp := PendingReleasesResponse{ + ChannelReleases: []ChannelRelease{ + { + ChannelID: "test-channel-123", + ChannelSequence: 11, + ReleaseSequence: 101, + VersionLabel: "1.0.1", + IsRequired: false, + }, + { + ChannelID: "test-channel-123", + ChannelSequence: 12, + ReleaseSequence: 102, + VersionLabel: "1.0.2", + IsRequired: true, + }, + { + ChannelID: "test-channel-123", + ChannelSequence: 13, + ReleaseSequence: 103, + VersionLabel: "1.0.3", + IsRequired: false, + }, + }, + } + + w.WriteHeader(http.StatusOK) + yaml.NewEncoder(w).Encode(resp) + } + }, + expectedResponse: &PendingReleasesResponse{ + ChannelReleases: []ChannelRelease{ + { + ChannelID: "test-channel-123", + ChannelSequence: 11, + ReleaseSequence: 101, + VersionLabel: "1.0.1", + IsRequired: false, + }, + { + ChannelID: "test-channel-123", + ChannelSequence: 12, + ReleaseSequence: 102, + VersionLabel: "1.0.2", + IsRequired: true, + }, + { + ChannelID: "test-channel-123", + ChannelSequence: 13, + ReleaseSequence: 103, + VersionLabel: "1.0.3", + IsRequired: false, + }, + }, + }, + }, + { + name: "successful pending releases fetch with empty results", + channelID: "test-channel-123", + channelSequence: 10, + opts: &PendingReleasesOptions{ + IsSemverSupported: false, + SortOrder: SortOrderAscending, + }, + serverHandler: func(t *testing.T) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + resp := PendingReleasesResponse{ + ChannelReleases: []ChannelRelease{}, + } + w.WriteHeader(http.StatusOK) + yaml.NewEncoder(w).Encode(resp) + } + }, + expectedResponse: &PendingReleasesResponse{ + ChannelReleases: []ChannelRelease{}, + }, + }, + { + name: "successful pending releases with ascending sort order", + channelID: "test-channel-123", + channelSequence: 5, + opts: &PendingReleasesOptions{ + IsSemverSupported: true, + SortOrder: SortOrderAscending, + }, + serverHandler: func(t *testing.T) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "asc", r.URL.Query().Get("sortOrder")) + resp := PendingReleasesResponse{ + ChannelReleases: []ChannelRelease{ + { + ChannelSequence: 6, + VersionLabel: "1.0.0", + }, + }, + } + w.WriteHeader(http.StatusOK) + yaml.NewEncoder(w).Encode(resp) + } + }, + expectedResponse: &PendingReleasesResponse{ + ChannelReleases: []ChannelRelease{ + { + ChannelSequence: 6, + VersionLabel: "1.0.0", + }, + }, + }, + }, + { + name: "successful pending releases with descending sort order", + channelID: "test-channel-123", + channelSequence: 5, + opts: &PendingReleasesOptions{ + IsSemverSupported: false, + SortOrder: SortOrderDescending, + }, + serverHandler: func(t *testing.T) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "desc", r.URL.Query().Get("sortOrder")) + resp := PendingReleasesResponse{ + ChannelReleases: []ChannelRelease{ + { + ChannelSequence: 10, + VersionLabel: "2.0.0", + }, + }, + } + w.WriteHeader(http.StatusOK) + yaml.NewEncoder(w).Encode(resp) + } + }, + expectedResponse: &PendingReleasesResponse{ + ChannelReleases: []ChannelRelease{ + { + ChannelSequence: 10, + VersionLabel: "2.0.0", + }, + }, + }, + }, + { + name: "returns error on 401 unauthorized", + channelID: "test-channel-123", + channelSequence: 10, + opts: &PendingReleasesOptions{ + IsSemverSupported: true, + SortOrder: SortOrderAscending, + }, + serverHandler: func(t *testing.T) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("unauthorized")) + } + }, + wantErr: "unexpected status code 401", + }, + { + name: "returns error on 404 not found", + channelID: "nonexistent-channel", + channelSequence: 10, + opts: &PendingReleasesOptions{ + IsSemverSupported: true, + SortOrder: SortOrderAscending, + }, + serverHandler: func(t *testing.T) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("channel not found")) + } + }, + wantErr: "unexpected status code 404", + }, + { + name: "returns error on 500 internal server error", + channelID: "test-channel-123", + channelSequence: 10, + opts: &PendingReleasesOptions{ + IsSemverSupported: true, + SortOrder: SortOrderAscending, + }, + serverHandler: func(t *testing.T) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("internal server error")) + } + }, + wantErr: "unexpected status code 500", + }, + { + name: "returns error on invalid JSON response", + channelID: "test-channel-123", + channelSequence: 10, + opts: &PendingReleasesOptions{ + IsSemverSupported: true, + SortOrder: SortOrderAscending, + }, + serverHandler: func(t *testing.T) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("invalid json")) + } + }, + wantErr: "unmarshal pending releases response", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := require.New(t) + + license := kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + AppSlug: "test-app", + LicenseID: "test-license-id", + LicenseSequence: 1, + ChannelID: "test-channel-123", + Channels: []kotsv1beta1.Channel{ + { + ChannelID: "test-channel-123", + ChannelName: "Stable", + }, + }, + }, + } + + releaseData := &release.ReleaseData{ + ChannelRelease: &release.ChannelRelease{ + ChannelID: "test-channel-123", + }, + } + + // Create test server + server := httptest.NewServer(tt.serverHandler(t)) + defer server.Close() + + // Create client + c, err := NewClient(server.URL, &license, releaseData) + req.NoError(err) + + // Execute test + result, err := c.GetPendingReleases(context.Background(), tt.channelID, tt.channelSequence, tt.opts) + + // Validate results + if tt.wantErr != "" { + req.Error(err) + req.Contains(err.Error(), tt.wantErr) + req.Nil(result) + } else { + req.NoError(err) + req.NotNil(result) + req.Equal(len(tt.expectedResponse.ChannelReleases), len(result.ChannelReleases)) + + for i, expectedRelease := range tt.expectedResponse.ChannelReleases { + assert.Equal(t, expectedRelease.ChannelID, result.ChannelReleases[i].ChannelID) + assert.Equal(t, expectedRelease.ChannelSequence, result.ChannelReleases[i].ChannelSequence) + assert.Equal(t, expectedRelease.ReleaseSequence, result.ChannelReleases[i].ReleaseSequence) + assert.Equal(t, expectedRelease.VersionLabel, result.ChannelReleases[i].VersionLabel) + assert.Equal(t, expectedRelease.IsRequired, result.ChannelReleases[i].IsRequired) + } + } + }) + } +} + +func TestGetPendingReleases_ContextCancellation(t *testing.T) { + req := require.New(t) + + // Create a server that delays response + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Simulate slow response + <-r.Context().Done() + })) + defer server.Close() + + license := kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + AppSlug: "test-app", + LicenseID: "test-license-id", + LicenseSequence: 1, + ChannelID: "test-channel-123", + Channels: []kotsv1beta1.Channel{ + { + ChannelID: "test-channel-123", + ChannelName: "Stable", + }, + }, + }, + } + + releaseData := &release.ReleaseData{ + ChannelRelease: &release.ChannelRelease{ + ChannelID: "test-channel-123", + }, + } + + // Create client + c, err := NewClient(server.URL, &license, releaseData) + req.NoError(err) + + // Create a context that is already cancelled + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + opts := &PendingReleasesOptions{ + IsSemverSupported: true, + SortOrder: SortOrderAscending, + } + + // Execute test + result, err := c.GetPendingReleases(ctx, "test-channel-123", 10, opts) + + // Should return error due to cancelled context + req.Error(err) + req.Nil(result) +} diff --git a/pkg-new/replicatedapi/types.go b/pkg-new/replicatedapi/types.go new file mode 100644 index 0000000000..38373ade19 --- /dev/null +++ b/pkg-new/replicatedapi/types.go @@ -0,0 +1,30 @@ +package replicatedapi + +import "time" + +// PendingReleasesResponse represents the response from the /release/{appSlug}/pending API endpoint +type PendingReleasesResponse struct { + ChannelReleases []ChannelRelease `json:"channelReleases"` +} + +// SortOrder represents the order in which to sort releases +type SortOrder string + +const SortOrderAscending SortOrder = "asc" +const SortOrderDescending SortOrder = "desc" + +// PendingReleasesOptions represents options for fetching pending releases +type PendingReleasesOptions struct { + IsSemverSupported bool + SortOrder SortOrder +} + +// ChannelRelease represents a single release in a channel +type ChannelRelease struct { + ChannelID string `json:"channelId"` + ChannelSequence int64 `json:"channelSequence"` + ReleaseSequence int64 `json:"releaseSequence"` + VersionLabel string `json:"versionLabel"` + IsRequired bool `json:"isRequired"` + CreatedAt time.Time `json:"createdAt"` +} diff --git a/pkg-new/validation/errors.go b/pkg-new/validation/errors.go new file mode 100644 index 0000000000..3f219e24c7 --- /dev/null +++ b/pkg-new/validation/errors.go @@ -0,0 +1,61 @@ +package validation + +import ( + "fmt" + "strings" +) + +// ValidationError represents a validation failure that prevents an upgrade. +// These are expected errors that indicate the upgrade cannot proceed due to +// business rules (e.g., version downgrades, required releases). +// Internal/system errors (API failures, parsing errors) should NOT use this type. +type ValidationError struct { + Message string +} + +func (e *ValidationError) Error() string { + return e.Message +} + +// NewRequiredReleasesError creates a ValidationError indicating that intermediate +// required releases must be installed before upgrading to the target version +func NewRequiredReleasesError(requiredVersions []string, targetVersion string) *ValidationError { + return &ValidationError{ + Message: fmt.Sprintf("this upgrade requires installing intermediate version(s) first: %s. Please go through this upgrade path before upgrading to %s", + strings.Join(requiredVersions, ", "), targetVersion), + } +} + +// NewAppVersionDowngradeError creates a ValidationError indicating that the target +// app version is older than the current version +func NewAppVersionDowngradeError(currentVersion, targetVersion string) *ValidationError { + return &ValidationError{ + Message: fmt.Sprintf("downgrade detected: cannot upgrade from app version %s to older version %s", currentVersion, targetVersion), + } +} + +// NewECVersionDowngradeError creates a ValidationError indicating that the target +// Embedded Cluster version is older than the current version +func NewECVersionDowngradeError(currentVersion, targetVersion string) *ValidationError { + return &ValidationError{ + Message: fmt.Sprintf("downgrade detected: cannot upgrade from Embedded Cluster version %s to older version %s", currentVersion, targetVersion), + } +} + +// NewK8sVersionSkipError creates a ValidationError indicating that the Kubernetes +// version upgrade skips a minor version, which is not supported by Kubernetes +func NewK8sVersionSkipError(currentVersion, targetVersion string) *ValidationError { + return &ValidationError{ + Message: fmt.Sprintf("Kubernetes version skip detected: cannot upgrade from k8s %s to %s. Kubernetes only supports upgrading by one minor version at a time", + currentVersion, targetVersion), + } +} + +// NewK8sVersionDowngrade creates a ValidationError indicating that the Kubernetes +// version upgrade downgrades the kubernetes version used, which is not supported +func NewK8sVersionDowngrade(currentVersion, targetVersion string) *ValidationError { + return &ValidationError{ + Message: fmt.Sprintf("Kubernetes version downgrade detected: cannot downgrade from k8s %s to %s. Kubernetes downgrades are not supported", + currentVersion, targetVersion), + } +} diff --git a/pkg-new/validation/upgradable.go b/pkg-new/validation/upgradable.go new file mode 100644 index 0000000000..083c4e1cea --- /dev/null +++ b/pkg-new/validation/upgradable.go @@ -0,0 +1,222 @@ +package validation + +import ( + "context" + "fmt" + "regexp" + "strconv" + + "github.com/Masterminds/semver/v3" + "github.com/replicatedhq/embedded-cluster/pkg-new/replicatedapi" + "github.com/replicatedhq/embedded-cluster/pkg/airgap" + kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" +) + +// k8sBuildRegex holds the regex pattern we use for the build portion of our EC version - i.e. 2.11.3+k8s-1.33 +var k8sBuildRegex = regexp.MustCompile(`k8s-(\d+\.\d+)`) + +// UpgradableOptions holds configuration for validating release deployability +type UpgradableOptions struct { + CurrentAppVersion string + CurrentAppSequence int64 + CurrentECVersion string + TargetAppVersion string + TargetAppSequence int64 + TargetECVersion string + License *kotsv1beta1.License + requiredReleases []string +} + +// WithAirgapRequiredReleases extracts the required releases from airgap metadata to be used for validation +func (opts *UpgradableOptions) WithAirgapRequiredReleases(metadata *airgap.AirgapMetadata) error { + if metadata == nil || metadata.AirgapInfo == nil { + return fmt.Errorf("airgap metadata is required for validating airgap required releases") + } + + // RequiredReleases are in descending order, we need to iterate through the required releases of the target release until we find releases lower than the current installed release + requiredReleases := metadata.AirgapInfo.Spec.RequiredReleases + if len(requiredReleases) > 0 { + // Extract version labels from required releases + for _, release := range requiredReleases { + sequence, err := strconv.ParseInt(release.UpdateCursor, 10, 64) + if err != nil { + return fmt.Errorf("failed to parse airgap spec required release update cursor %s: %w", release.UpdateCursor, err) + } + // We've hit a release that is less than or equal to the current installed release, we can stop + if sequence <= opts.CurrentAppSequence { + return nil + } + opts.requiredReleases = append(opts.requiredReleases, release.VersionLabel) + } + } + return nil +} + +// WithOnlineRequiredReleases fetches the pending releases from the current app sequence and extracts the required releases until the target app sequence +func (opts *UpgradableOptions) WithOnlineRequiredReleases(ctx context.Context, replAPIClient replicatedapi.Client) error { + if opts.License == nil { + return fmt.Errorf("license is required to check online upgrade required releases") + } + options := &replicatedapi.PendingReleasesOptions{ + IsSemverSupported: opts.License.Spec.IsSemverRequired, + SortOrder: replicatedapi.SortOrderAscending, + } + // Get pending releases from the current app sequence in asceding order + pendingReleases, err := replAPIClient.GetPendingReleases(ctx, opts.License.Spec.ChannelID, opts.CurrentAppSequence, options) + if err != nil { + return fmt.Errorf("failed to get pending releases while checking required releases for upgrade: %w", err) + } + if pendingReleases != nil { + opts.handlePendingReleases(pendingReleases.ChannelReleases) + } + return nil +} + +// handlePendingReleases processes the pending releases to extract required releases between current and target sequences +func (opts *UpgradableOptions) handlePendingReleases(pendingReleases []replicatedapi.ChannelRelease) { + // Find required releases between current and target sequence + for _, release := range pendingReleases { + // Releases are in asceding order, we've hit the target sequence so we can break + if release.ChannelSequence == opts.TargetAppSequence { + break + } + if release.IsRequired { + opts.requiredReleases = append(opts.requiredReleases, release.VersionLabel) + } + } +} + +// ValidateIsReleaseUpgradable validates that a target release can be safely deployed +func ValidateIsReleaseUpgradable(ctx context.Context, opts UpgradableOptions) error { + // Check 1: App version downgrade + if err := validateAppVersionDowngrade(opts); err != nil { + return err + } + + // Check 2: Required releases + if err := validateRequiredReleases(ctx, opts); err != nil { + return err + } + + // Check 3: EC version downgrade + if err := validateECVersionDowngrade(opts); err != nil { + return err + } + + // Check 4: K8s minor version skip and downgrade + if err := validateK8sVersion(opts); err != nil { + return err + } + + return nil +} + +// validateRequiredReleases checks if any required releases are being skipped +func validateRequiredReleases(ctx context.Context, opts UpgradableOptions) error { + if len(opts.requiredReleases) > 0 { + return NewRequiredReleasesError(opts.requiredReleases, opts.TargetAppVersion) + } + + return nil +} + +// validateAppVersionDowngrade checks if the target app version is older than the current version +func validateAppVersionDowngrade(opts UpgradableOptions) error { + // If using semver than compare using it + if opts.License.Spec.IsSemverRequired { + currentVer, err := semver.NewVersion(opts.CurrentAppVersion) + if err != nil { + return fmt.Errorf("failed to parse current app version %s: %w", opts.CurrentAppVersion, err) + } + targetVer, err := semver.NewVersion(opts.TargetAppVersion) + if err != nil { + return fmt.Errorf("failed to parse target app version %s: %w", opts.TargetAppVersion, err) + } + if targetVer.LessThan(currentVer) { + return NewAppVersionDowngradeError(opts.CurrentAppVersion, opts.TargetAppVersion) + } + return nil + } + + // Use app sequence as fallback + if opts.CurrentAppSequence > opts.TargetAppSequence { + return NewAppVersionDowngradeError(opts.CurrentAppVersion, opts.TargetAppVersion) + } + + return nil +} + +// validateECVersionDowngrade checks if the target EC version is older than the current version +func validateECVersionDowngrade(opts UpgradableOptions) error { + current, err := semver.NewVersion(opts.CurrentECVersion) + if err != nil { + return fmt.Errorf("failed to parse current EC version %s: %w", opts.CurrentECVersion, err) + } + + target, err := semver.NewVersion(opts.TargetECVersion) + if err != nil { + return fmt.Errorf("failed to parse target EC version %s: %w", opts.TargetECVersion, err) + } + + if target.LessThan(current) { + return NewECVersionDowngradeError(opts.CurrentECVersion, opts.TargetECVersion) + } + + return nil +} + +// validateK8sVersion checks if the K8s version skips a minor version or downgrades +func validateK8sVersion(opts UpgradableOptions) error { + // Parse the EC version format to extract K8s version: "2.12.0+k8s-1.33-*" + currentK8s, err := getK8sVersion(opts.CurrentECVersion) + if err != nil { + return fmt.Errorf("failed to extract k8s version from current version %s: %w", opts.CurrentECVersion, err) + } + + targetK8s, err := getK8sVersion(opts.TargetECVersion) + if err != nil { + return fmt.Errorf("failed to extract k8s version from target version %s: %w", opts.TargetECVersion, err) + } + + // Check if minor version is being skipped + if targetK8s.Minor() > currentK8s.Minor()+1 { + return NewK8sVersionSkipError( + currentK8s.String(), + targetK8s.String(), + ) + } + + // Check if K8s version is being downgraded + if targetK8s.LessThan(currentK8s) { + return NewK8sVersionDowngrade( + currentK8s.String(), + targetK8s.String(), + ) + } + + return nil +} + +// getK8sVersion parses an EC version string in the format "2.12.0+k8s-1.33-*" +// and returns the K8s version +func getK8sVersion(version string) (*semver.Version, error) { + // Parse the EC version format to extract K8s version: "2.12.0+k8s-1.33-*" + ecVersion, err := semver.NewVersion(version) + if err != nil { + return nil, fmt.Errorf("failed to parse EC version %s: %w", version, err) + } + + // Parse the build portion of the semver version +k8s- and extract it + matches := k8sBuildRegex.FindStringSubmatch(ecVersion.Metadata()) + if len(matches) != 2 { + return nil, fmt.Errorf("invalid EC version format: expected 'X.Y.Z+k8s-A.B-*', got %s", version) + } + + // Parse k8s version + k8sVersion, err := semver.NewVersion(matches[1]) + if err != nil { + return nil, fmt.Errorf("failed to parse k8s version %s: %w", k8sVersion, err) + } + + return k8sVersion, nil +} diff --git a/pkg-new/validation/upgradable_test.go b/pkg-new/validation/upgradable_test.go new file mode 100644 index 0000000000..f4407f9f60 --- /dev/null +++ b/pkg-new/validation/upgradable_test.go @@ -0,0 +1,530 @@ +package validation + +import ( + "errors" + "testing" + + "github.com/replicatedhq/embedded-cluster/pkg-new/replicatedapi" + "github.com/replicatedhq/embedded-cluster/pkg/airgap" + kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Test helpers + +func newTestLicense(isSemverRequired bool) *kotsv1beta1.License { + return &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + AppSlug: "test-app", + LicenseID: "test-license-id", + IsSemverRequired: isSemverRequired, + ChannelID: "test-channel-123", + ChannelName: "Stable", + LicenseSequence: 1, + Channels: []kotsv1beta1.Channel{ + { + ChannelID: "test-channel-123", + ChannelName: "Stable", + }, + }, + }, + } +} + +type airgapReleaseData struct { + versionLabel string + updateCursor string +} + +func newTestAirgapMetadataWithSequences(releases []airgapReleaseData) *airgap.AirgapMetadata { + var releaseMetas []kotsv1beta1.AirgapReleaseMeta + for _, r := range releases { + releaseMetas = append(releaseMetas, kotsv1beta1.AirgapReleaseMeta{ + VersionLabel: r.versionLabel, + UpdateCursor: r.updateCursor, + }) + } + + return &airgap.AirgapMetadata{ + AirgapInfo: &kotsv1beta1.Airgap{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "embedded-cluster.replicated.com/v1beta1", + Kind: "AirgapInfo", + }, + Spec: kotsv1beta1.AirgapSpec{ + RequiredReleases: releaseMetas, + }, + }, + } +} + +// Tests + +func TestWithAirgapRequiredReleases(t *testing.T) { + tests := []struct { + name string + metadata *airgap.AirgapMetadata + currentSequence int64 + expectedReleases []string + expectError bool + errorContains string + }{ + { + name: "no required releases", + metadata: newTestAirgapMetadataWithSequences([]airgapReleaseData{}), + currentSequence: 100, + expectedReleases: []string{}, + expectError: false, + }, + { + name: "all releases newer than current", + metadata: newTestAirgapMetadataWithSequences([]airgapReleaseData{ + {versionLabel: "1.5.0", updateCursor: "500"}, + {versionLabel: "1.4.0", updateCursor: "400"}, + {versionLabel: "1.3.0", updateCursor: "300"}, + }), + currentSequence: 100, + expectedReleases: []string{"1.5.0", "1.4.0", "1.3.0"}, + expectError: false, + }, + { + name: "mixed releases - stops at older release", + metadata: newTestAirgapMetadataWithSequences([]airgapReleaseData{ + {versionLabel: "1.5.0", updateCursor: "500"}, + {versionLabel: "1.4.0", updateCursor: "400"}, + {versionLabel: "1.2.0", updateCursor: "200"}, + {versionLabel: "1.1.0", updateCursor: "100"}, + }), + currentSequence: 300, + expectedReleases: []string{"1.5.0", "1.4.0"}, + expectError: false, + }, + { + name: "stops at current sequence", + metadata: newTestAirgapMetadataWithSequences([]airgapReleaseData{ + {versionLabel: "1.4.0", updateCursor: "400"}, + {versionLabel: "1.3.0", updateCursor: "300"}, + {versionLabel: "1.2.0", updateCursor: "200"}, + }), + currentSequence: 300, + expectedReleases: []string{"1.4.0"}, + expectError: false, + }, + { + name: "all releases older than current", + metadata: newTestAirgapMetadataWithSequences([]airgapReleaseData{ + {versionLabel: "1.2.0", updateCursor: "200"}, + {versionLabel: "1.1.0", updateCursor: "100"}, + }), + currentSequence: 300, + expectedReleases: []string{}, + expectError: false, + }, + { + name: "nil metadata", + metadata: nil, + currentSequence: 100, + expectError: true, + errorContains: "airgap metadata is required", + }, + { + name: "nil airgap info", + metadata: &airgap.AirgapMetadata{ + AirgapInfo: nil, + }, + currentSequence: 100, + expectError: true, + errorContains: "airgap metadata is required", + }, + { + name: "invalid update cursor", + metadata: newTestAirgapMetadataWithSequences([]airgapReleaseData{ + {versionLabel: "1.5.0", updateCursor: "invalid-number"}, + }), + currentSequence: 100, + expectError: true, + errorContains: "failed to parse airgap spec required release update cursor", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := UpgradableOptions{ + CurrentAppSequence: tt.currentSequence, + } + + err := opts.WithAirgapRequiredReleases(tt.metadata) + + if tt.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errorContains) + } else { + require.NoError(t, err) + if len(tt.expectedReleases) == 0 { + assert.Empty(t, opts.requiredReleases) + } else { + assert.Equal(t, tt.expectedReleases, opts.requiredReleases) + } + } + }) + } +} + +func TestHandlePendingReleases(t *testing.T) { + tests := []struct { + name string + pendingReleases []replicatedapi.ChannelRelease + currentSequence int64 + targetSequence int64 + expectedReleases []string + }{ + { + name: "no required releases", + pendingReleases: []replicatedapi.ChannelRelease{ + {ChannelSequence: 101, VersionLabel: "1.1.0", IsRequired: false}, + {ChannelSequence: 102, VersionLabel: "1.2.0", IsRequired: false}, + {ChannelSequence: 103, VersionLabel: "1.3.0", IsRequired: false}, + }, + currentSequence: 100, + targetSequence: 104, + expectedReleases: []string{}, + }, + { + name: "all releases required", + pendingReleases: []replicatedapi.ChannelRelease{ + {ChannelSequence: 101, VersionLabel: "1.1.0", IsRequired: true}, + {ChannelSequence: 102, VersionLabel: "1.2.0", IsRequired: true}, + {ChannelSequence: 103, VersionLabel: "1.3.0", IsRequired: true}, + }, + currentSequence: 100, + targetSequence: 104, + expectedReleases: []string{"1.1.0", "1.2.0", "1.3.0"}, + }, + { + name: "mixed required and not required", + pendingReleases: []replicatedapi.ChannelRelease{ + {ChannelSequence: 101, VersionLabel: "1.1.0", IsRequired: true}, + {ChannelSequence: 102, VersionLabel: "1.2.0", IsRequired: false}, + {ChannelSequence: 103, VersionLabel: "1.3.0", IsRequired: true}, + }, + currentSequence: 100, + targetSequence: 104, + expectedReleases: []string{"1.1.0", "1.3.0"}, + }, + { + name: "stops at target sequence", + pendingReleases: []replicatedapi.ChannelRelease{ + {ChannelSequence: 101, VersionLabel: "1.1.0", IsRequired: true}, + {ChannelSequence: 102, VersionLabel: "1.2.0", IsRequired: true}, + {ChannelSequence: 103, VersionLabel: "1.3.0", IsRequired: true}, + {ChannelSequence: 104, VersionLabel: "1.4.0", IsRequired: true}, + {ChannelSequence: 105, VersionLabel: "1.5.0", IsRequired: true}, + }, + currentSequence: 100, + targetSequence: 104, + expectedReleases: []string{"1.1.0", "1.2.0", "1.3.0"}, + }, + { + name: "empty pending releases", + pendingReleases: []replicatedapi.ChannelRelease{}, + currentSequence: 100, + targetSequence: 104, + expectedReleases: []string{}, + }, + { + name: "single required release", + pendingReleases: []replicatedapi.ChannelRelease{ + {ChannelSequence: 101, VersionLabel: "1.1.0", IsRequired: true}, + }, + currentSequence: 100, + targetSequence: 102, + expectedReleases: []string{"1.1.0"}, + }, + { + name: "target sequence equals first release - no releases collected", + pendingReleases: []replicatedapi.ChannelRelease{ + {ChannelSequence: 100, VersionLabel: "1.0.0", IsRequired: true}, + {ChannelSequence: 101, VersionLabel: "1.1.0", IsRequired: true}, + }, + currentSequence: 99, + targetSequence: 100, + expectedReleases: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := UpgradableOptions{ + CurrentAppSequence: tt.currentSequence, + TargetAppSequence: tt.targetSequence, + } + + opts.handlePendingReleases(tt.pendingReleases) + + if len(tt.expectedReleases) == 0 { + assert.Empty(t, opts.requiredReleases) + } else { + assert.Equal(t, tt.expectedReleases, opts.requiredReleases) + } + }) + } +} + +func TestValidateIsReleaseUpgradable(t *testing.T) { + tests := []struct { + name string + opts UpgradableOptions + expectError bool + expectValidationErr bool + }{ + { + name: "valid upgrade - semver", + opts: UpgradableOptions{ + CurrentAppVersion: "1.0.0", + CurrentAppSequence: 100, + CurrentECVersion: "2.0.0+k8s-1.29", + TargetAppVersion: "1.1.0", + TargetAppSequence: 101, + TargetECVersion: "2.1.0+k8s-1.29", + License: newTestLicense(true), + requiredReleases: []string{}, + }, + expectError: false, + expectValidationErr: false, + }, + { + name: "valid upgrade - sequence-based", + opts: UpgradableOptions{ + CurrentAppVersion: "v100", + CurrentAppSequence: 100, + CurrentECVersion: "2.0.0+k8s-1.29", + TargetAppVersion: "v101", + TargetAppSequence: 101, + TargetECVersion: "2.1.0+k8s-1.29", + License: newTestLicense(false), + requiredReleases: []string{}, + }, + expectError: false, + expectValidationErr: false, + }, + { + name: "valid k8s minor version upgrade", + opts: UpgradableOptions{ + CurrentAppVersion: "1.0.0", + CurrentAppSequence: 100, + CurrentECVersion: "2.0.0+k8s-1.29", + TargetAppVersion: "1.1.0", + TargetAppSequence: 101, + TargetECVersion: "2.1.0+k8s-1.30", + License: newTestLicense(true), + requiredReleases: []string{}, + }, + expectError: false, + expectValidationErr: false, + }, + { + name: "valid upgrade - same versions", + opts: UpgradableOptions{ + CurrentAppVersion: "1.0.0", + CurrentAppSequence: 100, + CurrentECVersion: "2.0.0+k8s-1.29", + TargetAppVersion: "1.0.0", + TargetAppSequence: 100, + TargetECVersion: "2.0.0+k8s-1.29", + License: newTestLicense(true), + requiredReleases: []string{}, + }, + expectError: false, + expectValidationErr: false, + }, + { + name: "valid upgrade - patch version only", + opts: UpgradableOptions{ + CurrentAppVersion: "1.0.0", + CurrentAppSequence: 100, + CurrentECVersion: "2.0.0+k8s-1.29", + TargetAppVersion: "1.0.1", + TargetAppSequence: 101, + TargetECVersion: "2.0.1+k8s-1.29", + License: newTestLicense(true), + requiredReleases: []string{}, + }, + expectError: false, + expectValidationErr: false, + }, + { + name: "app version downgrade - semver", + opts: UpgradableOptions{ + CurrentAppVersion: "2.0.0", + CurrentAppSequence: 200, + CurrentECVersion: "2.0.0+k8s-1.29", + TargetAppVersion: "1.5.0", + TargetAppSequence: 150, + TargetECVersion: "2.1.0+k8s-1.29", + License: newTestLicense(true), + requiredReleases: []string{}, + }, + expectError: true, + expectValidationErr: true, + }, + { + name: "app version downgrade - sequence-based", + opts: UpgradableOptions{ + CurrentAppVersion: "v200", + CurrentAppSequence: 200, + CurrentECVersion: "2.0.0+k8s-1.29", + TargetAppVersion: "v150", + TargetAppSequence: 150, + TargetECVersion: "2.1.0+k8s-1.29", + License: newTestLicense(false), + requiredReleases: []string{}, + }, + expectError: true, + expectValidationErr: true, + }, + { + name: "required releases present", + opts: UpgradableOptions{ + CurrentAppVersion: "1.0.0", + CurrentAppSequence: 100, + CurrentECVersion: "2.0.0+k8s-1.29", + TargetAppVersion: "1.5.0", + TargetAppSequence: 500, + TargetECVersion: "2.1.0+k8s-1.29", + License: newTestLicense(true), + requiredReleases: []string{"1.1.0", "1.2.0"}, + }, + expectError: true, + expectValidationErr: true, + }, + { + name: "ec version downgrade", + opts: UpgradableOptions{ + CurrentAppVersion: "1.0.0", + CurrentAppSequence: 100, + CurrentECVersion: "2.5.0+k8s-1.30", + TargetAppVersion: "1.1.0", + TargetAppSequence: 101, + TargetECVersion: "2.3.0+k8s-1.29", + License: newTestLicense(true), + requiredReleases: []string{}, + }, + expectError: true, + expectValidationErr: true, + }, + { + name: "k8s version skip - one minor version", + opts: UpgradableOptions{ + CurrentAppVersion: "1.0.0", + CurrentAppSequence: 100, + CurrentECVersion: "2.0.0+k8s-1.29", + TargetAppVersion: "1.1.0", + TargetAppSequence: 101, + TargetECVersion: "2.1.0+k8s-1.31", + License: newTestLicense(true), + requiredReleases: []string{}, + }, + expectError: true, + expectValidationErr: true, + }, + { + name: "k8s version skip - multiple minor versions", + opts: UpgradableOptions{ + CurrentAppVersion: "1.0.0", + CurrentAppSequence: 100, + CurrentECVersion: "2.0.0+k8s-1.27", + TargetAppVersion: "1.1.0", + TargetAppSequence: 101, + TargetECVersion: "2.1.0+k8s-1.31", + License: newTestLicense(true), + requiredReleases: []string{}, + }, + expectError: true, + expectValidationErr: true, + }, + { + name: "k8s version downgrade", + opts: UpgradableOptions{ + CurrentAppVersion: "1.0.0", + CurrentAppSequence: 100, + CurrentECVersion: "2.0.0+k8s-1.33", + TargetAppVersion: "1.1.0", + TargetAppSequence: 101, + TargetECVersion: "2.1.0+k8s-1.32", + License: newTestLicense(true), + requiredReleases: []string{}, + }, + expectError: true, + expectValidationErr: true, + }, + { + name: "invalid semver in app version", + opts: UpgradableOptions{ + CurrentAppVersion: "invalid-version", + CurrentAppSequence: 100, + CurrentECVersion: "2.0.0+k8s-1.29", + TargetAppVersion: "1.1.0", + TargetAppSequence: 101, + TargetECVersion: "2.1.0+k8s-1.29", + License: newTestLicense(true), + requiredReleases: []string{}, + }, + expectError: true, + expectValidationErr: false, // parsing errors are not ValidationError + }, + { + name: "invalid ec version format", + opts: UpgradableOptions{ + CurrentAppVersion: "1.0.0", + CurrentAppSequence: 100, + CurrentECVersion: "invalid-version", + TargetAppVersion: "1.1.0", + TargetAppSequence: 101, + TargetECVersion: "2.1.0+k8s-1.29", + License: newTestLicense(true), + requiredReleases: []string{}, + }, + expectError: true, + expectValidationErr: false, // parsing errors are not ValidationError + }, + { + name: "invalid k8s version format in ec version", + opts: UpgradableOptions{ + CurrentAppVersion: "1.0.0", + CurrentAppSequence: 100, + CurrentECVersion: "2.0.0+invalid-build", + TargetAppVersion: "1.1.0", + TargetAppSequence: 101, + TargetECVersion: "2.1.0+k8s-1.29", + License: newTestLicense(true), + requiredReleases: []string{}, + }, + expectError: true, + expectValidationErr: false, // parsing errors are not ValidationError + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateIsReleaseUpgradable(t.Context(), tt.opts) + + if tt.expectError { + require.Error(t, err) + + // Check if it's a ValidationError type + var validationErr *ValidationError + isValidationErr := errors.As(err, &validationErr) + + if tt.expectValidationErr { + assert.True(t, isValidationErr, "expected ValidationError but got: %T", err) + } else { + assert.False(t, isValidationErr, "expected non-ValidationError but got ValidationError") + } + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/pkg/dryrun/replicatedapi.go b/pkg/dryrun/replicatedapi.go index 0d03340912..b8b6295632 100644 --- a/pkg/dryrun/replicatedapi.go +++ b/pkg/dryrun/replicatedapi.go @@ -13,8 +13,9 @@ var _ replicatedapi.Client = (*ReplicatedAPIClient)(nil) // ReplicatedAPIClient is a mockable implementation of the replicatedapi.Client interface. type ReplicatedAPIClient struct { - License *kotsv1beta1.License - LicenseBytes []byte + License *kotsv1beta1.License + LicenseBytes []byte + PendingReleases []replicatedapi.ChannelRelease } // SyncLicense returns the mocked license data. @@ -30,3 +31,8 @@ func (c *ReplicatedAPIClient) SyncLicense(ctx context.Context) (*kotsv1beta1.Lic return c.License, c.LicenseBytes, nil } + +// GetPendingReleases returns the mocked pending releases data. +func (c *ReplicatedAPIClient) GetPendingReleases(ctx context.Context, channelID string, currentSequence int64, opts *replicatedapi.PendingReleasesOptions) (*replicatedapi.PendingReleasesResponse, error) { + return &replicatedapi.PendingReleasesResponse{ChannelReleases: c.PendingReleases}, nil +}