diff --git a/api/v1/clusterextension_types.go b/api/v1/clusterextension_types.go index 0141f1a7a4..c7a7ef62c2 100644 --- a/api/v1/clusterextension_types.go +++ b/api/v1/clusterextension_types.go @@ -17,6 +17,7 @@ limitations under the License. package v1 import ( + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -92,6 +93,13 @@ type ClusterExtensionSpec struct { // // +optional Install *ClusterExtensionInstallConfig `json:"install,omitempty"` + + // config contains arbitrary JSON configuration values to be applied at render time. + // These values will be merged into the bundle manifests during rendering. + // +optional + // +kubebuilder:validation:Type=object + // +kubebuilder:pruning:PreserveUnknownFields + Config *apiextensionsv1.JSON `json:"config,omitempty"` } const SourceTypeCatalog = "Catalog" diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index 23fcf7d85e..f0c98bc90b 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -330,6 +330,10 @@ func (in *ClusterExtensionSpec) DeepCopyInto(out *ClusterExtensionSpec) { *out = new(ClusterExtensionInstallConfig) (*in).DeepCopyInto(*out) } + if in.Config != nil { + in, out := &in.Config, &out.Config + *out = (*in).DeepCopy() + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterExtensionSpec. diff --git a/cmd/demo-config/main.go b/cmd/demo-config/main.go new file mode 100644 index 0000000000..625915fd38 --- /dev/null +++ b/cmd/demo-config/main.go @@ -0,0 +1,118 @@ +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "strings" + + "sigs.k8s.io/yaml" + + "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/engine" + + "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle/source" + "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render" + "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render/registryv1" +) + +func main() { + helmFlag := flag.Bool("helm", false, "render as Helm chart instead of registry+v1 bundle") + flag.Parse() + if flag.NArg() != 2 { + fmt.Fprintf(os.Stderr, "usage: %s [-helm] \n", os.Args[0]) + os.Exit(1) + } + bundleDir := flag.Arg(0) + installNs := flag.Arg(1) + + // load optional configuration values from config.yaml + cfg := map[string]interface{}{} + configFile := filepath.Join(bundleDir, "config.yaml") + if data, err := os.ReadFile(configFile); err == nil { + if err := yaml.Unmarshal(data, &cfg); err != nil { + fmt.Fprintf(os.Stderr, "error unmarshalling config file %q: %v\n", configFile, err) + os.Exit(1) + } + } + + if *helmFlag { + // ensure a Helm chart is present + // look for Chart.yaml in the specified dir, or try replacing "bundles" -> "charts" if not found + chartPath := filepath.Join(bundleDir, "Chart.yaml") + if _, err := os.Stat(chartPath); err != nil { + // fallback: swap a "bundles" segment to "charts" in the path + parts := strings.Split(bundleDir, string(os.PathSeparator)) + for i, p := range parts { + if p == "bundles" { + parts[i] = "charts" + altDir := filepath.Join(parts...) + if _, err2 := os.Stat(filepath.Join(altDir, "Chart.yaml")); err2 == nil { + bundleDir = altDir + chartPath = filepath.Join(bundleDir, "Chart.yaml") + break + } + } + } + if _, err := os.Stat(chartPath); err != nil { + fmt.Fprintf(os.Stderr, "error: helm chart not found in %q: %v\n", bundleDir, err) + os.Exit(1) + } + } + // render with the Helm engine + chrt, err := loader.Load(bundleDir) + if err != nil { + fmt.Fprintf(os.Stderr, "error loading Helm chart: %v\n", err) + os.Exit(1) + } + // load values.yaml and apply any config overrides + values := map[string]interface{}{} + valuesFile := filepath.Join(bundleDir, "values.yaml") + if data, err := os.ReadFile(valuesFile); err == nil { + if err := yaml.Unmarshal(data, &values); err != nil { + fmt.Fprintf(os.Stderr, "error unmarshalling values file %q: %v\n", valuesFile, err) + os.Exit(1) + } + } + for k, v := range cfg { + values[k] = v + } + // render the chart templates using the provided values under the 'Values' key + renderContext := chartutil.Values{"Values": values} + rendered, err := engine.Engine{}.Render(chrt, renderContext) + if err != nil { + fmt.Fprintf(os.Stderr, "error rendering Helm chart: %v\n", err) + os.Exit(1) + } + for name, content := range rendered { + fmt.Printf("---\n# Source: %s\n%s\n", name, content) + } + return + } + + // parse registry+v1 bundle + regv1, err := source.FromFS(os.DirFS(bundleDir)).GetBundle() + if err != nil { + fmt.Fprintf(os.Stderr, "error parsing bundle directory: %v\n", err) + os.Exit(1) + } + + // render bundle with configuration + opts := []render.Option{render.WithConfig(cfg)} + objs, err := registryv1.Renderer.Render(regv1, installNs, opts...) + if err != nil { + fmt.Fprintf(os.Stderr, "error rendering bundle: %v\n", err) + os.Exit(1) + } + + for _, obj := range objs { + data, err := yaml.Marshal(obj) + if err != nil { + fmt.Fprintf(os.Stderr, "error marshaling object: %v\n", err) + os.Exit(1) + } + fmt.Printf("---\n%s", string(data)) + } +} diff --git a/config/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensions.yaml b/config/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensions.yaml index 162683603d..eef8d5195c 100644 --- a/config/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensions.yaml +++ b/config/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensions.yaml @@ -57,6 +57,12 @@ spec: description: spec is an optional field that defines the desired state of the ClusterExtension. properties: + config: + description: |- + config contains arbitrary JSON configuration values to be applied at render time. + These values will be merged into the bundle manifests during rendering. + type: object + x-kubernetes-preserve-unknown-fields: true install: description: |- install is an optional field used to configure the installation options diff --git a/config/base/operator-controller/crd/standard/olm.operatorframework.io_clusterextensions.yaml b/config/base/operator-controller/crd/standard/olm.operatorframework.io_clusterextensions.yaml index 18faa59789..91fe222ec3 100644 --- a/config/base/operator-controller/crd/standard/olm.operatorframework.io_clusterextensions.yaml +++ b/config/base/operator-controller/crd/standard/olm.operatorframework.io_clusterextensions.yaml @@ -57,6 +57,12 @@ spec: description: spec is an optional field that defines the desired state of the ClusterExtension. properties: + config: + description: |- + config contains arbitrary JSON configuration values to be applied at render time. + These values will be merged into the bundle manifests during rendering. + type: object + x-kubernetes-preserve-unknown-fields: true install: description: |- install is an optional field used to configure the installation options diff --git a/docs/tutorials/install-extension.md b/docs/tutorials/install-extension.md index b71d5a58c0..72a2f689f8 100644 --- a/docs/tutorials/install-extension.md +++ b/docs/tutorials/install-extension.md @@ -31,9 +31,9 @@ For information on determining the ServiceAccount's permission, please see [Deri ## Procedure -1. Create a CR for the Kubernetes extension you want to install: +1. Create a CR for the Kubernetes extension you want to install. You can also specify arbitrary configuration values under `spec.config` (per [RFC: Registry+v1 Configuration Support](../../RFC_Config_registry+v1_bundle_config.md)): - ``` yaml title="Example CR" + ```yaml title="Example CR" apiVersion: olm.operatorframework.io/v1 kind: ClusterExtension metadata: @@ -46,8 +46,11 @@ For information on determining the ServiceAccount's permission, please see [Deri sourceType: Catalog catalog: packageName: - channels: [,,] version: "" + config: + version: "v2.0.0-demo" + name: "demo-configmap" ``` `extension_name` diff --git a/hack/tools/crd-generator/testdata/api/v1/clusterextension_types.go b/hack/tools/crd-generator/testdata/api/v1/clusterextension_types.go index c5c04d5b93..4056138e25 100644 --- a/hack/tools/crd-generator/testdata/api/v1/clusterextension_types.go +++ b/hack/tools/crd-generator/testdata/api/v1/clusterextension_types.go @@ -93,6 +93,11 @@ type ClusterExtensionSpec struct { // // +optional Install *ClusterExtensionInstallConfig `json:"install,omitempty"` + + // config contains arbitrary configuration values to be applied at render time. + // These values will be merged into the bundle manifests during rendering. + // +optional + Config map[string]interface{} `json:"config,omitempty"` } const SourceTypeCatalog = "Catalog" diff --git a/internal/operator-controller/applier/helm.go b/internal/operator-controller/applier/helm.go index ecfb3fdc2b..c6ecb68b2e 100644 --- a/internal/operator-controller/applier/helm.go +++ b/internal/operator-controller/applier/helm.go @@ -3,6 +3,7 @@ package applier import ( "bytes" "context" + "encoding/json" "errors" "fmt" "io" @@ -126,7 +127,14 @@ func (h *Helm) Apply(ctx context.Context, contentFS fs.FS, ext *ocv1.ClusterExte if err != nil { return nil, "", err } - values := chartutil.Values{} + // merge in any user-provided config JSON as Helm values + valuesMap := map[string]interface{}{} + if ext.Spec.Config != nil && ext.Spec.Config.Raw != nil { + if err := json.Unmarshal(ext.Spec.Config.Raw, &valuesMap); err != nil { + return nil, "", fmt.Errorf("invalid JSON in spec.config: %w", err) + } + } + values := chartutil.Values{"Values": valuesMap} post := &postrenderer{ labels: objectLabels, diff --git a/internal/operator-controller/rukpak/render/registryv1/generators/generators.go b/internal/operator-controller/rukpak/render/registryv1/generators/generators.go index 3fdbf943c2..f6c632921c 100644 --- a/internal/operator-controller/rukpak/render/registryv1/generators/generators.go +++ b/internal/operator-controller/rukpak/render/registryv1/generators/generators.go @@ -14,6 +14,7 @@ import ( rbacv1 "k8s.io/api/rbac/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/utils/ptr" @@ -281,6 +282,30 @@ func BundleAdditionalResourcesGenerator(rv1 *bundle.RegistryV1, opts render.Opti objs = append(objs, obj) } + // apply overrides from configuration, if present + if opts.Config != nil { + for _, obj := range objs { + if obj.GetObjectKind().GroupVersionKind().Kind == "ConfigMap" { + u, ok := obj.(*unstructured.Unstructured) + if !ok { + continue + } + data, found, err := unstructured.NestedStringMap(u.Object, "data") + if err != nil { + return nil, err + } + if !found { + data = map[string]string{} + } + for k, v := range opts.Config { + data[k] = fmt.Sprintf("%v", v) + } + if err := unstructured.SetNestedStringMap(u.Object, data, "data"); err != nil { + return nil, err + } + } + } + } return objs, nil } diff --git a/internal/operator-controller/rukpak/render/render.go b/internal/operator-controller/rukpak/render/render.go index 70063f1d48..9bf8827a3e 100644 --- a/internal/operator-controller/rukpak/render/render.go +++ b/internal/operator-controller/rukpak/render/render.go @@ -61,6 +61,8 @@ type Options struct { TargetNamespaces []string UniqueNameGenerator UniqueNameGenerator CertificateProvider CertificateProvider + // Config holds arbitrary configuration values provided at render time + Config map[string]interface{} } func (o *Options) apply(opts ...Option) *Options { @@ -94,6 +96,13 @@ func WithTargetNamespaces(namespaces ...string) Option { } } +// WithConfig supplies configuration values to be used during bundle rendering +func WithConfig(cfg map[string]interface{}) Option { + return func(o *Options) { + o.Config = cfg + } +} + func WithUniqueNameGenerator(generator UniqueNameGenerator) Option { return func(o *Options) { o.UniqueNameGenerator = generator diff --git a/manifests/experimental-e2e.yaml b/manifests/experimental-e2e.yaml index d3adf46e5b..a05e2ddc7b 100644 --- a/manifests/experimental-e2e.yaml +++ b/manifests/experimental-e2e.yaml @@ -511,6 +511,12 @@ spec: description: spec is an optional field that defines the desired state of the ClusterExtension. properties: + config: + description: |- + config contains arbitrary JSON configuration values to be applied at render time. + These values will be merged into the bundle manifests during rendering. + type: object + x-kubernetes-preserve-unknown-fields: true install: description: |- install is an optional field used to configure the installation options diff --git a/manifests/experimental.yaml b/manifests/experimental.yaml index 7b0d2b9a3c..d9929fa1c0 100644 --- a/manifests/experimental.yaml +++ b/manifests/experimental.yaml @@ -511,6 +511,12 @@ spec: description: spec is an optional field that defines the desired state of the ClusterExtension. properties: + config: + description: |- + config contains arbitrary JSON configuration values to be applied at render time. + These values will be merged into the bundle manifests during rendering. + type: object + x-kubernetes-preserve-unknown-fields: true install: description: |- install is an optional field used to configure the installation options diff --git a/manifests/standard-e2e.yaml b/manifests/standard-e2e.yaml index a8aff9838e..fb19efa4c4 100644 --- a/manifests/standard-e2e.yaml +++ b/manifests/standard-e2e.yaml @@ -511,6 +511,12 @@ spec: description: spec is an optional field that defines the desired state of the ClusterExtension. properties: + config: + description: |- + config contains arbitrary JSON configuration values to be applied at render time. + These values will be merged into the bundle manifests during rendering. + type: object + x-kubernetes-preserve-unknown-fields: true install: description: |- install is an optional field used to configure the installation options diff --git a/manifests/standard.yaml b/manifests/standard.yaml index fa25463055..0d41c80f80 100644 --- a/manifests/standard.yaml +++ b/manifests/standard.yaml @@ -511,6 +511,12 @@ spec: description: spec is an optional field that defines the desired state of the ClusterExtension. properties: + config: + description: |- + config contains arbitrary JSON configuration values to be applied at render time. + These values will be merged into the bundle manifests during rendering. + type: object + x-kubernetes-preserve-unknown-fields: true install: description: |- install is an optional field used to configure the installation options diff --git a/test/convert/generate-manifests.go b/test/convert/generate-manifests.go index 147b05e36d..9b17fd380c 100644 --- a/test/convert/generate-manifests.go +++ b/test/convert/generate-manifests.go @@ -68,8 +68,20 @@ func generateManifests(outputPath, bundleDir, installNamespace, watchNamespace s os.Exit(1) } - // Convert RegistryV1 to plain manifests - objs, err := registryv1.Renderer.Render(regv1, installNamespace, render.WithTargetNamespaces(watchNamespace)) + // load optional configuration for registry+v1 bundle if present + cfg := map[string]interface{}{} + configFile := filepath.Join(bundleDir, "config.yaml") + if data, err := os.ReadFile(configFile); err == nil { + if err := yaml.Unmarshal(data, &cfg); err != nil { + return fmt.Errorf("error unmarshalling config file %q: %w", configFile, err) + } + } + // Convert RegistryV1 to plain manifests, applying any configuration + opts := []render.Option{render.WithTargetNamespaces(watchNamespace)} + if cfg != nil { + opts = append(opts, render.WithConfig(cfg)) + } + objs, err := registryv1.Renderer.Render(regv1, installNamespace, opts...) if err != nil { return fmt.Errorf("error converting registry+v1 bundle: %w", err) } diff --git a/testdata/images/bundles/test-operator/v2.0.0/config.yaml b/testdata/images/bundles/test-operator/v2.0.0/config.yaml new file mode 100644 index 0000000000..2f74f97587 --- /dev/null +++ b/testdata/images/bundles/test-operator/v2.0.0/config.yaml @@ -0,0 +1,2 @@ +version: "v2.0.0-demo" +name: "demo-configmap" diff --git a/testdata/images/charts/test-operator/v2.0.0/Chart.yaml b/testdata/images/charts/test-operator/v2.0.0/Chart.yaml new file mode 100644 index 0000000000..ca6a6c3948 --- /dev/null +++ b/testdata/images/charts/test-operator/v2.0.0/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v2 +name: test-operator +version: 2.0.0 +appVersion: v2.0.0 diff --git a/testdata/images/charts/test-operator/v2.0.0/config.yaml b/testdata/images/charts/test-operator/v2.0.0/config.yaml new file mode 100644 index 0000000000..15bbf424eb --- /dev/null +++ b/testdata/images/charts/test-operator/v2.0.0/config.yaml @@ -0,0 +1,2 @@ +version: "v2.0.0-demo" +name: "demo-configmap" diff --git a/testdata/images/charts/test-operator/v2.0.0/templates/configmap.yaml b/testdata/images/charts/test-operator/v2.0.0/templates/configmap.yaml new file mode 100644 index 0000000000..b8ad793664 --- /dev/null +++ b/testdata/images/charts/test-operator/v2.0.0/templates/configmap.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-configmap +data: + version: "{{ index .Values "version" }}" + name: "{{ index .Values "name" }}" diff --git a/testdata/images/charts/test-operator/v2.0.0/templates/olm.operatorframework.com_olme2etest.yaml b/testdata/images/charts/test-operator/v2.0.0/templates/olm.operatorframework.com_olme2etest.yaml new file mode 100644 index 0000000000..fcfd4aeafe --- /dev/null +++ b/testdata/images/charts/test-operator/v2.0.0/templates/olm.operatorframework.com_olme2etest.yaml @@ -0,0 +1,28 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.1 + name: olme2etests.olm.operatorframework.io +spec: + group: olm.operatorframework.io + names: + kind: OLME2ETest + listKind: OLME2ETestList + plural: olme2etests + singular: olme2etest + scope: Cluster + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + testField: + type: string diff --git a/testdata/images/charts/test-operator/v2.0.0/templates/testoperator.clusterserviceversion.yaml b/testdata/images/charts/test-operator/v2.0.0/templates/testoperator.clusterserviceversion.yaml new file mode 100644 index 0000000000..7a06196f20 --- /dev/null +++ b/testdata/images/charts/test-operator/v2.0.0/templates/testoperator.clusterserviceversion.yaml @@ -0,0 +1,151 @@ +apiVersion: operators.coreos.com/v1alpha1 +kind: ClusterServiceVersion +metadata: + annotations: + alm-examples: |- + [ + { + "apiVersion": "olme2etests.olm.operatorframework.io/v1", + "kind": "OLME2ETests", + "metadata": { + "labels": { + "app.kubernetes.io/managed-by": "kustomize", + "app.kubernetes.io/name": "test" + }, + "name": "test-sample" + }, + "spec": null + } + ] + capabilities: Basic Install + createdAt: "2024-10-24T19:21:40Z" + operators.operatorframework.io/builder: operator-sdk-v1.34.1 + operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 + name: testoperator.v2.0.0 + namespace: placeholder +spec: + apiservicedefinitions: {} + customresourcedefinitions: + owned: + - description: Configures subsections of Alertmanager configuration specific to each namespace + displayName: OLME2ETest + kind: OLME2ETest + name: olme2etests.olm.operatorframework.io + version: v1 + description: OLM E2E Testing Operator + displayName: test-operator + icon: + - base64data: "" + mediatype: "" + install: + spec: + deployments: + - label: + app.kubernetes.io/component: controller + app.kubernetes.io/name: test-operator + app.kubernetes.io/version: 2.0.0 + name: test-operator + spec: + replicas: 1 + selector: + matchLabels: + app: olme2etest + template: + metadata: + labels: + app: olme2etest + spec: + terminationGracePeriodSeconds: 0 + containers: + - name: busybox + image: busybox + command: + - 'sleep' + - '1000' + securityContext: + runAsUser: 1000 + runAsNonRoot: true + serviceAccountName: simple-bundle-manager + clusterPermissions: + - rules: + - apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create + - apiGroups: + - authorization.k8s.io + resources: + - subjectaccessreviews + verbs: + - create + serviceAccountName: simple-bundle-manager + permissions: + - rules: + - apiGroups: + - "" + resources: + - configmaps + - serviceaccounts + verbs: + - get + - list + - watch + - create + - update + - patch + - delete + - apiGroups: + - networking.k8s.io + resources: + - networkpolicies + verbs: + - get + - list + - create + - update + - delete + - apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - get + - list + - watch + - create + - update + - patch + - delete + - apiGroups: + - "" + resources: + - events + verbs: + - create + - patch + serviceAccountName: simple-bundle-manager + strategy: deployment + installModes: + - supported: false + type: OwnNamespace + - supported: false + type: SingleNamespace + - supported: false + type: MultiNamespace + - supported: true + type: AllNamespaces + keywords: + - registry + links: + - name: simple-bundle + url: https://simple-bundle.domain + maintainers: + - email: main#simple-bundle.domain + name: Simple Bundle + maturity: beta + provider: + name: Simple Bundle + url: https://simple-bundle.domain + version: 2.0.0 diff --git a/testdata/images/charts/test-operator/v2.0.0/templates/testoperator.networkpolicy.yaml b/testdata/images/charts/test-operator/v2.0.0/templates/testoperator.networkpolicy.yaml new file mode 100644 index 0000000000..d87648e6f3 --- /dev/null +++ b/testdata/images/charts/test-operator/v2.0.0/templates/testoperator.networkpolicy.yaml @@ -0,0 +1,8 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: test-operator-network-policy +spec: + podSelector: {} + policyTypes: + - Ingress diff --git a/testdata/images/charts/test-operator/v2.0.0/values.yaml b/testdata/images/charts/test-operator/v2.0.0/values.yaml new file mode 100644 index 0000000000..547b9d31aa --- /dev/null +++ b/testdata/images/charts/test-operator/v2.0.0/values.yaml @@ -0,0 +1,2 @@ +version: "v2.0.0" +name: "test-configmap"