From 0589dd2ff354b3d2c0a688663abafc54b397476f Mon Sep 17 00:00:00 2001 From: Ethan Mosbaugh Date: Tue, 29 Oct 2024 17:26:31 -0500 Subject: [PATCH] feat(ec): allow rollbacks for embedded cluster (#4972) * feat(ec): allow rollbacks for embedded cluster * f * f * f * f * feedback * feedback * feedback * feedback * feedback * f * assert interface --- cmd/kotsadm/cli/root.go | 5 + pkg/apparchive/helm-v1beta1.go | 6 +- pkg/store/kotsstore/downstream_store.go | 81 ++++++++-- pkg/store/kotsstore/downstream_store_test.go | 162 ++++++++++++++++++- pkg/store/kotsstore/kots_store.go | 3 + pkg/store/kotsstore/version_store.go | 4 - pkg/store/store.go | 15 +- pkg/supportbundle/spec.go | 6 +- pkg/updatechecker/updatechecker.go | 4 +- pkg/util/util.go | 5 - 10 files changed, 241 insertions(+), 50 deletions(-) diff --git a/cmd/kotsadm/cli/root.go b/cmd/kotsadm/cli/root.go index 4ec8a4066b..087a0ea330 100644 --- a/cmd/kotsadm/cli/root.go +++ b/cmd/kotsadm/cli/root.go @@ -5,6 +5,8 @@ import ( "os" "strings" + "github.com/replicatedhq/kots/pkg/store" + "github.com/replicatedhq/kots/pkg/store/kotsstore" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -15,6 +17,9 @@ func RootCmd() *cobra.Command { Short: "kotsadm is the Admin Console for KOTS", Long: ``, Args: cobra.MinimumNArgs(1), + PersistentPreRun: func(cmd *cobra.Command, args []string) { + store.SetStore(kotsstore.StoreFromEnv()) + }, PreRun: func(cmd *cobra.Command, args []string) { viper.BindPFlags(cmd.Flags()) }, diff --git a/pkg/apparchive/helm-v1beta1.go b/pkg/apparchive/helm-v1beta1.go index a9e6ffa0b8..b008ac0914 100644 --- a/pkg/apparchive/helm-v1beta1.go +++ b/pkg/apparchive/helm-v1beta1.go @@ -15,12 +15,8 @@ import ( ) var ( - goTemplateRegex *regexp.Regexp -) - -func init() { goTemplateRegex = regexp.MustCompile(`({{)|(}})`) -} +) func GetRenderedV1Beta1ChartsArchive(versionArchive string, downstreamName, kustomizeBinPath string) ([]byte, map[string][]byte, error) { renderedChartsDir := filepath.Join(versionArchive, "rendered", downstreamName, "charts") diff --git a/pkg/store/kotsstore/downstream_store.go b/pkg/store/kotsstore/downstream_store.go index b69db3cf9a..3733202120 100644 --- a/pkg/store/kotsstore/downstream_store.go +++ b/pkg/store/kotsstore/downstream_store.go @@ -1,7 +1,9 @@ package kotsstore import ( + "bytes" "encoding/base64" + "encoding/json" "fmt" "strings" "time" @@ -13,8 +15,10 @@ import ( "github.com/replicatedhq/kots/pkg/kotsutil" "github.com/replicatedhq/kots/pkg/logger" "github.com/replicatedhq/kots/pkg/persistence" + "github.com/replicatedhq/kots/pkg/store" "github.com/replicatedhq/kots/pkg/store/types" "github.com/replicatedhq/kots/pkg/tasks" + "github.com/replicatedhq/kots/pkg/util" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" "github.com/rqlite/gorqlite" ) @@ -423,7 +427,10 @@ func (s *KOTSStore) GetDownstreamVersions(appID string, clusterID string, downlo if err := s.AddDownstreamVersionDetails(appID, clusterID, v, false); err != nil { return nil, errors.Wrap(err, "failed to add details to latest downloaded version") } - v.IsDeployable, v.NonDeployableCause = isAppVersionDeployable(v, result, license.Spec.IsSemverRequired) + v.IsDeployable, v.NonDeployableCause, err = isAppVersionDeployable(s, appID, v, result, license.Spec.IsSemverRequired) + if err != nil { + return nil, errors.Wrapf(err, "failed to check if version %s is deployable", v.VersionLabel) + } break } @@ -676,7 +683,10 @@ func (s *KOTSStore) AddDownstreamVersionsDetails(appID string, clusterID string, } for _, v := range versions { - v.IsDeployable, v.NonDeployableCause = isAppVersionDeployable(v, allVersions, license.Spec.IsSemverRequired) + v.IsDeployable, v.NonDeployableCause, err = isAppVersionDeployable(s, appID, v, allVersions, license.Spec.IsSemverRequired) + if err != nil { + return errors.Wrapf(err, "failed to check if version %s is deployable", v.VersionLabel) + } } } @@ -866,28 +876,32 @@ func isSameUpstreamRelease(v1 *downstreamtypes.DownstreamVersion, v2 *downstream return v1.Semver.EQ(*v2.Semver) } -func isAppVersionDeployable(version *downstreamtypes.DownstreamVersion, appVersions *downstreamtypes.DownstreamVersions, isSemverRequired bool) (bool, string) { +func isAppVersionDeployable( + versionStore store.VersionStore, + appID string, version *downstreamtypes.DownstreamVersion, appVersions *downstreamtypes.DownstreamVersions, + isSemverRequired bool, +) (bool, string, error) { if version.HasFailingStrictPreflights { - return false, "Deployment is disabled as a strict analyzer in this version's preflight checks has failed or has not been run." + return false, "Deployment is disabled as a strict analyzer in this version's preflight checks has failed or has not been run.", nil } if version.Status == types.VersionPendingDownload { - return false, "Version is pending download." + return false, "Version is pending download.", nil } if version.Status == types.VersionPendingConfig { - return false, "Version is pending configuration." + return false, "Version is pending configuration.", nil } if appVersions.CurrentVersion == nil { // no version has been deployed yet, treat as an initial install where any version can be deployed at first. - return true, "" + return true, "", nil } if version.Sequence == appVersions.CurrentVersion.Sequence { // version is currently deployed, so previous required versions should've already been deployed. // also, we shouldn't block re-deploying if a previous release is edited later by the vendor to be required. - return true, "" + return true, "", nil } // rollback support is determined across all versions from all channels @@ -906,18 +920,41 @@ func isAppVersionDeployable(version *downstreamtypes.DownstreamVersion, appVersi break } } + + // This is a past version if versionIndex > deployedVersionIndex { - // this is a past version - // rollback support is based off of the latest downloaded version + // Rollback support is based off of the latest downloaded version so that a vendor can + // toggle on support without requiring the end user to deploy a new version. for _, v := range appVersions.AllVersions { + // Find the first version that is not pending download. This will be the latest + // version. if v.Status == types.VersionPendingDownload { continue } if v.KOTSKinds == nil || !v.KOTSKinds.KotsApplication.Spec.AllowRollback { - return false, "Rollback is not supported." + return false, "Rollback is not supported.", nil } break } + + if util.IsEmbeddedCluster() && appVersions.CurrentVersion != nil { + currentECConfig, err := getRawEmbeddedClusterConfigForVersion(versionStore, appID, appVersions.CurrentVersion.Sequence) + if err != nil { + return false, "", errors.Wrapf(err, "failed to get embedded cluster config for current version %d", appVersions.CurrentVersion.Sequence) + } + newECConfig, err := getRawEmbeddedClusterConfigForVersion(versionStore, appID, version.Sequence) + if err != nil { + return false, "", errors.Wrapf(err, "failed to get embedded cluster config for version %d", version.Sequence) + } + if util.IsEmbeddedCluster() && currentECConfig != nil { + // Compare the embedded cluster config of the version specified to the currently + // deployed version to check if it has changed. If it has, then we do not allow + // rollbacks. + if !bytes.Equal(currentECConfig, newECConfig) { + return false, "Rollback is not supported, cluster configuration has changed.", nil + } + } + } } // if semantic versioning is not enabled, only require versions from the same channel AND with a lower cursor/channel sequence @@ -951,7 +988,7 @@ func isAppVersionDeployable(version *downstreamtypes.DownstreamVersion, appVersi if deployedVersionIndex == -1 { // the deployed version is from a different channel - return true, "" + return true, "", nil } // find required versions between the deployed version and the desired version @@ -969,7 +1006,7 @@ ALL_VERSIONS_LOOP: // this is a past version // >= because if the deployed version is required, rolling back isn't allowed if i >= deployedVersionIndex && i < versionIndex { - return false, "One or more non-reversible versions have been deployed since this version." + return false, "One or more non-reversible versions have been deployed since this version.", nil } continue } @@ -997,12 +1034,24 @@ ALL_VERSIONS_LOOP: } versionLabelsStr := strings.Join(versionLabels, ", ") if len(requiredVersions) == 1 { - return false, fmt.Sprintf("This version cannot be deployed because version %s is required and must be deployed first.", versionLabelsStr) + return false, fmt.Sprintf("This version cannot be deployed because version %s is required and must be deployed first.", versionLabelsStr), nil } - return false, fmt.Sprintf("This version cannot be deployed because versions %s are required and must be deployed first.", versionLabelsStr) + return false, fmt.Sprintf("This version cannot be deployed because versions %s are required and must be deployed first.", versionLabelsStr), nil } - return true, "" + return true, "", nil +} + +func getRawEmbeddedClusterConfigForVersion(versionStore store.VersionStore, appID string, sequence int64) ([]byte, error) { + currentConf, err := versionStore.GetEmbeddedClusterConfigForVersion(appID, sequence) + if err != nil { + return nil, errors.Wrap(err, "failed to get embedded cluster config") + } + b, err := json.Marshal(currentConf) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal embedded cluster config") + } + return b, nil } func getReleaseNotes(appID string, parentSequence int64) (string, error) { diff --git a/pkg/store/kotsstore/downstream_store_test.go b/pkg/store/kotsstore/downstream_store_test.go index 97593adcf2..bbc5e2fded 100644 --- a/pkg/store/kotsstore/downstream_store_test.go +++ b/pkg/store/kotsstore/downstream_store_test.go @@ -4,12 +4,16 @@ import ( "testing" "github.com/blang/semver" + "github.com/golang/mock/gomock" + embeddedclusterv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" downstreamtypes "github.com/replicatedhq/kots/pkg/api/downstream/types" "github.com/replicatedhq/kots/pkg/cursor" "github.com/replicatedhq/kots/pkg/kotsutil" + mock_store "github.com/replicatedhq/kots/pkg/store/mock" "github.com/replicatedhq/kots/pkg/store/types" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func Test_isSameUpstreamRelease(t *testing.T) { @@ -250,8 +254,10 @@ func Test_isAppVersionDeployable(t *testing.T) { version *downstreamtypes.DownstreamVersion appVersions *downstreamtypes.DownstreamVersions isSemverRequired bool + setup func(t *testing.T, mockStore *mock_store.MockStore) expectedIsDeployable bool expectedCause string + wantErr bool }{ { name: "failing strict preflights", @@ -3621,6 +3627,147 @@ func Test_isAppVersionDeployable(t *testing.T) { }, /* ---- Semver rollback tests end here ---- */ /* ---- Semver tests end here ---- */ + /* ---- Embedded cluster config tests start here ---- */ + { + name: "embedded cluster config change should not allow rollbacks", + setup: func(t *testing.T, mockStore *mock_store.MockStore) { + t.Setenv("EMBEDDED_CLUSTER_ID", "1234") + + mockStore.EXPECT().GetEmbeddedClusterConfigForVersion("APPID", int64(0)).Return(&embeddedclusterv1beta1.Config{ + Spec: embeddedclusterv1beta1.ConfigSpec{ + Version: "1.0.0-ec.0", + }, + }, nil) + mockStore.EXPECT().GetEmbeddedClusterConfigForVersion("APPID", int64(1)).Return(&embeddedclusterv1beta1.Config{ + Spec: embeddedclusterv1beta1.ConfigSpec{ + Version: "1.0.0-ec.1", + }, + }, nil) + }, + version: &downstreamtypes.DownstreamVersion{ + VersionLabel: "1.0.0", + Sequence: 0, + }, + appVersions: &downstreamtypes.DownstreamVersions{ + CurrentVersion: &downstreamtypes.DownstreamVersion{ + VersionLabel: "2.0.0", + Sequence: 1, + }, + AllVersions: []*downstreamtypes.DownstreamVersion{ + { + VersionLabel: "3.0.0", + Sequence: 2, + KOTSKinds: &kotsutil.KotsKinds{ + KotsApplication: kotsv1beta1.Application{ + Spec: kotsv1beta1.ApplicationSpec{ + AllowRollback: true, + }, + }, + }, + }, + { + VersionLabel: "2.0.0", + Sequence: 1, + }, + { + VersionLabel: "1.0.0", + Sequence: 0, + }, + }, + }, + expectedIsDeployable: false, + expectedCause: "Rollback is not supported, cluster configuration has changed.", + }, + { + name: "embedded cluster config no change should allow rollbacks", + setup: func(t *testing.T, mockStore *mock_store.MockStore) { + t.Setenv("EMBEDDED_CLUSTER_ID", "1234") + + mockStore.EXPECT().GetEmbeddedClusterConfigForVersion("APPID", int64(0)).Return(&embeddedclusterv1beta1.Config{ + Spec: embeddedclusterv1beta1.ConfigSpec{ + Version: "1.0.0-ec.0", + }, + }, nil) + mockStore.EXPECT().GetEmbeddedClusterConfigForVersion("APPID", int64(1)).Return(&embeddedclusterv1beta1.Config{ + Spec: embeddedclusterv1beta1.ConfigSpec{ + Version: "1.0.0-ec.0", + }, + }, nil) + }, + version: &downstreamtypes.DownstreamVersion{ + VersionLabel: "1.0.0", + Sequence: 0, + }, + appVersions: &downstreamtypes.DownstreamVersions{ + CurrentVersion: &downstreamtypes.DownstreamVersion{ + VersionLabel: "2.0.0", + Sequence: 1, + }, + AllVersions: []*downstreamtypes.DownstreamVersion{ + { + VersionLabel: "3.0.0", + Sequence: 2, + KOTSKinds: &kotsutil.KotsKinds{ + KotsApplication: kotsv1beta1.Application{ + Spec: kotsv1beta1.ApplicationSpec{ + AllowRollback: true, + }, + }, + }, + }, + { + VersionLabel: "2.0.0", + Sequence: 1, + }, + { + VersionLabel: "1.0.0", + Sequence: 0, + }, + }, + }, + expectedIsDeployable: true, + expectedCause: "", + }, + { + name: "embedded cluster, allowRollback = false should not allow rollbacks", + setup: func(t *testing.T, mockStore *mock_store.MockStore) { + t.Setenv("EMBEDDED_CLUSTER_ID", "1234") + }, + version: &downstreamtypes.DownstreamVersion{ + VersionLabel: "1.0.0", + Sequence: 0, + }, + appVersions: &downstreamtypes.DownstreamVersions{ + CurrentVersion: &downstreamtypes.DownstreamVersion{ + VersionLabel: "2.0.0", + Sequence: 1, + }, + AllVersions: []*downstreamtypes.DownstreamVersion{ + { + VersionLabel: "3.0.0", + Sequence: 2, + KOTSKinds: &kotsutil.KotsKinds{ + KotsApplication: kotsv1beta1.Application{ + Spec: kotsv1beta1.ApplicationSpec{ + AllowRollback: false, + }, + }, + }, + }, + { + VersionLabel: "2.0.0", + Sequence: 1, + }, + { + VersionLabel: "1.0.0", + Sequence: 0, + }, + }, + }, + expectedIsDeployable: false, + expectedCause: "Rollback is not supported.", + }, + /* ---- Embedded cluster config tests end here ---- */ } for _, test := range tests { @@ -3656,7 +3803,20 @@ func Test_isAppVersionDeployable(t *testing.T) { } } - isDeployable, cause := isAppVersionDeployable(test.version, test.appVersions, test.isSemverRequired) + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := mock_store.NewMockStore(ctrl) + if test.setup != nil { + test.setup(t, mockStore) + } + + isDeployable, cause, err := isAppVersionDeployable(mockStore, "APPID", test.version, test.appVersions, test.isSemverRequired) + if test.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } assert.Equal(t, test.expectedIsDeployable, isDeployable) assert.Equal(t, test.expectedCause, cause) }) diff --git a/pkg/store/kotsstore/kots_store.go b/pkg/store/kotsstore/kots_store.go index e8caf43d36..d0a8c95abb 100644 --- a/pkg/store/kotsstore/kots_store.go +++ b/pkg/store/kotsstore/kots_store.go @@ -14,6 +14,7 @@ import ( kotsadmtypes "github.com/replicatedhq/kots/pkg/kotsadm/types" "github.com/replicatedhq/kots/pkg/logger" "github.com/replicatedhq/kots/pkg/persistence" + "github.com/replicatedhq/kots/pkg/store" "github.com/replicatedhq/kots/pkg/util" kotsscheme "github.com/replicatedhq/kotskinds/client/kotsclientset/scheme" troubleshootscheme "github.com/replicatedhq/troubleshoot/pkg/client/troubleshootclientset/scheme" @@ -28,6 +29,8 @@ var ( ErrNotFound = errors.New("not found") ) +var _ store.Store = &KOTSStore{} + type KOTSStore struct { sessionSecret *corev1.Secret sessionExpiration time.Time diff --git a/pkg/store/kotsstore/version_store.go b/pkg/store/kotsstore/version_store.go index 069182d867..4f1dbffc44 100644 --- a/pkg/store/kotsstore/version_store.go +++ b/pkg/store/kotsstore/version_store.go @@ -69,10 +69,6 @@ func (s *KOTSStore) IsRollbackSupportedForVersion(appID string, sequence int64) return false, errors.Wrap(err, "failed to load kots app from contents") } - if util.IsEmbeddedCluster() { - return false, nil - } - return kotsAppSpec.Spec.AllowRollback, nil } diff --git a/pkg/store/store.go b/pkg/store/store.go index a5d9ecc28d..096c4f190f 100644 --- a/pkg/store/store.go +++ b/pkg/store/store.go @@ -1,38 +1,27 @@ package store import ( - "github.com/replicatedhq/kots/pkg/store/kotsstore" "github.com/replicatedhq/kots/pkg/util" ) var ( - hasStore = false globalStore Store ) -var _ Store = (*kotsstore.KOTSStore)(nil) - func GetStore() Store { if util.IsUpgradeService() { panic("store cannot not be used in the upgrade service") } - if !hasStore { - globalStore = storeFromEnv() - hasStore = true + if globalStore == nil { + panic("store not initialized") } return globalStore } -func storeFromEnv() Store { - return kotsstore.StoreFromEnv() -} - func SetStore(s Store) { if s == nil { - hasStore = false globalStore = nil return } - hasStore = true globalStore = s } diff --git a/pkg/supportbundle/spec.go b/pkg/supportbundle/spec.go index 538eb67aec..af00f1fefa 100644 --- a/pkg/supportbundle/spec.go +++ b/pkg/supportbundle/spec.go @@ -46,11 +46,7 @@ import ( "k8s.io/client-go/kubernetes" ) -var appNameRE *regexp.Regexp - -func init() { - appNameRE = regexp.MustCompile(`^kotsadm-.*-supportbundle(?:$|.*)`) -} +var appNameRE = regexp.MustCompile(`^kotsadm-.*-supportbundle(?:$|.*)`) // CreateRenderedSpec creates the support bundle specification from defaults and the kots app func CreateRenderedSpec(app *apptypes.App, sequence int64, kotsKinds *kotsutil.KotsKinds, opts types.TroubleshootOptions) (*troubleshootv1beta2.SupportBundle, error) { diff --git a/pkg/updatechecker/updatechecker.go b/pkg/updatechecker/updatechecker.go index 05a87c675f..fc4374d173 100644 --- a/pkg/updatechecker/updatechecker.go +++ b/pkg/updatechecker/updatechecker.go @@ -35,13 +35,15 @@ import ( // jobs maps app ids to their cron jobs var jobs = make(map[string]*cron.Cron) var mtx sync.Mutex -var store = storepkg.GetStore() +var store storepkg.Store // Start will start the update checker // the frequency of those update checks are app specific and can be modified by the user func Start() error { logger.Debug("starting update checker") + store = storepkg.GetStore() + appsList, err := store.ListInstalledApps() if err != nil { return errors.Wrap(err, "failed to list installed apps") diff --git a/pkg/util/util.go b/pkg/util/util.go index 6fd7d111cb..abe7e2bcb8 100644 --- a/pkg/util/util.go +++ b/pkg/util/util.go @@ -10,16 +10,11 @@ import ( "net/url" "os" "path/filepath" - "time" "github.com/pkg/errors" "github.com/replicatedhq/kots/pkg/crypto" ) -func init() { - rand.Seed(time.Now().UnixNano()) -} - func IsURL(str string) bool { _, err := url.ParseRequestURI(str) if err != nil {