diff --git a/pkg/validation/errors/error.go b/pkg/validation/errors/error.go index 9b4391658..4e22d6286 100644 --- a/pkg/validation/errors/error.go +++ b/pkg/validation/errors/error.go @@ -102,6 +102,7 @@ const ( ErrorInvalidPackageManifest ErrorType = "PackageManifestNotValid" ErrorObjectFailedValidation ErrorType = "ObjectFailedValidation" ErrorPropertiesAnnotationUsed ErrorType = "PropertiesAnnotationUsed" + ErrorDeprecatedValidator ErrorType = "DeprecatedValidator" ) func NewError(t ErrorType, detail, field string, v interface{}) Error { @@ -248,3 +249,7 @@ func WarnInvalidObject(detail string, value interface{}) Error { func WarnPropertiesAnnotationUsed(detail string) Error { return Error{ErrorPropertiesAnnotationUsed, LevelWarn, "", "", detail} } + +func WarnDeprecatedValidator(detail string) Error { + return Error{ErrorDeprecatedValidator, LevelWarn, "", "", detail} +} diff --git a/pkg/validation/internal/operatorhub.go b/pkg/validation/internal/operatorhub.go index e82d10287..ea9bc03b5 100644 --- a/pkg/validation/internal/operatorhub.go +++ b/pkg/validation/internal/operatorhub.go @@ -6,7 +6,6 @@ import ( "io/ioutil" "net/mail" "net/url" - "os" "path/filepath" "strings" @@ -118,15 +117,9 @@ import ( // `k8s-version` key is allowed. If informed, it will perform the checks against this specific Kubernetes version where the // operator bundle is intend to be used and will raise errors instead of warnings. // Currently, this check is capable of verifying the removed APIs only for Kubernetes 1.22 version. -var OperatorHubValidator interfaces.Validator = interfaces.ValidatorFunc(validateOperatorHub) - -var validCapabilities = map[string]struct{}{ - "Basic Install": {}, - "Seamless Upgrades": {}, - "Full Lifecycle": {}, - "Deep Insights": {}, - "Auto Pilot": {}, -} +// +// Deprecated: Use OperatorHubV2Validator, StandardCapabilitiesValidator and StandardCategoriesValidator for equivalent validation. +var OperatorHubValidator interfaces.Validator = interfaces.ValidatorFunc(validateOperatorHubDeprecated) var validMediatypes = map[string]struct{}{ "image/gif": {}, @@ -135,29 +128,12 @@ var validMediatypes = map[string]struct{}{ "image/svg+xml": {}, } -var validCategories = map[string]struct{}{ - "AI/Machine Learning": {}, - "Application Runtime": {}, - "Big Data": {}, - "Cloud Provider": {}, - "Developer Tools": {}, - "Database": {}, - "Integration & Delivery": {}, - "Logging & Tracing": {}, - "Monitoring": {}, - "Modernization & Migration": {}, - "Networking": {}, - "OpenShift Optional": {}, - "Security": {}, - "Storage": {}, - "Streaming & Messaging": {}, -} - const minKubeVersionWarnMessage = "csv.Spec.minKubeVersion is not informed. It is recommended you provide this information. " + "Otherwise, it would mean that your operator project can be distributed and installed in any cluster version " + "available, which is not necessarily the case for all projects." -func validateOperatorHub(objs ...interface{}) (results []errors.ManifestResult) { +// Warning: this validator is deprecated in favor of validateOperatorHub() +func validateOperatorHubDeprecated(objs ...interface{}) (results []errors.ManifestResult) { // Obtain the k8s version if informed via the objects an optional k8sVersion := "" @@ -178,6 +154,11 @@ func validateOperatorHub(objs ...interface{}) (results []errors.ManifestResult) } } + // Add a deprecation warning to the list so that users are aware this validator is deprecated + deprecationResultWarning := errors.ManifestResult{} + deprecationResultWarning.Add(errors.WarnDeprecatedValidator(`The "operatorhub" validator is deprecated; for equivalent validation use "operatorhub/v2", "standardcapabilities" and "standardcategories" validators`)) + results = append(results, deprecationResultWarning) + return results } @@ -221,7 +202,8 @@ func validateHubCSVSpec(csv v1alpha1.ClusterServiceVersion) CSVChecks { checks = checkSpecProviderName(checks) checks = checkSpecMaintainers(checks) checks = checkSpecLinks(checks) - checks = checkAnnotations(checks) + checks = checkCapabilities(checks) + checks = checkCategories(checks) checks = checkSpecVersion(checks) checks = checkSpecIcon(checks) checks = checkSpecMinKubeVersion(checks) @@ -256,46 +238,6 @@ func checkSpecVersion(checks CSVChecks) CSVChecks { return checks } -// checkAnnotations will validate the values informed via annotations such as; capabilities and categories -func checkAnnotations(checks CSVChecks) CSVChecks { - if checks.csv.GetAnnotations() == nil { - checks.csv.SetAnnotations(make(map[string]string)) - } - - if capability, ok := checks.csv.ObjectMeta.Annotations["capabilities"]; ok { - if _, ok := validCapabilities[capability]; !ok { - checks.errs = append(checks.errs, fmt.Errorf("csv.Metadata.Annotations.Capabilities %s is not a valid capabilities level", capability)) - } - } - - if categories, ok := checks.csv.ObjectMeta.Annotations["categories"]; ok { - categorySlice := strings.Split(categories, ",") - - // use custom categories for validation if provided - customCategoriesPath := os.Getenv("OPERATOR_BUNDLE_CATEGORIES") - if customCategoriesPath != "" { - customCategories, err := extractCategories(customCategoriesPath) - if err != nil { - checks.errs = append(checks.errs, fmt.Errorf("could not extract custom categories from categories %#v: %s", customCategories, err)) - } else { - for _, category := range categorySlice { - if _, ok := customCategories[strings.TrimSpace(category)]; !ok { - checks.errs = append(checks.errs, fmt.Errorf("csv.Metadata.Annotations[\"categories\"] value %s is not in the set of custom categories", category)) - } - } - } - } else { - // use default categories - for _, category := range categorySlice { - if _, ok := validCategories[strings.TrimSpace(category)]; !ok { - checks.errs = append(checks.errs, fmt.Errorf("csv.Metadata.Annotations[\"categories\"] value %s is not in the set of default categories", category)) - } - } - } - } - return checks -} - // checkSpecIcon will validate if the CSV.spec.Icon was informed and is correct func checkSpecIcon(checks CSVChecks) CSVChecks { if checks.csv.Spec.Icon != nil { diff --git a/pkg/validation/internal/operatorhub_test.go b/pkg/validation/internal/operatorhub_test.go index d8c049180..f0ad83af1 100644 --- a/pkg/validation/internal/operatorhub_test.go +++ b/pkg/validation/internal/operatorhub_test.go @@ -32,9 +32,9 @@ func TestValidateBundleOperatorHub(t *testing.T) { `Error: Value : (etcdoperator.v0.9.4) csv.Spec.Maintainers email invalidemail is invalid: mail: missing '@' or angle-addr`, `Error: Value : (etcdoperator.v0.9.4) csv.Spec.Links elements should contain both name and url`, `Error: Value : (etcdoperator.v0.9.4) csv.Spec.Links url https//coreos.com/operators/etcd/docs/latest/ is invalid: parse "https//coreos.com/operators/etcd/docs/latest/": invalid URI for request`, - `Error: Value : (etcdoperator.v0.9.4) csv.Metadata.Annotations.Capabilities Installs and stuff is not a valid capabilities level`, + `Error: Value : (etcdoperator.v0.9.4) csv.Metadata.Annotations.Capabilities "Installs and stuff" is not a valid capabilities level`, `Error: Value : (etcdoperator.v0.9.4) csv.Spec.Icon should only have one element`, - `Error: Value : (etcdoperator.v0.9.4) csv.Metadata.Annotations["categories"] value Magic is not in the set of default categories`, + `Error: Value : (etcdoperator.v0.9.4) csv.Metadata.Annotations["categories"] value "Magic" is not in the set of standard categories`, `Error: Value : (etcdoperator.v0.9.4) csv.Spec.Version must be set`, }, }, diff --git a/pkg/validation/internal/operatorhubv2.go b/pkg/validation/internal/operatorhubv2.go new file mode 100644 index 000000000..64b5aca18 --- /dev/null +++ b/pkg/validation/internal/operatorhubv2.go @@ -0,0 +1,80 @@ +package internal + +import ( + "github.com/operator-framework/api/pkg/manifests" + "github.com/operator-framework/api/pkg/operators/v1alpha1" + "github.com/operator-framework/api/pkg/validation/errors" + interfaces "github.com/operator-framework/api/pkg/validation/interfaces" +) + +var OperatorHubV2Validator interfaces.Validator = interfaces.ValidatorFunc(validateOperatorHubV2) + +func validateOperatorHubV2(objs ...interface{}) (results []errors.ManifestResult) { + // Obtain the k8s version if informed via the objects an optional + k8sVersion := "" + for _, obj := range objs { + switch obj.(type) { + case map[string]string: + k8sVersion = obj.(map[string]string)[k8sVersionKey] + if len(k8sVersion) > 0 { + break + } + } + } + + for _, obj := range objs { + switch v := obj.(type) { + case *manifests.Bundle: + results = append(results, validateBundleOperatorHubV2(v, k8sVersion)) + } + } + + return results +} + +func validateBundleOperatorHubV2(bundle *manifests.Bundle, k8sVersion string) errors.ManifestResult { + result := errors.ManifestResult{Name: bundle.Name} + + if bundle == nil { + result.Add(errors.ErrInvalidBundle("Bundle is nil", nil)) + return result + } + + if bundle.CSV == nil { + result.Add(errors.ErrInvalidBundle("Bundle csv is nil", bundle.Name)) + return result + } + + csvChecksResult := validateHubCSVSpecV2(*bundle.CSV) + for _, err := range csvChecksResult.errs { + result.Add(errors.ErrInvalidCSV(err.Error(), bundle.CSV.GetName())) + } + for _, warn := range csvChecksResult.warns { + result.Add(errors.WarnInvalidCSV(warn.Error(), bundle.CSV.GetName())) + } + + errs, warns := validateDeprecatedAPIS(bundle, k8sVersion) + for _, err := range errs { + result.Add(errors.ErrFailedValidation(err.Error(), bundle.CSV.GetName())) + } + for _, warn := range warns { + result.Add(errors.WarnFailedValidation(warn.Error(), bundle.CSV.GetName())) + } + + return result +} + +// validateHubCSVSpec will check the CSV against the criteria to publish an +// operator bundle in the OperatorHub.io +func validateHubCSVSpecV2(csv v1alpha1.ClusterServiceVersion) CSVChecks { + checks := CSVChecks{csv: csv, errs: []error{}, warns: []error{}} + + checks = checkSpecProviderName(checks) + checks = checkSpecMaintainers(checks) + checks = checkSpecLinks(checks) + checks = checkSpecVersion(checks) + checks = checkSpecIcon(checks) + checks = checkSpecMinKubeVersion(checks) + + return checks +} diff --git a/pkg/validation/internal/operatorhubv2_test.go b/pkg/validation/internal/operatorhubv2_test.go new file mode 100644 index 000000000..d1263b9f0 --- /dev/null +++ b/pkg/validation/internal/operatorhubv2_test.go @@ -0,0 +1,58 @@ +package internal + +import ( + "testing" + + "github.com/operator-framework/api/pkg/manifests" + + "github.com/stretchr/testify/require" +) + +func TestValidateBundleOperatorHubV2(t *testing.T) { + var table = []struct { + description string + directory string + hasError bool + errStrings []string + }{ + { + description: "registryv1 bundle/valid bundle", + directory: "./testdata/valid_bundle", + hasError: false, + }, + { + description: "registryv1 bundle/invald bundle operatorhubio", + directory: "./testdata/invalid_bundle_operatorhub", + hasError: true, + errStrings: []string{ + `Error: Value : (etcdoperator.v0.9.4) csv.Spec.Provider.Name not specified`, + `Error: Value : (etcdoperator.v0.9.4) csv.Spec.Maintainers elements should contain both name and email`, + `Error: Value : (etcdoperator.v0.9.4) csv.Spec.Maintainers email invalidemail is invalid: mail: missing '@' or angle-addr`, + `Error: Value : (etcdoperator.v0.9.4) csv.Spec.Links elements should contain both name and url`, + `Error: Value : (etcdoperator.v0.9.4) csv.Spec.Links url https//coreos.com/operators/etcd/docs/latest/ is invalid: parse "https//coreos.com/operators/etcd/docs/latest/": invalid URI for request`, + `Error: Value : (etcdoperator.v0.9.4) csv.Spec.Icon should only have one element`, + `Error: Value : (etcdoperator.v0.9.4) csv.Spec.Version must be set`, + }, + }, + } + + for _, tt := range table { + // Validate the bundle object + bundle, err := manifests.GetBundleFromDir(tt.directory) + require.NoError(t, err) + + results := OperatorHubV2Validator.Validate(bundle) + + if len(results) > 0 { + require.Equal(t, results[0].HasError(), tt.hasError) + if results[0].HasError() { + require.Equal(t, len(tt.errStrings), len(results[0].Errors)) + + for _, err := range results[0].Errors { + errString := err.Error() + require.Contains(t, tt.errStrings, errString) + } + } + } + } +} diff --git a/pkg/validation/internal/standardcapabilities.go b/pkg/validation/internal/standardcapabilities.go new file mode 100644 index 000000000..432bb5316 --- /dev/null +++ b/pkg/validation/internal/standardcapabilities.go @@ -0,0 +1,59 @@ +package internal + +import ( + "fmt" + + "github.com/operator-framework/api/pkg/manifests" + "github.com/operator-framework/api/pkg/validation/errors" + interfaces "github.com/operator-framework/api/pkg/validation/interfaces" +) + +var StandardCapabilitiesValidator interfaces.Validator = interfaces.ValidatorFunc(validateCapabilities) + +var validCapabilities = map[string]struct{}{ + "Basic Install": {}, + "Seamless Upgrades": {}, + "Full Lifecycle": {}, + "Deep Insights": {}, + "Auto Pilot": {}, +} + +func validateCapabilities(objs ...interface{}) (results []errors.ManifestResult) { + for _, obj := range objs { + switch v := obj.(type) { + case *manifests.Bundle: + results = append(results, validateCapabilitiesBundle(v)) + } + } + + return results +} + +func validateCapabilitiesBundle(bundle *manifests.Bundle) errors.ManifestResult { + result := errors.ManifestResult{Name: bundle.Name} + csvCategoryCheck := CSVChecks{csv: *bundle.CSV, errs: []error{}, warns: []error{}} + + csvChecksResult := checkCapabilities(csvCategoryCheck) + for _, err := range csvChecksResult.errs { + result.Add(errors.ErrInvalidCSV(err.Error(), bundle.CSV.GetName())) + } + for _, warn := range csvChecksResult.warns { + result.Add(errors.WarnInvalidCSV(warn.Error(), bundle.CSV.GetName())) + } + + return result +} + +// checkAnnotations will validate the values informed via annotations such as; capabilities and categories +func checkCapabilities(checks CSVChecks) CSVChecks { + if checks.csv.GetAnnotations() == nil { + checks.csv.SetAnnotations(make(map[string]string)) + } + + if capability, ok := checks.csv.ObjectMeta.Annotations["capabilities"]; ok { + if _, ok := validCapabilities[capability]; !ok { + checks.errs = append(checks.errs, fmt.Errorf("csv.Metadata.Annotations.Capabilities %q is not a valid capabilities level", capability)) + } + } + return checks +} diff --git a/pkg/validation/internal/standardcapabilities_test.go b/pkg/validation/internal/standardcapabilities_test.go new file mode 100644 index 000000000..061e1c7a7 --- /dev/null +++ b/pkg/validation/internal/standardcapabilities_test.go @@ -0,0 +1,52 @@ +package internal + +import ( + "testing" + + "github.com/operator-framework/api/pkg/manifests" + + "github.com/stretchr/testify/require" +) + +func TestValidateCapabilities(t *testing.T) { + var table = []struct { + description string + directory string + hasError bool + errStrings []string + }{ + { + description: "registryv1 bundle/valid bundle", + directory: "./testdata/valid_bundle", + hasError: false, + }, + { + description: "registryv1 bundle/invald bundle operatorhubio", + directory: "./testdata/invalid_bundle_operatorhub", + hasError: true, + errStrings: []string{ + `Error: Value : (etcdoperator.v0.9.4) csv.Metadata.Annotations.Capabilities "Installs and stuff" is not a valid capabilities level`, + }, + }, + } + + for _, tt := range table { + // Validate the bundle object + bundle, err := manifests.GetBundleFromDir(tt.directory) + require.NoError(t, err) + + results := StandardCapabilitiesValidator.Validate(bundle) + + if len(results) > 0 { + require.Equal(t, results[0].HasError(), tt.hasError) + if results[0].HasError() { + require.Equal(t, len(tt.errStrings), len(results[0].Errors)) + + for _, err := range results[0].Errors { + errString := err.Error() + require.Contains(t, tt.errStrings, errString) + } + } + } + } +} diff --git a/pkg/validation/internal/standardcategories.go b/pkg/validation/internal/standardcategories.go new file mode 100644 index 000000000..3b866cf1c --- /dev/null +++ b/pkg/validation/internal/standardcategories.go @@ -0,0 +1,91 @@ +package internal + +import ( + "fmt" + "os" + "strings" + + "github.com/operator-framework/api/pkg/manifests" + "github.com/operator-framework/api/pkg/validation/errors" + interfaces "github.com/operator-framework/api/pkg/validation/interfaces" +) + +var StandardCategoriesValidator interfaces.Validator = interfaces.ValidatorFunc(validateCategories) + +var validCategories = map[string]struct{}{ + "AI/Machine Learning": {}, + "Application Runtime": {}, + "Big Data": {}, + "Cloud Provider": {}, + "Developer Tools": {}, + "Database": {}, + "Integration & Delivery": {}, + "Logging & Tracing": {}, + "Monitoring": {}, + "Modernization & Migration": {}, + "Networking": {}, + "OpenShift Optional": {}, + "Security": {}, + "Storage": {}, + "Streaming & Messaging": {}, + "Observability": {}, +} + +func validateCategories(objs ...interface{}) (results []errors.ManifestResult) { + for _, obj := range objs { + switch v := obj.(type) { + case *manifests.Bundle: + results = append(results, validateCategoriesBundle(v)) + } + } + + return results +} + +func validateCategoriesBundle(bundle *manifests.Bundle) errors.ManifestResult { + result := errors.ManifestResult{Name: bundle.Name} + csvCategoryCheck := CSVChecks{csv: *bundle.CSV, errs: []error{}, warns: []error{}} + + csvChecksResult := checkCategories(csvCategoryCheck) + for _, err := range csvChecksResult.errs { + result.Add(errors.ErrInvalidCSV(err.Error(), bundle.CSV.GetName())) + } + for _, warn := range csvChecksResult.warns { + result.Add(errors.WarnInvalidCSV(warn.Error(), bundle.CSV.GetName())) + } + + return result +} + +func checkCategories(checks CSVChecks) CSVChecks { + if checks.csv.GetAnnotations() == nil { + checks.csv.SetAnnotations(make(map[string]string)) + } + + if categories, ok := checks.csv.ObjectMeta.Annotations["categories"]; ok { + categorySlice := strings.Split(categories, ",") + + // use custom categories for validation if provided + customCategoriesPath := os.Getenv("OPERATOR_BUNDLE_CATEGORIES") + if customCategoriesPath != "" { + customCategories, err := extractCategories(customCategoriesPath) + if err != nil { + checks.errs = append(checks.errs, fmt.Errorf("could not extract custom categories from categories %#v: %s", customCategories, err)) + } else { + for _, category := range categorySlice { + if _, ok := customCategories[strings.TrimSpace(category)]; !ok { + checks.errs = append(checks.errs, fmt.Errorf("csv.Metadata.Annotations[\"categories\"] value %q is not in the set of custom categories", category)) + } + } + } + } else { + // use default categories + for _, category := range categorySlice { + if _, ok := validCategories[strings.TrimSpace(category)]; !ok { + checks.errs = append(checks.errs, fmt.Errorf("csv.Metadata.Annotations[\"categories\"] value %q is not in the set of standard categories", category)) + } + } + } + } + return checks +} diff --git a/pkg/validation/internal/standardcategories_test.go b/pkg/validation/internal/standardcategories_test.go new file mode 100644 index 000000000..f93ae8fd4 --- /dev/null +++ b/pkg/validation/internal/standardcategories_test.go @@ -0,0 +1,52 @@ +package internal + +import ( + "testing" + + "github.com/operator-framework/api/pkg/manifests" + + "github.com/stretchr/testify/require" +) + +func TestValidateCategories(t *testing.T) { + var table = []struct { + description string + directory string + hasError bool + errStrings []string + }{ + { + description: "registryv1 bundle/valid bundle", + directory: "./testdata/valid_bundle", + hasError: false, + }, + { + description: "registryv1 bundle/invald bundle operatorhubio", + directory: "./testdata/invalid_bundle_operatorhub", + hasError: true, + errStrings: []string{ + `Error: Value : (etcdoperator.v0.9.4) csv.Metadata.Annotations["categories"] value "Magic" is not in the set of standard categories`, + }, + }, + } + + for _, tt := range table { + // Validate the bundle object + bundle, err := manifests.GetBundleFromDir(tt.directory) + require.NoError(t, err) + + results := StandardCategoriesValidator.Validate(bundle) + + if len(results) > 0 { + require.Equal(t, results[0].HasError(), tt.hasError) + if results[0].HasError() { + require.Equal(t, len(tt.errStrings), len(results[0].Errors)) + + for _, err := range results[0].Errors { + errString := err.Error() + require.Contains(t, tt.errStrings, errString) + } + } + } + } +} diff --git a/pkg/validation/internal/testdata/valid_bundle/etcdoperator.v0.9.4.clusterserviceversion.yaml b/pkg/validation/internal/testdata/valid_bundle/etcdoperator.v0.9.4.clusterserviceversion.yaml index 29d1f682a..b4dc61e58 100644 --- a/pkg/validation/internal/testdata/valid_bundle/etcdoperator.v0.9.4.clusterserviceversion.yaml +++ b/pkg/validation/internal/testdata/valid_bundle/etcdoperator.v0.9.4.clusterserviceversion.yaml @@ -16,7 +16,7 @@ metadata: ],\n \"storageType\":\"S3\",\n \"s3\": {\n \"path\": \"\"\ ,\n \"awsSecret\": \"\"\n }\n }\n }\n]\n" capabilities: Full Lifecycle - categories: Database, Big Data + categories: Database, Big Data, Observability containerImage: quay.io/coreos/etcd-operator@sha256:66a37fd61a06a43969854ee6d3e21087a98b93838e284a6086b13917f96b0d9b createdAt: 2019-02-28 01:03:00 description: Create and maintain highly-available etcd clusters on Kubernetes diff --git a/pkg/validation/validation.go b/pkg/validation/validation.go index 2f3f61eab..d98237e05 100644 --- a/pkg/validation/validation.go +++ b/pkg/validation/validation.go @@ -29,8 +29,23 @@ var BundleValidator = internal.BundleValidator // OperatorHubValidator implements Validator to validate bundle objects // for OperatorHub.io requirements. +// +// Deprecated: Use OperatorHubV2Validator, StandardCapabilitiesValidator +// and StandardCategoriesValidator for equivalent functionality. var OperatorHubValidator = internal.OperatorHubValidator +// OperatorHubV2Validator implements Validator to validate bundle objects +// for OperatorHub.io requirements. +var OperatorHubV2Validator = internal.OperatorHubV2Validator + +// StandardCapabilitiesValidator implements Validator to validate bundle objects +// for OperatorHub.io requirements around UI capability metadata +var StandardCapabilitiesValidator = internal.StandardCapabilitiesValidator + +// StandardCategoriesValidator implements Validator to validate bundle objects +// for OperatorHub.io requirements around UI category metadata +var StandardCategoriesValidator = internal.StandardCategoriesValidator + // Object Validator validates various custom objects in the bundle like PDBs and SCCs. // Object validation is optional and not a default-level validation. var ObjectValidator = internal.ObjectValidator @@ -69,7 +84,9 @@ var AllValidators = interfaces.Validators{ ClusterServiceVersionValidator, CustomResourceDefinitionValidator, BundleValidator, - OperatorHubValidator, + OperatorHubV2Validator, + StandardCategoriesValidator, + StandardCapabilitiesValidator, ObjectValidator, OperatorGroupValidator, CommunityOperatorValidator,