Skip to content

Commit 53ce73d

Browse files
Merge pull request openshift#7413 from patrickdillon/fg-val
CORS-2877: Install Config Feature Gate Validation
2 parents e7b2f51 + 00a99cb commit 53ce73d

File tree

8 files changed

+380
-74
lines changed

8 files changed

+380
-74
lines changed

pkg/asset/manifests/featuregate.go

Lines changed: 2 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ package manifests
22

33
import (
44
"path/filepath"
5-
"strconv"
6-
"strings"
75

86
"github.com/pkg/errors"
97
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -12,6 +10,7 @@ import (
1210
configv1 "github.com/openshift/api/config/v1"
1311
"github.com/openshift/installer/pkg/asset"
1412
"github.com/openshift/installer/pkg/asset/installconfig"
13+
"github.com/openshift/installer/pkg/types/featuregates"
1514
)
1615

1716
var fgFileName = filepath.Join(openshiftManifestDir, "99_feature-gate.yaml")
@@ -62,10 +61,7 @@ func (f *FeatureGate) Generate(dependencies asset.Parents) error {
6261
return errors.Errorf("custom features can only be used with the CustomNoUpgrade feature set")
6362
}
6463

65-
customFeatures, err := generateCustomFeatures(installConfig.Config.FeatureGates)
66-
if err != nil {
67-
return errors.Wrapf(err, "failed to generate custom features")
68-
}
64+
customFeatures := featuregates.GenerateCustomFeatures(installConfig.Config.FeatureGates)
6965
f.Config.Spec.CustomNoUpgrade = customFeatures
7066
}
7167

