diff --git a/pkg/manifest/manifest.go b/pkg/manifest/manifest.go index 820277f79b..394f7ba4d2 100644 --- a/pkg/manifest/manifest.go +++ b/pkg/manifest/manifest.go @@ -6,7 +6,9 @@ import ( "io" "os" "path/filepath" + "strings" + configv1 "github.com/openshift/api/config/v1" "github.com/pkg/errors" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" @@ -15,6 +17,11 @@ import ( "k8s.io/client-go/kubernetes/scheme" ) +const ( + CapabilityAnnotation = "capability.openshift.io/name" + DefaultClusterProfile = "self-managed-high-availability" +) + // resourceId uniquely identifies a Kubernetes resource. // It is used to identify any duplicate resources within // a given set of manifests. @@ -98,6 +105,97 @@ func (m *Manifest) UnmarshalJSON(in []byte) error { return validateResourceId(m.id) } +// Include returns an error if the manifest fails an inclusion filter and should not be excluded from futher +// processing by cluster version operator. Pointer arguments can be set nil to avoid excluding based on that +// filter. For example, setting profile non-nil and capabilities nil will return an error if the manifest's +// profile does not match, but will never return an error about capability issues. +func (m *Manifest) Include(excludeIdentifier *string, includeTechPreview bool, profile *string, capabilities *configv1.ClusterVersionCapabilitiesStatus) error { + + annotations := m.Obj.GetAnnotations() + if annotations == nil { + return fmt.Errorf("no annotations") + } + + if excludeIdentifier != nil { + excludeAnnotation := fmt.Sprintf("exclude.release.openshift.io/%s", *excludeIdentifier) + if v := annotations[excludeAnnotation]; v == "true" { + return fmt.Errorf("%s=%s", excludeAnnotation, v) + } + } + + featureGateAnnotation := "release.openshift.io/feature-gate" + featureGateAnnotationValue, featureGateAnnotationExists := annotations[featureGateAnnotation] + if featureGateAnnotationValue == string(configv1.TechPreviewNoUpgrade) && !includeTechPreview { + return fmt.Errorf("tech-preview excluded, and %s=%s", featureGateAnnotation, featureGateAnnotationValue) + } + // never include the manifest if the feature-gate annotation is outside of allowed values (only TechPreviewNoUpgrade is currently allowed) + if featureGateAnnotationExists && featureGateAnnotationValue != string(configv1.TechPreviewNoUpgrade) { + return fmt.Errorf("unrecognized value %s=%s", featureGateAnnotation, featureGateAnnotationValue) + } + + if profile != nil { + profileAnnotation := fmt.Sprintf("include.release.openshift.io/%s", *profile) + if val, ok := annotations[profileAnnotation]; ok && val != "true" { + return fmt.Errorf("unrecognized value %s=%s", profileAnnotation, val) + } else if !ok { + return fmt.Errorf("%s unset", profileAnnotation) + } + } + + // If there is no capabilities defined in a release then we do not need to check presence of capabilities in the manifest + if capabilities != nil { + return checkResourceEnablement(annotations, capabilities) + } + return nil +} + +// checkResourceEnablement, given resource annotations and defined cluster capabilities, checks if the capability +// annotation exists. If so, each capability name is validated against the known set of capabilities. Each valid +// capability is then checked if it is disabled. If any invalid capabilities are found an error is returned listing +// all invalid capabilities. Otherwise, if any disabled capabilities are found an error is returned listing all +// disabled capabilities. +func checkResourceEnablement(annotations map[string]string, capabilities *configv1.ClusterVersionCapabilitiesStatus) error { + val, ok := annotations[CapabilityAnnotation] + if !ok { + return nil + } + caps := strings.Split(val, "+") + numCaps := len(caps) + unknownCaps := make([]string, 0, numCaps) + disabledCaps := make([]string, 0, numCaps) + + var isKnownCap bool = false + var isEnabledCap bool = false + + for _, c := range caps { + for _, knownCapability := range capabilities.KnownCapabilities { + if c == string(knownCapability) { + isKnownCap = true + } + } + if !isKnownCap { + unknownCaps = append(unknownCaps, c) + continue + } + for _, enabledCapability := range capabilities.EnabledCapabilities { + if c == string(enabledCapability) { + isEnabledCap = true + } + + } + if !isEnabledCap { + disabledCaps = append(disabledCaps, c) + } + } + if len(unknownCaps) > 0 { + return fmt.Errorf("unrecognized capability names: %s", strings.Join(unknownCaps, ", ")) + } + if len(disabledCaps) > 0 { + return fmt.Errorf("disabled capabilities: %s", strings.Join(disabledCaps, ", ")) + } + return nil +} + // ManifestsFromFiles reads files and returns Manifests in the same order. // 'files' should be list of absolute paths for the manifests on disk. An // error is returned for each manifest that defines a duplicate resource diff --git a/pkg/manifest/manifest_test.go b/pkg/manifest/manifest_test.go index b5cd71df14..d42836e050 100644 --- a/pkg/manifest/manifest_test.go +++ b/pkg/manifest/manifest_test.go @@ -2,6 +2,7 @@ package manifest import ( "flag" + "fmt" "io/ioutil" "os" "path/filepath" @@ -10,6 +11,9 @@ import ( "testing" "github.com/davecgh/go-spew/spew" + configv1 "github.com/openshift/api/config/v1" + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/klog/v2" ) @@ -581,3 +585,134 @@ func setupTestFS(t *testing.T, d dir) (string, func() error) { } return root, cleanup } + +func Test_include(t *testing.T) { + tests := []struct { + name string + exclude string + includeTechPreview bool + profile string + annotations map[string]interface{} + caps configv1.ClusterVersionCapabilitiesStatus + + expected error + }{ + { + name: "exclusion identifier set", + exclude: "identifier", + profile: DefaultClusterProfile, + annotations: map[string]interface{}{ + "exclude.release.openshift.io/identifier": "true", + "include.release.openshift.io/self-managed-high-availability": "true"}, + expected: fmt.Errorf("exclude.release.openshift.io/identifier=true"), + }, + { + name: "profile selection works", + profile: "single-node", + annotations: map[string]interface{}{"include.release.openshift.io/self-managed-high-availability": "true"}, + expected: fmt.Errorf("include.release.openshift.io/single-node unset"), + }, + { + name: "profile selection works included", + profile: DefaultClusterProfile, + annotations: map[string]interface{}{"include.release.openshift.io/self-managed-high-availability": "true"}, + }, + { + name: "correct techpreview value is excluded if techpreview off", + profile: DefaultClusterProfile, + annotations: map[string]interface{}{ + "include.release.openshift.io/self-managed-high-availability": "true", + "release.openshift.io/feature-gate": "TechPreviewNoUpgrade", + }, + expected: fmt.Errorf("tech-preview excluded, and release.openshift.io/feature-gate=TechPreviewNoUpgrade"), + }, + { + name: "correct techpreview value is included if techpreview on", + includeTechPreview: true, + profile: DefaultClusterProfile, + annotations: map[string]interface{}{ + "include.release.openshift.io/self-managed-high-availability": "true", + "release.openshift.io/feature-gate": "TechPreviewNoUpgrade", + }, + }, + { + name: "incorrect techpreview value is not excluded if techpreview off", + profile: DefaultClusterProfile, + annotations: map[string]interface{}{ + "include.release.openshift.io/self-managed-high-availability": "true", + "release.openshift.io/feature-gate": "Other", + }, + expected: fmt.Errorf("unrecognized value release.openshift.io/feature-gate=Other"), + }, + { + name: "incorrect techpreview value is not excluded if techpreview on", + includeTechPreview: true, + profile: DefaultClusterProfile, + annotations: map[string]interface{}{ + "include.release.openshift.io/self-managed-high-availability": "true", + "release.openshift.io/feature-gate": "Other", + }, + expected: fmt.Errorf("unrecognized value release.openshift.io/feature-gate=Other"), + }, + { + name: "default profile selection excludes without annotation", + profile: DefaultClusterProfile, + annotations: map[string]interface{}{}, + expected: fmt.Errorf("include.release.openshift.io/self-managed-high-availability unset"), + }, + { + name: "default profile selection excludes with no annotation", + profile: DefaultClusterProfile, + annotations: nil, + expected: fmt.Errorf("no annotations"), + }, + { + name: "unrecognized capability works", + profile: DefaultClusterProfile, + annotations: map[string]interface{}{ + "include.release.openshift.io/self-managed-high-availability": "true", + CapabilityAnnotation: "cap1"}, + expected: fmt.Errorf("unrecognized capability names: cap1"), + }, + { + name: "disabled capability works", + profile: DefaultClusterProfile, + annotations: map[string]interface{}{ + "include.release.openshift.io/self-managed-high-availability": "true", + CapabilityAnnotation: "cap1"}, + caps: configv1.ClusterVersionCapabilitiesStatus{ + KnownCapabilities: []configv1.ClusterVersionCapability{"cap1"}, + }, + expected: fmt.Errorf("disabled capabilities: cap1"), + }, + { + name: "enabled capability works", + profile: DefaultClusterProfile, + annotations: map[string]interface{}{ + "include.release.openshift.io/self-managed-high-availability": "true", + CapabilityAnnotation: "cap1"}, + caps: configv1.ClusterVersionCapabilitiesStatus{ + KnownCapabilities: []configv1.ClusterVersionCapability{"cap1"}, + EnabledCapabilities: []configv1.ClusterVersionCapability{"cap1"}, + }, + }, + } + + for _, tt := range tests { + metadata := map[string]interface{}{} + t.Run(tt.name, func(t *testing.T) { + if tt.annotations != nil { + metadata["annotations"] = tt.annotations + } + m := Manifest{ + Obj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": metadata, + }, + }, + } + err := m.Include(&tt.exclude, tt.includeTechPreview, &tt.profile, &tt.caps) + assert.Equal(t, err, tt.expected) + }) + } +}