diff --git a/cmd/generate-config/config/config-openapi-spec.json b/cmd/generate-config/config/config-openapi-spec.json index d615ba026c..fe3fb34c77 100755 --- a/cmd/generate-config/config/config-openapi-spec.json +++ b/cmd/generate-config/config/config-openapi-spec.json @@ -64,10 +64,12 @@ "type": "object", "required": [ "customNoUpgrade", - "featureSet" + "featureSet", + "specialHandlingSupportExceptionRequired" ], "properties": { "customNoUpgrade": { + "description": "CustomNoUpgrade is used to enable/disable feature gates. When the enabled or disable lists are not empty, x- and y-stream upgrades will be blocked.\nUse this field exclusively for custom feature gates, unless you are certain that the feature gate is a SpecialHandlingSupportExceptionRequired feature.", "type": "object", "required": [ "disabled", @@ -90,6 +92,28 @@ }, "featureSet": { "type": "string" + }, + "specialHandlingSupportExceptionRequired": { + "description": "SpecialHandlingSupportExceptionRequired allows for feature gates to be exempted from blocking x- and y-stream upgrades.", + "type": "object", + "required": [ + "disabled", + "enabled" + ], + "properties": { + "disabled": { + "type": "array", + "items": { + "type": "string" + } + }, + "enabled": { + "type": "array", + "items": { + "type": "string" + } + } + } } } }, diff --git a/docs/user/howto_config.md b/docs/user/howto_config.md index 1ae5bf9927..3703ce1919 100644 --- a/docs/user/howto_config.md +++ b/docs/user/howto_config.md @@ -19,6 +19,9 @@ apiServer: disabled: [] enabled: [] featureSet: "" + specialHandlingSupportExceptionRequired: + disabled: [] + enabled: [] namedCertificates: - certPath: "" keyPath: "" @@ -168,6 +171,9 @@ apiServer: disabled: [] enabled: [] featureSet: "" + specialHandlingSupportExceptionRequired: + disabled: [] + enabled: [] namedCertificates: - certPath: "" keyPath: "" diff --git a/etcd/vendor/github.com/openshift/microshift/pkg/config/apiserver.go b/etcd/vendor/github.com/openshift/microshift/pkg/config/apiserver.go index 4ed96c2166..54f6b29129 100644 --- a/etcd/vendor/github.com/openshift/microshift/pkg/config/apiserver.go +++ b/etcd/vendor/github.com/openshift/microshift/pkg/config/apiserver.go @@ -139,7 +139,7 @@ const ( FeatureSetDevPreviewNoUpgrade = "DevPreviewNoUpgrade" ) -type CustomNoUpgrade struct { +type EnableDisableFeatures struct { Enabled []string `json:"enabled"` Disabled []string `json:"disabled"` } @@ -149,20 +149,26 @@ type CustomNoUpgrade struct { var RequiredFeatureGates = []string{"UserNamespacesSupport", "UserNamespacesPodSecurityStandards"} type FeatureGates struct { - FeatureSet string `json:"featureSet"` - CustomNoUpgrade CustomNoUpgrade `json:"customNoUpgrade"` + FeatureSet string `json:"featureSet"` + // CustomNoUpgrade is used to enable/disable feature gates. When the enabled or disable lists are not empty, x- and y-stream upgrades will be blocked. + // Use this field exclusively for custom feature gates, unless you are certain that the feature gate is a SpecialHandlingSupportExceptionRequired feature. + CustomNoUpgrade EnableDisableFeatures `json:"customNoUpgrade"` + // SpecialHandlingSupportExceptionRequired allows for feature gates to be exempted from blocking x- and y-stream upgrades. + SpecialHandlingSupportExceptionRequired EnableDisableFeatures `json:"specialHandlingSupportExceptionRequired"` } // ToApiserverArgs converts the FeatureGates struct to a list of feature-gates arguments for the kube-apiserver. // Validation checks should be performed before calling this function to ensure the FeatureGates struct is valid. func (fg FeatureGates) ToApiserverArgs() ([]string, error) { ret := sets.NewString() - for _, feature := range fg.CustomNoUpgrade.Enabled { - ret.Insert(fmt.Sprintf("%s=true", feature)) - } - for _, feature := range fg.CustomNoUpgrade.Disabled { - ret.Insert(fmt.Sprintf("%s=false", feature)) + addFeatures := func(features []string, enabled bool) { + for _, feature := range features { + ret.Insert(fmt.Sprintf("%s=%t", feature, enabled)) + } } + + addFeatures(fg.CustomNoUpgrade.Enabled, true) + addFeatures(fg.CustomNoUpgrade.Disabled, false) return ret.List(), nil } @@ -171,49 +177,53 @@ func (fg FeatureGates) GoString() string { return fmt.Sprintf("FeatureGates{FeatureSet: %q, CustomNoUpgrade: %#v}", fg.FeatureSet, fg.CustomNoUpgrade) } +// validateFeatureGates validates the FeatureGates struct according to the following rules: +// 1. FeatureGates may be unset. +// 2. FeatureSet must be empty or CustomNoUpgrade. +// 3. If FeatureSet is DevPreviewNoUpgrade or TechPreviewNoUpgrade, return an error. +// 4. If FeatureSet is CustomNoUpgrade, CustomNoUpgrade.Enabled/Disabled lists may be set but are not required. +// 5. Required feature gates cannot be disabled. +// 6. Feature gates cannot be both enabled and disabled within the same object. func (fg *FeatureGates) validateFeatureGates() error { - // FG is unset if fg == nil || reflect.DeepEqual(*fg, FeatureGates{}) { return nil } - // FeatureSet must be empty or CustomNoUpgrade. If empty, CustomNoUpgrade.Enabled/Disabled lists must be empty. switch fg.FeatureSet { case "": - if len(fg.CustomNoUpgrade.Enabled) > 0 || len(fg.CustomNoUpgrade.Disabled) > 0 { - return fmt.Errorf("CustomNoUpgrade enabled/disabled lists must be empty when FeatureSet is empty") - } return nil case FeatureSetCustomNoUpgrade: - // Valid - continue to validate enabled/disabled lists below + // Valid - continue with validation case FeatureSetDevPreviewNoUpgrade, FeatureSetTechPreviewNoUpgrade: return fmt.Errorf("FeatureSet %s is not supported. Use CustomNoUpgrade to enable/disable feature gates", fg.FeatureSet) default: return fmt.Errorf("invalid feature set: %s", fg.FeatureSet) } - var errs = make(sets.Set[error], 0) - for _, requiredFG := range RequiredFeatureGates { - // Edge case: Users must not be allowed to explicitly disable required feature gates. - if sets.NewString(fg.CustomNoUpgrade.Disabled...).Has(requiredFG) { - errs.Insert(fmt.Errorf("required feature gate %s cannot be disabled: %s", requiredFG, fg.CustomNoUpgrade.Disabled)) - } - // Edge case: Users must not be allowed to explicitly enable required feature gates or else the config would be locked and the cluster - // would not be able to be upgraded. - if sets.New(fg.CustomNoUpgrade.Enabled...).Has(requiredFG) { - errs.Insert(fmt.Errorf("feature gate %s is explicitly enabled and cannot be enabled by the user", requiredFG)) + enabledCustom := sets.New(fg.CustomNoUpgrade.Enabled...) + disabledCustom := sets.New(fg.CustomNoUpgrade.Disabled...) + + // checkFeatureGateConflict checks if two sets of feature gates have any intersection and returns an error if they do. + checkFeatureGateConflict := func(a, b sets.Set[string], errorMsg string) error { + if intersect := a.Intersection(b); intersect.Len() > 0 { + return fmt.Errorf("%s: %s", errorMsg, intersect.UnsortedList()) } + return nil } - if errs.Len() > 0 { - return fmt.Errorf("invalid feature gates: %s", errs.UnsortedList()) + + conflictChecks := []struct { + setA sets.Set[string] + setB sets.Set[string] + msg string + }{ + {disabledCustom, sets.New(RequiredFeatureGates...), "required feature gates cannot be disabled"}, + {enabledCustom, disabledCustom, "feature gates cannot be both enabled and disabled"}, } - // Must not have any feature gates that are enabled and disabled at the same time - enabledSet := sets.New(fg.CustomNoUpgrade.Enabled...) - disabledSet := sets.New(fg.CustomNoUpgrade.Disabled...) - inBothSets := enabledSet.Intersection(disabledSet) - if inBothSets.Len() > 0 { - return fmt.Errorf("featuregates cannot be enabled and disabled at the same time: %s", inBothSets.UnsortedList()) + for _, check := range conflictChecks { + if err := checkFeatureGateConflict(check.setA, check.setB, check.msg); err != nil { + return err + } } return nil diff --git a/packaging/microshift/config.yaml b/packaging/microshift/config.yaml index 7a20f173ca..bcddbe6db1 100644 --- a/packaging/microshift/config.yaml +++ b/packaging/microshift/config.yaml @@ -15,10 +15,16 @@ apiServer: # profile is the OpenShift profile specifying a specific logging policy profile: Default featureGates: + # CustomNoUpgrade is used to enable/disable feature gates. When the enabled or disable lists are not empty, x- and y-stream upgrades will be blocked. + # Use this field exclusively for custom feature gates, unless you are certain that the feature gate is a SpecialHandlingSupportExceptionRequired feature. customNoUpgrade: disabled: [] enabled: [] featureSet: "" + # SpecialHandlingSupportExceptionRequired allows for feature gates to be exempted from blocking x- and y-stream upgrades. + specialHandlingSupportExceptionRequired: + disabled: [] + enabled: [] # List of custom certificates used to secure requests to specific host names namedCertificates: - certPath: "" diff --git a/pkg/admin/prerun/featuregate_lock.go b/pkg/admin/prerun/featuregate_lock.go index 40e84bd995..096b8ada3e 100644 --- a/pkg/admin/prerun/featuregate_lock.go +++ b/pkg/admin/prerun/featuregate_lock.go @@ -8,6 +8,7 @@ import ( "github.com/openshift/microshift/pkg/config" "github.com/openshift/microshift/pkg/util" + "k8s.io/apimachinery/pkg/util/sets" "k8s.io/klog/v2" "sigs.k8s.io/yaml" ) @@ -22,16 +23,18 @@ var ( // featureGateLockFile represents the structure of the lock file // that tracks custom feature gate configuration and prevents changes/upgrades type featureGateLockFile struct { - FeatureSet string `json:"featureSet"` - CustomNoUpgrade config.CustomNoUpgrade `json:"customNoUpgrade"` - Version versionMetadata `json:"version"` + FeatureSet string `json:"featureSet"` + CustomNoUpgrade config.EnableDisableFeatures `json:"customNoUpgrade"` + Version versionMetadata `json:"version"` } // FeatureGateLockManagement manages the feature gate lock file // that prevents upgrades and config changes when custom feature gates are configured func FeatureGateLockManagement(cfg *config.Config) error { klog.InfoS("START feature gate lock management") - if err := featureGateLockManagement(cfg); err != nil { + + fgCfg := &cfg.ApiServer.FeatureGates + if err := featureGateLockManagement(fgCfg); err != nil { klog.ErrorS(err, "FAIL feature gate lock management") return err } @@ -39,7 +42,7 @@ func FeatureGateLockManagement(cfg *config.Config) error { return nil } -func featureGateLockManagement(cfg *config.Config) error { +func featureGateLockManagement(fgCfg *config.FeatureGates) error { // If a lock file exists, it must be validated regardless of current config // This prevents users from removing feature gates from config in order to block upgrades and configuration changes lockExists, err := util.PathExists(featureGateLockFilePath) @@ -48,18 +51,18 @@ func featureGateLockManagement(cfg *config.Config) error { } // Lock file exists - validate configuration if lockExists { - return runValidationsChecks(cfg) + return runValidationsChecks(fgCfg) } // No lock file exists yet and custom feature gates are configured, so this is the first time configuring custom feature gates - if cfg.ApiServer.FeatureGates.FeatureSet != "" { - return createFeatureGateLockFile(cfg) + if fgCfg.FeatureSet != "" { + return createFeatureGateLockFile(fgCfg) } // No lock file and no custom feature gates - normal operation return nil } // createFeatureGateLockFile creates the lock file with current configuration -func createFeatureGateLockFile(cfg *config.Config) error { +func createFeatureGateLockFile(fgCfg *config.FeatureGates) error { klog.InfoS("Creating feature gate lock file - this cluster can no longer be upgraded", "path", featureGateLockFilePath) @@ -70,8 +73,8 @@ func createFeatureGateLockFile(cfg *config.Config) error { } lockFile := featureGateLockFile{ - FeatureSet: cfg.ApiServer.FeatureGates.FeatureSet, - CustomNoUpgrade: cfg.ApiServer.FeatureGates.CustomNoUpgrade, + FeatureSet: fgCfg.FeatureSet, + CustomNoUpgrade: fgCfg.CustomNoUpgrade, Version: currentVersion, } @@ -88,7 +91,7 @@ func createFeatureGateLockFile(cfg *config.Config) error { // runValidationsChecks validates the feature gate lock file and the current configuration // It returns an error if the configuration is invalid or if an x or y stream version upgrade has occurred. -func runValidationsChecks(cfg *config.Config) error { +func runValidationsChecks(fgCfg *config.FeatureGates) error { klog.InfoS("Validating feature gate lock file", "path", featureGateLockFilePath) lockFile, err := readFeatureGateLockFile(featureGateLockFilePath) @@ -97,7 +100,7 @@ func runValidationsChecks(cfg *config.Config) error { } // Check if feature gate configuration has changed - if err := configValidationChecksPass(lockFile, cfg.ApiServer.FeatureGates); err != nil { + if err := configValidationChecksPass(lockFile, fgCfg); err != nil { return fmt.Errorf("detected invalid changes in feature gate configuration: %w\n\n"+ "To restore MicroShift to a supported state, you must:\n"+ "1. Run: sudo microshift-cleanup-data --all\n"+ @@ -105,37 +108,60 @@ func runValidationsChecks(cfg *config.Config) error { "3. Restart MicroShift: sudo systemctl restart microshift", err) } - // Check if version has changed (upgrade attempted) - currentExecutableVersion, err := getExecutableVersion() - if err != nil { - return fmt.Errorf("failed to get current executable version: %w", err) - } - - if lockFile.Version.Major != currentExecutableVersion.Major || lockFile.Version.Minor != currentExecutableVersion.Minor { - return fmt.Errorf("version upgrade detected with custom feature gates: locked version %s, current version %s\n\n"+ - "Upgrades are not supported when custom feature gates are configured.\n"+ - "Custom feature gates (%s) were configured in version %s.\n"+ - "To restore MicroShift to a supported state, you must:\n"+ - "1. Roll back to version %s, OR\n"+ - "2. Run: sudo microshift-cleanup-data --all\n"+ - "3. Remove custom feature gates from /etc/microshift/config.yaml\n"+ - "4. Restart MicroShift: sudo systemctl restart microshift", - lockFile.Version.String(), currentExecutableVersion.String(), - lockFile.FeatureSet, lockFile.Version.String(), lockFile.Version.String()) + if err := upgradeChecksPass(lockFile, fgCfg); err != nil { + return err } klog.InfoS("Feature gate lock file validation successful") return nil } -func configValidationChecksPass(prev featureGateLockFile, current config.FeatureGates) error { - if prev.FeatureSet != "" && current.FeatureSet == "" { +func configValidationChecksPass(prev featureGateLockFile, fgCfg *config.FeatureGates) error { + if prev.FeatureSet != "" && fgCfg.FeatureSet == "" { // Disallow changing from feature set to no feature set return fmt.Errorf("cannot unset feature set. Previous config had feature set %q, current config has no feature set configured", prev.FeatureSet) } - if prev.FeatureSet == config.FeatureSetCustomNoUpgrade && current.FeatureSet != config.FeatureSetCustomNoUpgrade { + if prev.FeatureSet == config.FeatureSetCustomNoUpgrade && fgCfg.FeatureSet != config.FeatureSetCustomNoUpgrade { // Disallow changing from custom feature gates to any other feature set - return fmt.Errorf("cannot change CustomNoUpgrade feature set. Previous feature set was %q, current feature set is %q", prev.FeatureSet, current.FeatureSet) + return fmt.Errorf("cannot change CustomNoUpgrade feature set. Previous feature set was %q, current feature set is %q", prev.FeatureSet, fgCfg.FeatureSet) + } + return nil +} + +func upgradeChecksPass(lockFile featureGateLockFile, fgCfg *config.FeatureGates) error { + currentExecutableVersion, err := getExecutableVersion() + lockedVersion := lockFile.Version + if err != nil { + return fmt.Errorf("failed to get current executable version: %w", err) + } + + if lockedVersion.Major != currentExecutableVersion.Major || lockedVersion.Minor != currentExecutableVersion.Minor { + extractFeatureGatesWithoutExemptions := func(lhs []string, rhs []string) []string { + lhsSet := sets.New(lhs...) + rhsSet := sets.New(rhs...) + return lhsSet.Difference(rhsSet).UnsortedList() + } + + // Extract feature gates that lack a special handling support exception. + customNoUpgradeEnabled := extractFeatureGatesWithoutExemptions(fgCfg.CustomNoUpgrade.Enabled, fgCfg.SpecialHandlingSupportExceptionRequired.Enabled) + customNoUpgradeDisabled := extractFeatureGatesWithoutExemptions(fgCfg.CustomNoUpgrade.Disabled, fgCfg.SpecialHandlingSupportExceptionRequired.Disabled) + + // If there are any gates that lack a special handling support exception, return an error. + if len(customNoUpgradeEnabled) > 0 || len(customNoUpgradeDisabled) > 0 { + return fmt.Errorf("version upgrade detected with custom feature gates: locked version %s, current version %s\n\n"+ + "Upgrades are not supported when custom feature gates are configured.\n"+ + "Custom feature gates were configured in version %s.\n"+ + "Gates Enabled: %s\n"+ + "Gates Disabled: %s\n"+ + "To restore MicroShift to a supported state, you must:\n"+ + "1. Roll back to version %s, OR\n"+ + "2. Run: sudo microshift-cleanup-data --all\n"+ + "3. Remove custom feature gates from /etc/microshift/config.yaml\n"+ + "4. Restart MicroShift: sudo systemctl restart microshift", + lockedVersion.String(), currentExecutableVersion.String(), + lockedVersion.String(), customNoUpgradeEnabled, + customNoUpgradeDisabled, lockedVersion.String()) + } } return nil } diff --git a/pkg/admin/prerun/featuregate_lock_test.go b/pkg/admin/prerun/featuregate_lock_test.go index 1a9a243dd9..a8c1ac9c81 100644 --- a/pkg/admin/prerun/featuregate_lock_test.go +++ b/pkg/admin/prerun/featuregate_lock_test.go @@ -21,7 +21,7 @@ func TestFeatureGateLockFile_Marshal(t *testing.T) { name: "custom feature gates with enabled and disabled", lockFile: featureGateLockFile{ FeatureSet: config.FeatureSetCustomNoUpgrade, - CustomNoUpgrade: config.CustomNoUpgrade{ + CustomNoUpgrade: config.EnableDisableFeatures{ Enabled: []string{"FeatureA", "FeatureB"}, Disabled: []string{"FeatureC"}, }, @@ -32,7 +32,7 @@ func TestFeatureGateLockFile_Marshal(t *testing.T) { name: "TechPreviewNoUpgrade", lockFile: featureGateLockFile{ FeatureSet: config.FeatureSetTechPreviewNoUpgrade, - CustomNoUpgrade: config.CustomNoUpgrade{}, + CustomNoUpgrade: config.EnableDisableFeatures{}, }, wantErr: false, }, @@ -40,7 +40,7 @@ func TestFeatureGateLockFile_Marshal(t *testing.T) { name: "DevPreviewNoUpgrade", lockFile: featureGateLockFile{ FeatureSet: config.FeatureSetDevPreviewNoUpgrade, - CustomNoUpgrade: config.CustomNoUpgrade{}, + CustomNoUpgrade: config.EnableDisableFeatures{}, }, wantErr: false, }, @@ -48,7 +48,7 @@ func TestFeatureGateLockFile_Marshal(t *testing.T) { name: "empty feature gates", lockFile: featureGateLockFile{ FeatureSet: "", - CustomNoUpgrade: config.CustomNoUpgrade{}, + CustomNoUpgrade: config.EnableDisableFeatures{}, }, wantErr: false, }, @@ -85,7 +85,7 @@ func TestIsCustomFeatureGatesConfigured(t *testing.T) { name: "CustomNoUpgrade with enabled features", fg: config.FeatureGates{ FeatureSet: config.FeatureSetCustomNoUpgrade, - CustomNoUpgrade: config.CustomNoUpgrade{ + CustomNoUpgrade: config.EnableDisableFeatures{ Enabled: []string{"FeatureA"}, }, }, @@ -110,15 +110,15 @@ func TestIsCustomFeatureGatesConfigured(t *testing.T) { fg: config.FeatureGates{ FeatureSet: "", }, - want: false, + want: true, // validation passes when prev and current both have no feature set }, { name: "CustomNoUpgrade without any enabled/disabled", fg: config.FeatureGates{ FeatureSet: config.FeatureSetCustomNoUpgrade, - CustomNoUpgrade: config.CustomNoUpgrade{}, + CustomNoUpgrade: config.EnableDisableFeatures{}, }, - want: false, + want: true, // validation passes when prev and current match }, } @@ -127,12 +127,10 @@ func TestIsCustomFeatureGatesConfigured(t *testing.T) { err := configValidationChecksPass(featureGateLockFile{ FeatureSet: tt.fg.FeatureSet, CustomNoUpgrade: tt.fg.CustomNoUpgrade, - }, tt.fg) - if err != nil { - t.Errorf("featureValidationsPass() error = %v", err) - } - if err != nil { - t.Errorf("featureValidationsPass() error = %v", err) + }, &tt.fg) + got := err == nil + if got != tt.want { + t.Errorf("configValidationChecksPass() got pass = %v, want %v (err = %v)", got, tt.want, err) } }) } @@ -156,7 +154,7 @@ func TestFeatureGateLockFile_ReadWrite(t *testing.T) { name: "write and read custom feature gates", lockFile: featureGateLockFile{ FeatureSet: config.FeatureSetCustomNoUpgrade, - CustomNoUpgrade: config.CustomNoUpgrade{ + CustomNoUpgrade: config.EnableDisableFeatures{ Enabled: []string{"FeatureA", "FeatureB"}, Disabled: []string{"FeatureC"}, }, @@ -242,7 +240,7 @@ func TestConfigValidationChecksPass(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := configValidationChecksPass(tt.lockFile, tt.current) + err := configValidationChecksPass(tt.lockFile, &tt.current) if (err != nil) != tt.wantErr { t.Errorf("configValidationChecksPass() error = %v, wantErr %v", err, tt.wantErr) } @@ -277,7 +275,7 @@ func TestFeatureGateLockManagement_FirstRun(t *testing.T) { ApiServer: config.ApiServer{ FeatureGates: config.FeatureGates{ FeatureSet: config.FeatureSetCustomNoUpgrade, - CustomNoUpgrade: config.CustomNoUpgrade{ + CustomNoUpgrade: config.EnableDisableFeatures{ Enabled: []string{"FeatureA"}, }, }, @@ -326,7 +324,7 @@ func TestFeatureGateLockManagement_ConfigChange(t *testing.T) { // Create lockFile file with initial config (CustomNoUpgrade feature set) initialLock := featureGateLockFile{ FeatureSet: config.FeatureSetCustomNoUpgrade, - CustomNoUpgrade: config.CustomNoUpgrade{ + CustomNoUpgrade: config.EnableDisableFeatures{ Enabled: []string{"FeatureA"}, }, Version: testVersion, @@ -365,18 +363,24 @@ func TestFeatureGateLockManagement_VersionChange(t *testing.T) { } tests := []struct { - name string - lockFileVer versionMetadata - currentVer versionMetadata - wantErr bool - description string + name string + lockFileVer versionMetadata + currentVer versionMetadata + customNoUpgrade *config.EnableDisableFeatures + specialHandlingSupportException *config.EnableDisableFeatures + wantErr bool + description string }{ { - name: "minor version upgrade should fail", - lockFileVer: getVersion(0, 0, 0), - currentVer: getVersion(0, 1, 0), - wantErr: true, - description: "Minor version upgrade (4.21.0 -> 4.22.0) should be blocked", + name: "minor version upgrade should fail", + lockFileVer: getVersion(0, 0, 0), + currentVer: getVersion(0, 1, 0), + wantErr: true, + specialHandlingSupportException: &config.EnableDisableFeatures{}, + description: "Minor version upgrade (4.21.0 -> 4.22.0) should be blocked", + customNoUpgrade: &config.EnableDisableFeatures{ + Enabled: []string{"FeatureA"}, + }, }, { name: "major version upgrade should fail", @@ -384,6 +388,10 @@ func TestFeatureGateLockManagement_VersionChange(t *testing.T) { currentVer: getVersion(1, 0, 0), wantErr: true, description: "Major version upgrade (4.21.0 -> 5.0.0) should be blocked", + customNoUpgrade: &config.EnableDisableFeatures{ + Enabled: []string{"FeatureA"}, + }, + specialHandlingSupportException: &config.EnableDisableFeatures{}, }, { name: "patch version change should succeed", @@ -391,6 +399,10 @@ func TestFeatureGateLockManagement_VersionChange(t *testing.T) { currentVer: getVersion(0, 0, 1), wantErr: false, description: "Patch version change (4.21.0 -> 4.21.1) should be allowed", + customNoUpgrade: &config.EnableDisableFeatures{ + Enabled: []string{"FeatureA"}, + }, + specialHandlingSupportException: &config.EnableDisableFeatures{}, }, { name: "same version should succeed", @@ -398,6 +410,10 @@ func TestFeatureGateLockManagement_VersionChange(t *testing.T) { currentVer: getVersion(0, 0, 0), wantErr: false, description: "Same version (4.21.0 -> 4.21.0) should be allowed", + customNoUpgrade: &config.EnableDisableFeatures{ + Enabled: []string{"FeatureA"}, + }, + specialHandlingSupportException: &config.EnableDisableFeatures{}, }, { name: "minor version downgrade should fail", @@ -405,6 +421,10 @@ func TestFeatureGateLockManagement_VersionChange(t *testing.T) { currentVer: getVersion(0, 0, 0), wantErr: true, description: "Minor version downgrade (4.22.0 -> 4.21.0) should be blocked", + customNoUpgrade: &config.EnableDisableFeatures{ + Enabled: []string{"FeatureA"}, + }, + specialHandlingSupportException: &config.EnableDisableFeatures{}, }, { name: "major version downgrade should fail", @@ -412,6 +432,36 @@ func TestFeatureGateLockManagement_VersionChange(t *testing.T) { currentVer: getVersion(0, 0, 0), wantErr: true, description: "Major version downgrade (5.0.0 -> 4.21.0) should be blocked", + customNoUpgrade: &config.EnableDisableFeatures{ + Enabled: []string{"FeatureA"}, + }, + specialHandlingSupportException: &config.EnableDisableFeatures{}, + }, + { + name: "major version upgrade with special handling support exception should succeed", + lockFileVer: getVersion(0, 0, 0), + currentVer: getVersion(1, -21, 0), + wantErr: false, + description: "major version upgrade (4.21.0 -> 5.0.0) with special handling support exception should succeed", + customNoUpgrade: &config.EnableDisableFeatures{ + Enabled: []string{"FeatureA"}, + }, + specialHandlingSupportException: &config.EnableDisableFeatures{ + Enabled: []string{"FeatureA"}, + }, + }, + { + name: "minor version upgrade with special handling support exception should succeed", + lockFileVer: getVersion(0, 0, 0), + currentVer: getVersion(0, 1, 0), + wantErr: false, + description: "minor version upgrade (4.21.0 -> 4.22.0) with special handling support exception should succeed", + customNoUpgrade: &config.EnableDisableFeatures{ + Enabled: []string{"FeatureA"}, + }, + specialHandlingSupportException: &config.EnableDisableFeatures{ + Enabled: []string{"FeatureA"}, + }, }, } @@ -436,13 +486,19 @@ func TestFeatureGateLockManagement_VersionChange(t *testing.T) { } defer func() { getExecutableVersion = originalGetExecutableVersion }() - // Create lockFile file with locked version + // Create lockFile file with locked version. Lock file does not store the special handling support exception. + customNoUpgrade := config.EnableDisableFeatures{} + if tt.customNoUpgrade != nil { + customNoUpgrade = *tt.customNoUpgrade + } + specialHandling := config.EnableDisableFeatures{} + if tt.specialHandlingSupportException != nil { + specialHandling = *tt.specialHandlingSupportException + } lockFile := featureGateLockFile{ - FeatureSet: config.FeatureSetCustomNoUpgrade, - CustomNoUpgrade: config.CustomNoUpgrade{ - Enabled: []string{"FeatureA"}, - }, - Version: tt.lockFileVer, + FeatureSet: config.FeatureSetCustomNoUpgrade, + CustomNoUpgrade: customNoUpgrade, + Version: tt.lockFileVer, } if err := writeFeatureGateLockFile(featureGateLockFilePath, lockFile); err != nil { t.Fatal(err) @@ -451,10 +507,9 @@ func TestFeatureGateLockManagement_VersionChange(t *testing.T) { cfg := &config.Config{ ApiServer: config.ApiServer{ FeatureGates: config.FeatureGates{ - FeatureSet: config.FeatureSetCustomNoUpgrade, - CustomNoUpgrade: config.CustomNoUpgrade{ - Enabled: []string{"FeatureA"}, - }, + FeatureSet: config.FeatureSetCustomNoUpgrade, + CustomNoUpgrade: customNoUpgrade, + SpecialHandlingSupportExceptionRequired: specialHandling, }, }, } diff --git a/pkg/config/apiserver.go b/pkg/config/apiserver.go index e8235988ff..54f6b29129 100644 --- a/pkg/config/apiserver.go +++ b/pkg/config/apiserver.go @@ -139,7 +139,7 @@ const ( FeatureSetDevPreviewNoUpgrade = "DevPreviewNoUpgrade" ) -type CustomNoUpgrade struct { +type EnableDisableFeatures struct { Enabled []string `json:"enabled"` Disabled []string `json:"disabled"` } @@ -149,8 +149,12 @@ type CustomNoUpgrade struct { var RequiredFeatureGates = []string{"UserNamespacesSupport", "UserNamespacesPodSecurityStandards"} type FeatureGates struct { - FeatureSet string `json:"featureSet"` - CustomNoUpgrade CustomNoUpgrade `json:"customNoUpgrade"` + FeatureSet string `json:"featureSet"` + // CustomNoUpgrade is used to enable/disable feature gates. When the enabled or disable lists are not empty, x- and y-stream upgrades will be blocked. + // Use this field exclusively for custom feature gates, unless you are certain that the feature gate is a SpecialHandlingSupportExceptionRequired feature. + CustomNoUpgrade EnableDisableFeatures `json:"customNoUpgrade"` + // SpecialHandlingSupportExceptionRequired allows for feature gates to be exempted from blocking x- and y-stream upgrades. + SpecialHandlingSupportExceptionRequired EnableDisableFeatures `json:"specialHandlingSupportExceptionRequired"` } // ToApiserverArgs converts the FeatureGates struct to a list of feature-gates arguments for the kube-apiserver.