@@ -93,45 +89,3 @@ func (f *FeatureGate) Files() []*asset.File {
9389
func (f *FeatureGate) Load(ff asset.FileFetcher) (bool, error) {
9490
return false, nil
9591
}
96-
97-
// generateCustomFeatures generates the custom feature gates from the install config.
98-
func generateCustomFeatures(features []string) (*configv1.CustomFeatureGates, error) {
99-
customFeatures := &configv1.CustomFeatureGates{}
100-
101-
for _, feature := range features {
102-
featureName, enabled, err := parseCustomFeatureGate(feature)
103-
if err != nil {
104-
return nil, errors.Wrapf(err, "failed to parse custom feature %s", feature)
105-
}
106-
107-
if enabled {
108-
customFeatures.Enabled = append(customFeatures.Enabled, featureName)
109-
} else {
110-
customFeatures.Disabled = append(customFeatures.Disabled, featureName)
111-
}
112-
}
113-
114-
return customFeatures, nil
115-
}
116-
117-
// parseCustomFeatureGates parses the custom feature gate string into the feature name and whether it is enabled.
118-
// The expected format is <FeatureName>=<Enabled>.
119-
func parseCustomFeatureGate(rawFeature string) (configv1.FeatureGateName, bool, error) {
120-
var featureName string
121-
var enabled bool
122-
123-
featureParts := strings.Split(rawFeature, "=")
124-
if len(featureParts) != 2 {
125-
return "", false, errors.Errorf("feature not in expected format %s", rawFeature)
126-
}
127-
128-
featureName = featureParts[0]
129-
130-
var err error
131-
enabled, err = strconv.ParseBool(featureParts[1])
132-
if err != nil {
133-
return "", false, errors.Wrapf(err, "feature not in expected format %s, could not parse boolean value", rawFeature)
134-
}
135-
136-
return configv1.FeatureGateName(featureName), enabled, nil
137-
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package featuregates
2+
3+
// source: https://github.com/openshift/library-go/blob/c515269de16e5e239bd6e93e1f9821a976bb460b/pkg/operator/configobserver/featuregates/featuregate.go#L23-L28
4+
5+
import (
6+
"fmt"
7+
8+
"k8s.io/apimachinery/pkg/util/sets"
9+
"k8s.io/apimachinery/pkg/util/validation/field"
10+
11+
configv1 "github.com/openshift/api/config/v1"
12+
)
13+
14+
// FeatureGate indicates whether a given feature is enabled or not
15+
// This interface is heavily influenced by k8s.io/component-base, but not exactly compatible.
16+
type FeatureGate interface {
17+
// Enabled returns true if the key is enabled.
18+
Enabled(key configv1.FeatureGateName) bool
19+
}
20+
21+
type featureGate struct {
22+
enabled sets.Set[configv1.FeatureGateName]
23+
disabled sets.Set[configv1.FeatureGateName]
24+
}
25+
26+
// GatedInstallConfigFeature contains fields that will be used to validate
27+
// that required feature gates are enabled when gated install config fields
28+
// are used.
29+
// FeatureGateName: openshift/api feature gate required to enable the use of Field
30+
// Condition: the check which determines whether the install config field is used,
31+
// if Condition evaluates to True, FeatureGateName must be enabled to pass validation.
32+
// Field: the gated install config field, Field is used in the error message.
33+
type GatedInstallConfigFeature struct {
34+
FeatureGateName configv1.FeatureGateName
35+
Condition bool
36+
Field *field.Path
37+
}
38+
39+
func newFeatureGate(enabled, disabled []configv1.FeatureGateName) FeatureGate {
40+
return &featureGate{
41+
enabled: sets.New[configv1.FeatureGateName](enabled...),
42+
disabled: sets.New[configv1.FeatureGateName](disabled...),
43+
}
44+
}
45+
46+
// Enabled returns true if the provided feature gate is enabled
47+
// in the active feature sets.
48+
func (f *featureGate) Enabled(key configv1.FeatureGateName) bool {
49+
if f.enabled.Has(key) {
50+
return true
51+
}
52+
if f.disabled.Has(key) {
53+
return false
54+
}
55+
56+
panic(fmt.Errorf("feature %q is not registered in FeatureGates", key))
57+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package featuregates
2+
3+
// source: https://github.com/openshift/cluster-config-operator/blob/636a2dc303037e2561a243ae1ab5c5b953ddad04/pkg/cmd/render/render.go#L153
4+
5+
import (
6+
"strconv"
7+
"strings"
8+
9+
"k8s.io/apimachinery/pkg/util/sets"
10+
11+
configv1 "github.com/openshift/api/config/v1"
12+
)
13+
14+
func toFeatureGateNames(in []configv1.FeatureGateDescription) []configv1.FeatureGateName {
15+
out := []configv1.FeatureGateName{}
16+
for _, curr := range in {
17+
out = append(out, curr.FeatureGateAttributes.Name)
18+
}
19+
20+
return out
21+
}
22+
23+
// completeFeatureGates identifies every known feature and ensures that is explicitly on or explicitly off.
24+
func completeFeatureGates(knownFeatureSets map[configv1.FeatureSet]*configv1.FeatureGateEnabledDisabled, enabled, disabled []configv1.FeatureGateName) ([]configv1.FeatureGateName, []configv1.FeatureGateName) {
25+
specificallyEnabledFeatureGates := sets.New[configv1.FeatureGateName]()
26+
specificallyEnabledFeatureGates.Insert(enabled...)
27+
28+
knownFeatureGates := sets.New[configv1.FeatureGateName]()
29+
knownFeatureGates.Insert(enabled...)
30+
knownFeatureGates.Insert(disabled...)
31+
for _, known := range knownFeatureSets {
32+
for _, curr := range known.Disabled {
33+
knownFeatureGates.Insert(curr.FeatureGateAttributes.Name)
34+
}
35+
for _, curr := range known.Enabled {
36+
knownFeatureGates.Insert(curr.FeatureGateAttributes.Name)
37+
}
38+
}
39+
40+
return enabled, knownFeatureGates.Difference(specificallyEnabledFeatureGates).UnsortedList()
41+
}
42+
43+
// FeatureGateFromFeatureSets creates a FeatureGate from the active feature sets.
44+
func FeatureGateFromFeatureSets(knownFeatureSets map[configv1.FeatureSet]*configv1.FeatureGateEnabledDisabled, fs configv1.FeatureSet, customFS *configv1.CustomFeatureGates) FeatureGate {
45+
if customFS != nil {
46+
completeEnabled, completeDisabled := completeFeatureGates(knownFeatureSets, customFS.Enabled, customFS.Disabled)
47+
return newFeatureGate(completeEnabled, completeDisabled)
48+
}
49+
50+
featureSet := knownFeatureSets[fs]
51+
52+
completeEnabled, completeDisabled := completeFeatureGates(knownFeatureSets, toFeatureGateNames(featureSet.Enabled), toFeatureGateNames(featureSet.Disabled))
53+
return newFeatureGate(completeEnabled, completeDisabled)
54+
}
55+
56+
// GenerateCustomFeatures generates the custom feature gates from the install config.
57+
func GenerateCustomFeatures(features []string) *configv1.CustomFeatureGates {
58+
customFeatures := &configv1.CustomFeatureGates{}
59+
60+
for _, feature := range features {
61+
featureName, enabled := parseCustomFeatureGate(feature)
62+
63+
if enabled {
64+
customFeatures.Enabled = append(customFeatures.Enabled, featureName)
65+
} else {
66+
customFeatures.Disabled = append(customFeatures.Disabled, featureName)
67+
}
68+
}
69+
70+
return customFeatures
71+
}
72+
73+
// parseCustomFeatureGates parses the custom feature gate string into the feature name and whether it is enabled.
74+
// The expected format is <FeatureName>=<Enabled>.
75+
func parseCustomFeatureGate(rawFeature string) (configv1.FeatureGateName, bool) {
76+
var featureName string
77+
var enabled bool
78+
79+
featureParts := strings.Split(rawFeature, "=")
80+
if len(featureParts) != 2 {
81+
return "", false
82+
}
83+
84+
featureName = featureParts[0]
85+
86+
var err error
87+
enabled, err = strconv.ParseBool(featureParts[1])
88+
if err != nil {
89+
return "", false
90+
}
91+
92+
return configv1.FeatureGateName(featureName), enabled
93+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package validation
2+
3+
import (
4+
"k8s.io/apimachinery/pkg/util/validation/field"
5+
6+
configv1 "github.com/openshift/api/config/v1"
7+
"github.com/openshift/installer/pkg/types"
8+
"github.com/openshift/installer/pkg/types/featuregates"
9+
)
10+
11+
// GatedFeatures determines all of the install config fields that should
12+
// be validated to ensure that the proper featuregate is enabled when the field is used.
13+
func GatedFeatures(c *types.InstallConfig) []featuregates.GatedInstallConfigFeature {
14+
g := c.GCP
15+
return []featuregates.GatedInstallConfigFeature{
16+
{
17+
FeatureGateName: configv1.FeatureGateGCPLabelsTags,
18+
Condition: len(g.UserLabels) > 0,
19+
Field: field.NewPath("platform", "gcp", "userLabels"),
20+
},
21+
{
22+
FeatureGateName: configv1.FeatureGateGCPLabelsTags,
23+
Condition: len(g.UserTags) > 0,
24+
Field: field.NewPath("platform", "gcp", "userTags"),
25+
},
26+
}
27+
}

pkg/types/installconfig.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/openshift/installer/pkg/types/azure"
1414
"github.com/openshift/installer/pkg/types/baremetal"
1515
"github.com/openshift/installer/pkg/types/external"
16+
"github.com/openshift/installer/pkg/types/featuregates"
1617
"github.com/openshift/installer/pkg/types/gcp"
1718
"github.com/openshift/installer/pkg/types/ibmcloud"
1819
"github.com/openshift/installer/pkg/types/libvirt"
@@ -516,3 +517,17 @@ func (c *InstallConfig) WorkerMachinePool() *MachinePool {
516517

517518
return nil
518519
}
520+
521+
// EnabledFeatureGates returns a FeatureGate that can be checked (using the Enabled function)
522+
// to determine if a feature gate is enabled in the current feature sets.
523+
func (c *InstallConfig) EnabledFeatureGates() featuregates.FeatureGate {
524+
var customFS *configv1.CustomFeatureGates
525+
526+
if c.FeatureSet == configv1.CustomNoUpgrade {
527+
customFS = featuregates.GenerateCustomFeatures(c.FeatureGates)
528+
}
529+
530+
fg := featuregates.FeatureGateFromFeatureSets(configv1.FeatureSets, c.FeatureSet, customFS)
531+
532+
return fg
533+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package validation
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
8+
v1 "github.com/openshift/api/config/v1"
9+
"github.com/openshift/installer/pkg/types"
10+
"github.com/openshift/installer/pkg/types/gcp"
11+
"github.com/openshift/installer/pkg/types/vsphere"
12+
)
13+
14+
func TestFeatureGates(t *testing.T) {
15+
cases := []struct {
16+
name string
17+
installConfig *types.InstallConfig
18+
expected string
19+
}{
20+
{
21+
name: "GCP UserTags is allowed with Feature Gates enabled",
22+
installConfig: func() *types.InstallConfig {
23+
c := validInstallConfig()
24+
c.FeatureSet = v1.TechPreviewNoUpgrade
25+
c.GCP = validGCPPlatform()
26+
c.GCP.UserTags = []gcp.UserTag{{ParentID: "a", Key: "b", Value: "c"}}
27+
return c
28+
}(),
29+
},
30+
{
31+
name: "GCP UserTags is not allowed without Feature Gates",
32+
installConfig: func() *types.InstallConfig {
33+
c := validInstallConfig()
34+
c.GCP = validGCPPlatform()
35+
c.GCP.UserTags = []gcp.UserTag{{ParentID: "a", Key: "b", Value: "c"}}
36+
return c
37+
}(),
38+
expected: `^platform.gcp.userTags: Forbidden: this field is protected by the GCPLabelsTags feature gate which must be enabled through either the TechPreviewNoUpgrade or CustomNoUpgrade feature set$`,
39+
},
40+
{
41+
name: "GCP UserLabels is allowed with Feature Gates enabled",
42+
installConfig: func() *types.InstallConfig {
43+
c := validInstallConfig()
44+
c.FeatureSet = v1.TechPreviewNoUpgrade
45+
c.GCP = validGCPPlatform()
46+
c.GCP.UserLabels = []gcp.UserLabel{{Key: "a", Value: "b"}}
47+
return c
48+
}(),
49+
},
50+
{
51+
name: "GCP UserLabels is not allowed without Feature Gates",
52+
installConfig: func() *types.InstallConfig {
53+
c := validInstallConfig()
54+
c.GCP = validGCPPlatform()
55+
c.GCP.UserLabels = []gcp.UserLabel{{Key: "a", Value: "b"}}
56+
return c
57+
}(),
58+
expected: `^platform.gcp.userLabels: Forbidden: this field is protected by the GCPLabelsTags feature gate which must be enabled through either the TechPreviewNoUpgrade or CustomNoUpgrade feature set$`,
59+
},
60+
{
61+
name: "vSphere hosts is allowed with Feature Gates enabled",
62+
installConfig: func() *types.InstallConfig {
63+
c := validInstallConfig()
64+
c.FeatureSet = v1.TechPreviewNoUpgrade
65+
c.VSphere = validVSpherePlatform()
66+
c.VSphere.Hosts = []*vsphere.Host{{Role: "test"}}
67+
return c
68+
}(),
69+
},
70+
{
71+
name: "vSphere hosts is not allowed without Feature Gates",
72+
installConfig: func() *types.InstallConfig {
73+
c := validInstallConfig()
74+
c.VSphere = validVSpherePlatform()
75+
c.VSphere.Hosts = []*vsphere.Host{{Role: "test"}}
76+
return c
77+
}(),
78+
expected: `^platform.vsphere.hosts: Forbidden: this field is protected by the VSphereStaticIPs feature gate which must be enabled through either the TechPreviewNoUpgrade or CustomNoUpgrade feature set$`,
79+
},
80+
{
81+
name: "vSphere hosts is allowed with custom Feature Gates",
82+
installConfig: func() *types.InstallConfig {
83+
c := validInstallConfig()
84+
c.FeatureSet = v1.CustomNoUpgrade
85+
c.FeatureGates = []string{"VSphereStaticIPs=true"}
86+
c.VSphere = validVSpherePlatform()
87+
c.VSphere.Hosts = []*vsphere.Host{{Role: "test"}}
88+
return c
89+
}(),
90+
},
91+
{
92+
name: "vSphere hosts is not allowed with custom Feature Gate disabled",
93+
installConfig: func() *types.InstallConfig {
94+
c := validInstallConfig()
95+
c.FeatureSet = v1.CustomNoUpgrade
96+
c.FeatureGates = []string{"VSphereStaticIPs=false"}
97+
c.VSphere = validVSpherePlatform()
98+
c.VSphere.Hosts = []*vsphere.Host{{Role: "test"}}
99+
return c
100+
}(),
101+
expected: `^platform.vsphere.hosts: Forbidden: this field is protected by the VSphereStaticIPs feature gate which must be enabled through either the TechPreviewNoUpgrade or CustomNoUpgrade feature set$`,
102+
},
103+
}
104+
105+
for _, tc := range cases {
106+
t.Run(tc.name, func(t *testing.T) {
107+
err := ValidateFeatureSet(tc.installConfig).ToAggregate()
108+
if tc.expected == "" {
109+
assert.NoError(t, err)
110+
} else {
111+
assert.Regexp(t, tc.expected, err)
112+
}
113+
})
114+
}
115+
}

0 commit comments

Comments
 (0)