diff --git a/go.mod b/go.mod index 56d1b9f4f0..43e9486cfa 100644 --- a/go.mod +++ b/go.mod @@ -17,14 +17,16 @@ require ( github.com/google/go-containerregistry v0.20.6 github.com/google/renameio/v2 v2.0.0 github.com/gorilla/handlers v1.5.2 + github.com/invopop/jsonschema v0.13.0 github.com/klauspost/compress v1.18.0 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.1 github.com/operator-framework/api v0.34.0 github.com/operator-framework/helm-operator-plugins v0.8.0 github.com/operator-framework/operator-registry v1.57.0 - github.com/prometheus/client_golang v1.23.2 - github.com/prometheus/common v0.66.1 + github.com/prometheus/client_golang v1.23.0 + github.com/prometheus/common v0.65.0 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/spf13/cobra v1.10.1 github.com/stretchr/testify v1.11.1 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b @@ -69,7 +71,9 @@ require ( github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/buger/jsonparser v1.1.1 // indirect github.com/cenkalti/backoff/v5 v5.0.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chai2010/gettext-go v1.0.2 // indirect @@ -182,7 +186,6 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/rubenv/sql-migrate v1.8.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/secure-systems-lab/go-securesystemslib v0.9.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/sigstore/fulcio v1.7.1 // indirect @@ -199,6 +202,7 @@ require ( github.com/ulikunitz/xz v0.5.14 // indirect github.com/vbatts/tar-split v0.12.1 // indirect github.com/vbauerster/mpb/v8 v8.10.2 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xlab/treeprint v1.2.0 // indirect go.etcd.io/bbolt v1.4.3 // indirect diff --git a/go.sum b/go.sum index 9b0bb4e753..ca008ca4d8 100644 --- a/go.sum +++ b/go.sum @@ -38,12 +38,16 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70= github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -271,6 +275,8 @@ github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jmhodges/clock v1.2.0 h1:eq4kys+NI0PLngzaHEe7AmPT90XMGIEySD1JfV1PDIs= @@ -399,13 +405,13 @@ github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg= github.com/proglottis/gpgme v0.1.4 h1:3nE7YNA70o2aLjcg63tXMOhPD7bplfE5CBdV+hLAm2M= github.com/proglottis/gpgme v0.1.4/go.mod h1:5LoXMgpE4bttgwwdv9bLs/vwqv3qV7F4glEEZ7mRKrM= -github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= -github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= +github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= -github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= +github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/redis/go-redis/extra/rediscmd/v9 v9.10.0 h1:uTiEyEyfLhkw678n6EulHVto8AkcXVr8zUcBJNZ0ark= @@ -473,6 +479,8 @@ github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnn github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= github.com/vbauerster/mpb/v8 v8.10.2 h1:2uBykSHAYHekE11YvJhKxYmLATKHAGorZwFlyNw4hHM= github.com/vbauerster/mpb/v8 v8.10.2/go.mod h1:+Ja4P92E3/CorSZgfDtK46D7AVbDqmBQRTmyTqPElo0= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= diff --git a/internal/operator-controller/applier/helm.go b/internal/operator-controller/applier/helm.go index e758f42dab..8b33ef9d5d 100644 --- a/internal/operator-controller/applier/helm.go +++ b/internal/operator-controller/applier/helm.go @@ -217,8 +217,9 @@ func (h *Helm) buildHelmChart(bundleFS fs.FS, ext *ocv1.ClusterExtension) (*char } } - bundleConfig := map[string]interface{}{ - bundle.BundleConfigWatchNamespaceKey: watchNamespace, + bundleConfig := map[string]interface{}{} + if watchNamespace != "" { + bundleConfig[bundle.BundleConfigWatchNamespaceKey] = watchNamespace } return h.BundleToHelmChartConverter.ToHelmChart(source.FromFS(bundleFS), ext.Spec.Namespace, bundleConfig) } diff --git a/internal/operator-controller/applier/helm_test.go b/internal/operator-controller/applier/helm_test.go index fdf5eead07..c8581681e1 100644 --- a/internal/operator-controller/applier/helm_test.go +++ b/internal/operator-controller/applier/helm_test.go @@ -14,7 +14,6 @@ import ( "helm.sh/helm/v3/pkg/chart" "helm.sh/helm/v3/pkg/release" "helm.sh/helm/v3/pkg/storage/driver" - corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -648,9 +647,7 @@ func TestApply_InstallationWithSingleOwnNamespaceInstallSupportEnabled(t *testin } func TestApply_RegistryV1ToChartConverterIntegration(t *testing.T) { - t.Run("generates bundle resources in AllNamespaces install mode", func(t *testing.T) { - var expectedWatchNamespace = corev1.NamespaceAll - + t.Run("generates bundle resources without configuration when no bundle config is defined", func(t *testing.T) { helmApplier := applier.Helm{ ActionClientGetter: &mockActionGetter{ getClientErr: driver.ErrReleaseNotFound, @@ -661,7 +658,8 @@ func TestApply_RegistryV1ToChartConverterIntegration(t *testing.T) { }, BundleToHelmChartConverter: &fakeBundleToHelmChartConverter{ fn: func(bundle source.BundleSource, installNamespace string, config map[string]interface{}) (*chart.Chart, error) { - require.Equal(t, expectedWatchNamespace, config[registryv1Bundle.BundleConfigWatchNamespaceKey]) + // no config is passed + require.Empty(t, config) return nil, nil }, }, diff --git a/internal/operator-controller/rukpak/bundle/bundlecfg/bundle_config_schema.json b/internal/operator-controller/rukpak/bundle/bundlecfg/bundle_config_schema.json new file mode 100644 index 0000000000..02c0a03aa0 --- /dev/null +++ b/internal/operator-controller/rukpak/bundle/bundlecfg/bundle_config_schema.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle/config", + "properties": + { + "watchNamespace": + { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object" +} \ No newline at end of file diff --git a/internal/operator-controller/rukpak/bundle/bundlecfg/config.go b/internal/operator-controller/rukpak/bundle/bundlecfg/config.go new file mode 100644 index 0000000000..f34fe4d8e3 --- /dev/null +++ b/internal/operator-controller/rukpak/bundle/bundlecfg/config.go @@ -0,0 +1,272 @@ +package bundlecfg + +import ( + _ "embed" + "encoding/json" + "errors" + "fmt" + "slices" + "strings" + + "github.com/invopop/jsonschema" + schemavalidation "github.com/santhosh-tekuri/jsonschema/v6" + "github.com/santhosh-tekuri/jsonschema/v6/kind" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apimachinery/pkg/util/validation" + + "github.com/operator-framework/api/pkg/operators/v1alpha1" + + "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle" +) + +const ( + dns1123SubdomainFormat = "Namespace" + notOwnNamespaceFormat = "NotOwnNamespace" +) + +var ( + //go:embed bundle_config_schema.json + // bundleConfigBaseSchema is the base jsonschema for a registry+v1 bundle configuration + // The rool level properties (e.g. watchNamespaces) must match the attribute names in the + // Config struct's properties' json annotations. The final schema can be mutated to respect + // bundle specific settings, e.g. the particular install mode support (i.e. if the bundle + // only supports AllNamespaces install mode, it doesn't need a 'watchNamespace' parameter). + // TODO: when we are ready to develop the SubscriptionConfig support, update Config with + // the *v1alpha1.SubscriptionConfig parameter, and update the base schema with the json value + // of the 'config' parameter in the SubscriptionConfig CRD found here: + // https://github.com/operator-framework/api/blob/master/crds/operators.coreos.com_subscriptions.yaml#L70 + bundleConfigBaseSchema []byte + + // supportedBundleInstallModes is a set of install modes supported by OLMv1 + supportedBundleInstallModes = sets.New[v1alpha1.InstallModeType]( + v1alpha1.InstallModeTypeAllNamespaces, + v1alpha1.InstallModeTypeSingleNamespace, + v1alpha1.InstallModeTypeOwnNamespace, + ) + + // dnsFormat checks conformity to RFC1213 lowercase dns subdomain format by any field with format 'RFC-1123' + dnsFormat = &schemavalidation.Format{ + Name: dns1123SubdomainFormat, + Validate: func(v any) error { + if v == nil { + return nil + } + s, ok := v.(string) + if !ok { + return fmt.Errorf("invalid type %T, expected string", v) + } + errs := validation.IsDNS1123Subdomain(s) + if len(errs) > 0 { + return fmt.Errorf("%q is not a valid namespace name: %s", v, strings.Join(errs, ", ")) + } + return nil + }, + } +) + +// Config is a registry+v1 bundle configuration surface +type Config struct { + // WatchNamespace is supported for certain bundles to allow the user to configure installation in Single- or OwnNamespace modes + // The validation behavior of this field is determined by the install modes supported by the bundle, e.g.: + // - If a bundle only supports AllNamespaces mode (or only OwnNamespace mode): this field will be unknown + // - If a bundle supports AllNamespaces and SingleNamespace install modes: this field is optional + // - If a bundle supports AllNamespaces and OwnNamespace: this field is optional, but if set must be equal to the install namespace + WatchNamespace string `json:"watchNamespace,omitempty"` +} + +// ConfigSchema +type ConfigSchema struct{} + +// Unmarshall returns a validated Config struct from the values given in rawConfig. +// The applied validation will be determined by the install modes supported by the bundle +func Unmarshall(rv1 bundle.RegistryV1, installNamespace string, rawConfig map[string]interface{}) (*Config, error) { + if len(rawConfig) == 0 { + return nil, nil + } + + rawSchema, err := bundleConfigSchema(rv1, installNamespace) + if err != nil { + return nil, fmt.Errorf("error generating bundle config schema: %v", err) + } + + // custom formats used for field validation + // for instance kubernetes namespace name. + // Also used for value validation, e.g. when a watchNamespace cannot be the install namespace + // because more control over the error message can be given + customFormats := []*schemavalidation.Format{ + dnsFormat, + notOwnNamespaceFmt(installNamespace), + } + + if err := validateBundleConfig(rawSchema, customFormats, rawConfig); err != nil { + return nil, fmt.Errorf("invalid configuration: %v", err) + } + + return toConfig(rawConfig) +} + +// bundleConfigSchema generates a jsonschema used to validate bundle configuration +func bundleConfigSchema(rv1 bundle.RegistryV1, installNamespace string) ([]byte, error) { + schema := &jsonschema.Schema{} + if err := json.Unmarshal(bundleConfigBaseSchema, schema); err != nil { + return nil, err + } + + // apply bundle rawConfig based mutations for watchNamespace + if err := configureWatchNamespaceProperty(rv1, installNamespace, schema); err != nil { + return nil, err + } + + // return schema + out, err := schema.MarshalJSON() + if err != nil { + panic(err) + } + return out, err +} + +// configureWatchNamespaceProperty modifies schema to configure the watchNamespace config property based on +// the install modes supported by the bundle marking the field required or optional, or restricting the possible values +// it can take +func configureWatchNamespaceProperty(rv1 bundle.RegistryV1, installNamespace string, schema *jsonschema.Schema) error { + bundleInstallModes := sets.New[v1alpha1.InstallModeType]() + for _, im := range rv1.CSV.Spec.InstallModes { + if im.Supported { + bundleInstallModes.Insert(im.Type) + } + } + + supportedInstallModes := bundleInstallModes.Intersection(supportedBundleInstallModes) + + if len(supportedInstallModes) == 0 { + //bundleModes := slices.Sorted(slices.Values(bundleInstallModes.UnsortedList())) + supportedModes := slices.Sorted(slices.Values(supportedBundleInstallModes.UnsortedList())) + return fmt.Errorf("bundle does not support any of the allowable install modes %v", supportedModes) + } + + allSupported := supportedInstallModes.Has(v1alpha1.InstallModeTypeAllNamespaces) + singleSupported := supportedInstallModes.Has(v1alpha1.InstallModeTypeSingleNamespace) + ownSupported := supportedInstallModes.Has(v1alpha1.InstallModeTypeOwnNamespace) + + // no watchNamespace rawConfig parameter if bundle only supports AllNamespaces or OwnNamespace install modes + if len(supportedInstallModes) == 1 && (allSupported || ownSupported) { + schema.Properties.Delete("watchNamespace") + return nil + } + + watchNamespaceProperty, ok := schema.Properties.Get("watchNamespace") + if !ok { + return errors.New("watchNamespace not found in schema") + } + + watchNamespaceProperty.Format = dns1123SubdomainFormat + + // required or optional + if !allSupported && singleSupported { + schema.Required = append(schema.Required, "watchNamespace") + } else { + // note: the library currently doesn't support jsonschema.Types + // this is the current workaround for declaring optional/nullable fields + // https://github.com/invopop/jsonschema/issues/115 + watchNamespaceProperty.Extras = map[string]any{ + "type": []string{"string", "null"}, + } + if !ownSupported { + // if own namespace is not supported validate that it is not being used + watchNamespaceProperty.Format = notOwnNamespaceFormat + } + } + + // must be the install namespace + if allSupported && ownSupported && !singleSupported { + watchNamespaceProperty.Enum = []any{ + installNamespace, + nil, + } + } + return nil +} + +// validateBundleConfig validates the bundle rawConfig +func validateBundleConfig(rawSchema []byte, customFormats []*schemavalidation.Format, rawConfig map[string]interface{}) error { + schema, err := schemavalidation.UnmarshalJSON(strings.NewReader(string(rawSchema))) + if err != nil { + return err + } + + compiler := schemavalidation.NewCompiler() + for _, format := range customFormats { + compiler.RegisterFormat(format) + } + compiler.AssertFormat() + if err := compiler.AddResource("schema.json", schema); err != nil { + return err + } + compiledSchema, err := compiler.Compile("schema.json") + if err != nil { + return err + } + + return formatJSONSchemaValidationError(compiledSchema.Validate(rawConfig)) +} + +// toConfig converts rawConfig into a Config struct +func toConfig(rawConfig map[string]interface{}) (*Config, error) { + bytes, err := json.Marshal(rawConfig) + if err != nil { + return nil, err + } + cfg := &Config{} + err = json.Unmarshal(bytes, cfg) + return cfg, err +} + +// formatJSONSchemaValidationError extracts and formats the jsonschema validation errors given by the underlying library +func formatJSONSchemaValidationError(err error) error { + var validationErr *schemavalidation.ValidationError + if !errors.As(err, &validationErr) { + return err + } + var errs []error + for _, cause := range validationErr.Causes { + if cause == nil || cause.ErrorKind == nil { + continue + } + + var errMsg string + switch e := cause.ErrorKind.(type) { + case *kind.Format: + errMsg = e.Err.Error() + default: + errMsg = cause.Error() + } + + instanceLocation := "." + strings.Join(cause.InstanceLocation, ".") + if instanceLocation == "." { + errs = append(errs, fmt.Errorf("%v", errMsg)) + } else { + errs = append(errs, fmt.Errorf("at path %q: %s", instanceLocation, errMsg)) + } + } + if len(errs) > 0 { + return errors.Join(errs...) + } + return err +} + +// notOwnNamespaceFmt returns a dynamically generated format specifically for the case where +// a bundle does not support own namespace installation but a watch namespace can be optionally given +func notOwnNamespaceFmt(installNamespace string) *schemavalidation.Format { + return &schemavalidation.Format{ + Name: notOwnNamespaceFormat, + Validate: func(v any) error { + if err := dnsFormat.Validate(v); err != nil { + return err + } + if v == installNamespace { + return fmt.Errorf("unsupported value %q, watchNamespace cannot be install namespace", v) + } + return nil + }, + } +} diff --git a/internal/operator-controller/rukpak/bundle/bundlecfg/config_test.go b/internal/operator-controller/rukpak/bundle/bundlecfg/config_test.go new file mode 100644 index 0000000000..aa7efe2621 --- /dev/null +++ b/internal/operator-controller/rukpak/bundle/bundlecfg/config_test.go @@ -0,0 +1,266 @@ +package bundlecfg_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/operator-framework/api/pkg/operators/v1alpha1" + + "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle" + "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle/bundlecfg" + . "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util/testing" +) + +const ( + installNamespace = "install-namespace" +) + +var ( + requiredConfigSet = []v1alpha1.InstallModeType{ + v1alpha1.InstallModeTypeSingleNamespace, + } + + optionalNotOwnNamespaceConfigSet = []v1alpha1.InstallModeType{ + v1alpha1.InstallModeTypeAllNamespaces, + v1alpha1.InstallModeTypeSingleNamespace, + } + + optionalOwnNamespaceConfigSet = []v1alpha1.InstallModeType{ + v1alpha1.InstallModeTypeAllNamespaces, + v1alpha1.InstallModeTypeOwnNamespace, + } + + optionalConfigSet = []v1alpha1.InstallModeType{ + v1alpha1.InstallModeTypeAllNamespaces, + v1alpha1.InstallModeTypeOwnNamespace, + v1alpha1.InstallModeTypeSingleNamespace, + } + + notRequiredConfigSet = []v1alpha1.InstallModeType{ + v1alpha1.InstallModeTypeAllNamespaces, + } +) + +func Test_Unmarshall_WatchNamespace_Configuration(t *testing.T) { + // The behavior of watchNamespace is dynamic and depends on the install modes supported by + // the bundle (declared in its ClusterServiceVersion). For instance, a bundle that only + // supports AllNamespaces or only supports OwnNamespace mode does not need a watchNamespace configuration, or + // a bundle that supports AllNamespaces and OwnNamespace install modes will have an optional watchNamespace + // configuration, however when set, the value must be equal to the install namespace + for _, tt := range []struct { + name string + supportedInstallModes []v1alpha1.InstallModeType + rawConfig map[string]interface{} + expectedErrMsgFragment string + expectedConfig *bundlecfg.Config + }{ + { + name: "bundle does not have a valid install mode", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeMultiNamespace}, + rawConfig: map[string]interface{}{ + "watchNamespace": "some-namespace", + }, + expectedErrMsgFragment: "bundle does not support any of the allowable install modes [AllNamespaces OwnNamespace SingleNamespace]", + }, + { + name: "watchNamespace is required and provided", + supportedInstallModes: requiredConfigSet, + rawConfig: map[string]interface{}{ + "watchNamespace": "some-namespace", + }, + expectedConfig: &bundlecfg.Config{ + WatchNamespace: "some-namespace", + }, + }, + { + name: "watchNamespace is required and provided but invalid", + supportedInstallModes: requiredConfigSet, + rawConfig: map[string]interface{}{ + "watchNamespace": "invalid-", + }, + expectedErrMsgFragment: "is not a valid namespace name", + }, + { + name: "watchNamespace is required and provided but empty", + supportedInstallModes: requiredConfigSet, + rawConfig: map[string]interface{}{ + "watchNamespace": "", + }, + expectedErrMsgFragment: "is not a valid namespace name", + }, + { + name: "watchNamespace is required and not provided (nil)", + supportedInstallModes: requiredConfigSet, + rawConfig: nil, + expectedConfig: nil, + }, + { + name: "watchNamespace is required and not provided (empty config)", + supportedInstallModes: requiredConfigSet, + rawConfig: map[string]interface{}{}, + expectedConfig: nil, + }, + { + name: "watchNamespace is optional and provided", + supportedInstallModes: optionalConfigSet, + rawConfig: map[string]interface{}{ + "watchNamespace": "some-namespace", + }, + expectedConfig: &bundlecfg.Config{ + WatchNamespace: "some-namespace", + }, + }, + { + name: "watchNamespace is optional and provided but invalid", + supportedInstallModes: optionalConfigSet, + rawConfig: map[string]interface{}{ + "watchNamespace": "not a valid namespace name", + }, + expectedErrMsgFragment: "not a valid namespace name", + }, + { + name: "watchNamespace is optional and not provided (nil config)", + supportedInstallModes: optionalConfigSet, + rawConfig: nil, + expectedConfig: nil, + }, + { + name: "watchNamespace is optional and not provided (empty config)", + supportedInstallModes: optionalConfigSet, + rawConfig: map[string]interface{}{}, + expectedConfig: nil, + }, + { + name: "watchNamespace is optional and not provided (nil)", + supportedInstallModes: optionalConfigSet, + rawConfig: map[string]interface{}{ + "watchNamespace": nil, + }, + expectedConfig: &bundlecfg.Config{}, + }, + { + name: "watchNamespace is optional and restricted to install-namespace and correctly provided", + supportedInstallModes: optionalOwnNamespaceConfigSet, + rawConfig: map[string]interface{}{ + "watchNamespace": "install-namespace", + }, + expectedConfig: &bundlecfg.Config{ + WatchNamespace: "install-namespace", + }, + }, + { + name: "watchNamespace is optional and restricted to install-namespace and incorrectly provided", + supportedInstallModes: optionalOwnNamespaceConfigSet, + rawConfig: map[string]interface{}{ + "watchNamespace": "not-install-namespace", + }, + expectedErrMsgFragment: "value must be one of 'install-namespace', ", + }, + { + name: "watchNamespace is optional and restricted to install-namespace and not provided (nil)", + supportedInstallModes: optionalOwnNamespaceConfigSet, + rawConfig: map[string]interface{}{ + "watchNamespace": nil, + }, + expectedConfig: &bundlecfg.Config{}, + }, + { + name: "watchNamespace is optional and restricted to install-namespace and not provided (nil config)", + supportedInstallModes: optionalOwnNamespaceConfigSet, + rawConfig: nil, + expectedConfig: nil, + }, + { + name: "watchNamespace is optional and restricted to install-namespace and not provided (empty config)", + supportedInstallModes: optionalOwnNamespaceConfigSet, + rawConfig: map[string]interface{}{}, + expectedConfig: nil, + }, + { + name: "watchNamespace is optional and cannot be install-namespace but is install namespace", + supportedInstallModes: optionalNotOwnNamespaceConfigSet, + rawConfig: map[string]interface{}{ + "watchNamespace": "install-namespace", + }, + expectedConfig: &bundlecfg.Config{ + WatchNamespace: "install-namespace", + }, + expectedErrMsgFragment: "watchNamespace cannot be install namespace", + }, + { + name: "watchNamespace is optional and cannot be install-namespace and it not install namespace", + supportedInstallModes: optionalNotOwnNamespaceConfigSet, + rawConfig: map[string]interface{}{ + "watchNamespace": "not-install-namespace", + }, + expectedConfig: &bundlecfg.Config{ + WatchNamespace: "not-install-namespace", + }, + }, + { + name: "watchNamespace is optional and cannot be install-namespace and not provided (nil)", + supportedInstallModes: optionalNotOwnNamespaceConfigSet, + rawConfig: map[string]interface{}{ + "watchNamespace": nil, + }, + expectedConfig: &bundlecfg.Config{}, + }, + { + name: "watchNamespace is optional and cannot be install-namespace and not provided (nil config)", + supportedInstallModes: optionalNotOwnNamespaceConfigSet, + rawConfig: nil, + expectedConfig: nil, + }, + { + name: "watchNamespace is optional and cannot be install-namespace and not provided (empty config)", + supportedInstallModes: optionalNotOwnNamespaceConfigSet, + rawConfig: map[string]interface{}{}, + expectedConfig: nil, + }, + { + name: "watchNamespace is not a config option and it is set", + supportedInstallModes: notRequiredConfigSet, + rawConfig: map[string]interface{}{ + "watchNamespace": "some-namespace", + }, + expectedErrMsgFragment: "additional properties 'watchNamespace' not allowed", + }, + { + name: "watchNamespace is not a config option and it is not set (nil)", + supportedInstallModes: notRequiredConfigSet, + rawConfig: map[string]interface{}{ + "watchNamespace": nil, + }, + expectedErrMsgFragment: "additional properties 'watchNamespace' not allowed", + }, + { + name: "watchNamespace is not a config option and it is not set (config empty)", + supportedInstallModes: notRequiredConfigSet, + rawConfig: map[string]interface{}{}, + expectedConfig: nil, + }, + { + name: "watchNamespace is not a config option and it is not set (config nil)", + supportedInstallModes: notRequiredConfigSet, + rawConfig: nil, + expectedConfig: nil, + }, + } { + t.Run(tt.name, func(t *testing.T) { + rv1 := bundle.RegistryV1{ + CSV: MakeCSV(WithInstallModeSupportFor(tt.supportedInstallModes...)), + } + + cfg, err := bundlecfg.Unmarshall(rv1, installNamespace, tt.rawConfig) + + if tt.expectedErrMsgFragment == "" { + require.NoError(t, err) + require.Equal(t, tt.expectedConfig, cfg) + } else { + require.Error(t, err) + require.Contains(t, err.Error(), tt.expectedErrMsgFragment) + } + }) + } +} diff --git a/internal/operator-controller/rukpak/convert/helm.go b/internal/operator-controller/rukpak/convert/helm.go index 4a354b5697..9c51343713 100644 --- a/internal/operator-controller/rukpak/convert/helm.go +++ b/internal/operator-controller/rukpak/convert/helm.go @@ -7,7 +7,7 @@ import ( "helm.sh/helm/v3/pkg/chart" - bundlepkg "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle" + "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle/bundlecfg" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle/source" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render" ) @@ -24,15 +24,6 @@ func (r *BundleToHelmChartConverter) ToHelmChart(bundle source.BundleSource, ins return nil, err } - opts := []render.Option{ - render.WithCertificateProvider(r.CertificateProvider), - } - if config != nil { - if watchNs, ok := config[bundlepkg.BundleConfigWatchNamespaceKey].(string); ok { - opts = append(opts, render.WithTargetNamespaces(watchNs)) - } - } - if len(rv1.CSV.Spec.APIServiceDefinitions.Owned) > 0 { return nil, fmt.Errorf("unsupported bundle: apiServiceDefintions are not supported") } @@ -49,6 +40,18 @@ func (r *BundleToHelmChartConverter) ToHelmChart(bundle source.BundleSource, ins return nil, fmt.Errorf("unsupported bundle: webhookDefinitions are not supported") } + opts := []render.Option{ + render.WithCertificateProvider(r.CertificateProvider), + } + + bundleConfig, err := bundlecfg.Unmarshall(rv1, installNamespace, config) + if err != nil { + return nil, err + } + if bundleConfig != nil && bundleConfig.WatchNamespace != "" { + opts = append(opts, render.WithTargetNamespaces(bundleConfig.WatchNamespace)) + } + objs, err := r.BundleRenderer.Render(rv1, installNamespace, opts...) if err != nil { diff --git a/internal/operator-controller/rukpak/convert/helm_test.go b/internal/operator-controller/rukpak/convert/helm_test.go index 0151f73056..39d8fca435 100644 --- a/internal/operator-controller/rukpak/convert/helm_test.go +++ b/internal/operator-controller/rukpak/convert/helm_test.go @@ -32,6 +32,21 @@ func Test_BundleToHelmChartConverter_ToHelmChart_ReturnsBundleSourceFailures(t * require.Contains(t, err.Error(), "some error") } +func Test_BundleToHelmChartConverter_ToHelmChart_ReturnsBundleConfigFailures(t *testing.T) { + converter := convert.BundleToHelmChartConverter{} + b := source.FromBundle( + bundle.RegistryV1{ + CSV: MakeCSV(WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces)), + }, + ) + config := map[string]interface{}{ + bundle.BundleConfigWatchNamespaceKey: "some-namespace", + } + _, err := converter.ToHelmChart(b, "install-namespace", config) + require.Error(t, err) + require.Contains(t, err.Error(), "'watchNamespace' not allowed") +} + func Test_BundleToHelmChartConverter_ToHelmChart_ReturnsBundleRendererFailures(t *testing.T) { converter := convert.BundleToHelmChartConverter{ BundleRenderer: render.BundleRenderer{ @@ -45,12 +60,12 @@ func Test_BundleToHelmChartConverter_ToHelmChart_ReturnsBundleRendererFailures(t b := source.FromBundle( bundle.RegistryV1{ - CSV: MakeCSV(WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces)), + CSV: MakeCSV(WithInstallModeSupportFor(v1alpha1.InstallModeTypeSingleNamespace)), }, ) config := map[string]interface{}{ - bundle.BundleConfigWatchNamespaceKey: "", + bundle.BundleConfigWatchNamespaceKey: "some-namespace", } _, err := converter.ToHelmChart(b, "install-namespace", config) require.Error(t, err) @@ -121,14 +136,14 @@ func Test_BundleToHelmChartConverter_ToHelmChart_WebhooksWithCertProvider(t *tes b := source.FromBundle( bundle.RegistryV1{ CSV: MakeCSV( - WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces), + WithInstallModeSupportFor(v1alpha1.InstallModeTypeSingleNamespace), WithWebhookDefinitions(v1alpha1.WebhookDescription{}), ), }, ) config := map[string]interface{}{ - bundle.BundleConfigWatchNamespaceKey: "", + bundle.BundleConfigWatchNamespaceKey: "some-namespace", } _, err := converter.ToHelmChart(b, "install-namespace", config) require.NoError(t, err) @@ -136,7 +151,7 @@ func Test_BundleToHelmChartConverter_ToHelmChart_WebhooksWithCertProvider(t *tes func Test_BundleToHelmChartConverter_ToHelmChart_BundleRendererIntegration(t *testing.T) { expectedInstallNamespace := "install-namespace" - expectedWatchNamespace := "" + expectedWatchNamespace := "some-namespaces" expectedCertProvider := FakeCertProvider{} converter := convert.BundleToHelmChartConverter{ @@ -156,7 +171,7 @@ func Test_BundleToHelmChartConverter_ToHelmChart_BundleRendererIntegration(t *te b := source.FromBundle( bundle.RegistryV1{ - CSV: MakeCSV(WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces)), + CSV: MakeCSV(WithInstallModeSupportFor(v1alpha1.InstallModeTypeSingleNamespace)), }, ) @@ -186,7 +201,7 @@ func Test_BundleToHelmChartConverter_ToHelmChart_Success(t *testing.T) { bundle.RegistryV1{ CSV: MakeCSV( WithAnnotations(map[string]string{"foo": "bar"}), - WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces), + WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces, v1alpha1.InstallModeTypeSingleNamespace), ), Others: []unstructured.Unstructured{ *ToUnstructuredT(t, &corev1.Service{ @@ -203,7 +218,7 @@ func Test_BundleToHelmChartConverter_ToHelmChart_Success(t *testing.T) { ) config := map[string]interface{}{ - bundle.BundleConfigWatchNamespaceKey: "", + bundle.BundleConfigWatchNamespaceKey: "some-namespace", } chart, err := converter.ToHelmChart(b, "install-namespace", config) require.NoError(t, err)