diff --git a/Dockerfile.rhel b/Dockerfile.rhel index ef26c34d38..9146768451 100644 --- a/Dockerfile.rhel +++ b/Dockerfile.rhel @@ -5,6 +5,7 @@ RUN make clean build FROM registry.ci.openshift.org/ocp/4.22:base-rhel9 COPY --from=builder /go/src/github.com/openshift/cluster-capi-operator/bin/capi-operator . +COPY --from=builder /go/src/github.com/openshift/cluster-capi-operator/bin/capi-installer . COPY --from=builder /go/src/github.com/openshift/cluster-capi-operator/bin/capi-controllers . COPY --from=builder /go/src/github.com/openshift/cluster-capi-operator/bin/machine-api-migration . COPY --from=builder /go/src/github.com/openshift/cluster-capi-operator/bin/crd-compatibility-checker . diff --git a/Makefile b/Makefile index ff77787fc2..e03ae01522 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ CONTAINER_TOOL ?= docker PROJECT_DIR := $(shell dirname $(abspath $(lastword $(MAKEFILE_LIST)))) # ENVTEST_K8S_VERSION refers to the version of kubebuilder assets to be downloaded by envtest binary. -ENVTEST_K8S_VERSION = 1.33.2 +ENVTEST_K8S_VERSION = 1.35.1 ENVTEST = go run -mod=vendor ${PROJECT_DIR}/vendor/sigs.k8s.io/controller-runtime/tools/setup-envtest GOLANGCI_LINT = go run -mod=vendor ${PROJECT_DIR}/vendor/github.com/golangci/golangci-lint/v2/cmd/golangci-lint @@ -30,7 +30,7 @@ verify: fmt lint verify-ocp-manifests ## Run formatting and linting checks test: verify unit ## Run verification and unit tests -build: bin/capi-operator bin/capi-controllers bin/machine-api-migration bin/crd-compatibility-checker manifests-gen ## Build all binaries +build: bin/capi-operator bin/capi-installer bin/capi-controllers bin/machine-api-migration bin/crd-compatibility-checker manifests-gen ## Build all binaries clean: rm -rf bin/* diff --git a/cmd/capi-installer/main.go b/cmd/capi-installer/main.go new file mode 100644 index 0000000000..ef4b8caec2 --- /dev/null +++ b/cmd/capi-installer/main.go @@ -0,0 +1,193 @@ +// Copyright 2026 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + "time" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/apimachinery/pkg/util/sets" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/utils/ptr" + + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/cache" + + configv1 "github.com/openshift/api/config/v1" + operatorv1alpha1 "github.com/openshift/api/operator/v1alpha1" + + "github.com/openshift/cluster-capi-operator/pkg/commoncmdoptions" + "github.com/openshift/cluster-capi-operator/pkg/controllers" + "github.com/openshift/cluster-capi-operator/pkg/controllers/installer" + "github.com/openshift/cluster-capi-operator/pkg/controllers/revision" + "github.com/openshift/cluster-capi-operator/pkg/providerimages" + "github.com/openshift/cluster-capi-operator/pkg/util" +) + +var errPodIdentityNotSet = errors.New("POD_NAME and POD_NAMESPACE must be set") + +const ( + managerName = "capi-installer" +) + +func initScheme(scheme *runtime.Scheme) { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(configv1.AddToScheme(scheme)) + utilruntime.Must(apiextensionsv1.AddToScheme(scheme)) + utilruntime.Must(appsv1.AddToScheme(scheme)) + utilruntime.Must(operatorv1alpha1.AddToScheme(scheme)) +} + +func main() { + ctx, cancel := context.WithCancel(ctrl.SetupSignalHandler()) + cfg := ctrl.GetConfigOrDie() + + scheme := runtime.NewScheme() + initScheme(scheme) + + extraflags := flag.NewFlagSet("", flag.ContinueOnError) + providerImageDir := extraflags.String( + "provider-image-dir", + providerimages.ProviderImageMountBase, + "Directory containing provider image manifests. In dev mode, set to a local directory to skip pod spec reading.", + ) + + log, operatorConfig, mgrOpts, initManager, err := commoncmdoptions.InitOperatorConfig(ctx, cfg, scheme, managerName, controllers.DefaultOperatorNamespace, extraflags) + if err != nil { + log.Error(err, "unable to initialize operator config") + os.Exit(1) + } + + mgrOpts.Cache = cache.Options{ + DefaultNamespaces: map[string]cache.Config{ + *operatorConfig.CAPINamespace: {}, + *operatorConfig.OperatorNamespace: {}, + }, + SyncPeriod: ptr.To(10 * time.Minute), + } + + mgr, err := initManager(ctx, cancel, mgrOpts) + if err != nil { + log.Error(err, "unable to initialize manager") + os.Exit(1) + } + + if err := setupControllers(ctx, mgr, operatorConfig, *providerImageDir); err != nil { + log.Error(err, "unable to setup controllers") + os.Exit(1) + } + + log.Info("Starting " + managerName + " manager") + + if err := mgr.Start(ctx); err != nil { + log.Error(err, "problem running manager") + os.Exit(1) + } +} + +func setupControllers(ctx context.Context, mgr ctrl.Manager, operatorConfig commoncmdoptions.OperatorConfig, providerImageDir string) error { + allProviderProfiles, err := loadProviderImages(ctx, mgr, providerImageDir) + if err != nil { + return err + } + + currentReleaseRefs, err := loadCurrentReleaseImageRefs(ctx, mgr, *operatorConfig.OperatorNamespace) + if err != nil { + return err + } + + currentReleaseProfiles := make([]providerimages.ProviderImageManifests, 0, len(allProviderProfiles)) + for _, profile := range allProviderProfiles { + if currentReleaseRefs.Has(profile.ImageRef) { + currentReleaseProfiles = append(currentReleaseProfiles, profile) + } + } + + log := ctrl.LoggerFrom(ctx) + for _, profile := range allProviderProfiles { + log.Info("loaded provider profile", "name", profile.Name, "imageRef", profile.ImageRef, "profile", profile.Profile) + } + + if err := (&revision.RevisionController{ + Client: mgr.GetClient(), + ProviderProfiles: currentReleaseProfiles, + ReleaseVersion: util.GetReleaseVersion(), + }).SetupWithManager(mgr, operatorConfig.TLSOptions); err != nil { + log.Error(err, "unable to create revision controller", "controller", "RevisionController") + return fmt.Errorf("unable to create revision controller: %w", err) + } + + if err := installer.SetupWithManager(mgr, allProviderProfiles); err != nil { + return fmt.Errorf("unable to create installer controller: %w", err) + } + + return nil +} + +func loadProviderImages(ctx context.Context, mgr ctrl.Manager, providerImageDir string) ([]providerimages.ProviderImageManifests, error) { + podName := os.Getenv("POD_NAME") + podNamespace := os.Getenv("POD_NAMESPACE") + + if podName == "" || podNamespace == "" { + return nil, errPodIdentityNotSet + } + + var pod corev1.Pod + if err := mgr.GetAPIReader().Get(ctx, types.NamespacedName{Name: podName, Namespace: podNamespace}, &pod); err != nil { + return nil, fmt.Errorf("unable to get pod %s/%s: %w", podNamespace, podName, err) + } + + imageRefMap, err := providerimages.BuildImageRefMap(pod.Spec, managerName) + if err != nil { + return nil, fmt.Errorf("unable to build image ref map from pod spec: %w", err) + } + + log := ctrl.LoggerFrom(ctx) + + providerProfiles, err := providerimages.ScanProviderImages(log, providerImageDir, imageRefMap) + if err != nil { + return nil, fmt.Errorf("unable to scan provider images: %w", err) + } + + return providerProfiles, nil +} + +func loadCurrentReleaseImageRefs(ctx context.Context, mgr ctrl.Manager, operatorNamespace string) (sets.Set[string], error) { + configMap := &corev1.ConfigMap{} + + if err := mgr.GetAPIReader().Get(ctx, types.NamespacedName{ + Name: providerimages.ConfigMapName, + Namespace: operatorNamespace, + }, configMap); err != nil { + return nil, fmt.Errorf("unable to get ConfigMap %s/%s: %w", operatorNamespace, providerimages.ConfigMapName, err) + } + + imageRefs, err := providerimages.ImageRefsFromConfigMap(configMap) + if err != nil { + return nil, fmt.Errorf("unable to extract image refs from ConfigMap: %w", err) + } + + return imageRefs, nil +} diff --git a/cmd/capi-operator/main.go b/cmd/capi-operator/main.go index d2ede0aa09..21d0c2e673 100644 --- a/cmd/capi-operator/main.go +++ b/cmd/capi-operator/main.go @@ -17,14 +17,12 @@ package main import ( "context" "errors" - "flag" "fmt" "os" "time" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" utilruntime "k8s.io/apimachinery/pkg/util/runtime" @@ -33,6 +31,7 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" "github.com/go-logr/logr" configv1 "github.com/openshift/api/config/v1" @@ -41,24 +40,22 @@ import ( "github.com/openshift/cluster-capi-operator/pkg/commoncmdoptions" "github.com/openshift/cluster-capi-operator/pkg/controllers" "github.com/openshift/cluster-capi-operator/pkg/controllers/clusteroperator" - "github.com/openshift/cluster-capi-operator/pkg/controllers/installer" - "github.com/openshift/cluster-capi-operator/pkg/controllers/revision" - "github.com/openshift/cluster-capi-operator/pkg/providerimages" + "github.com/openshift/cluster-capi-operator/pkg/controllers/installerdeployment" "github.com/openshift/cluster-capi-operator/pkg/util" ) -var errPodIdentityNotSet = errors.New("POD_NAME and POD_NAMESPACE must be set") +var ( + errPodIdentityNotSet = errors.New("POD_NAME and POD_NAMESPACE must be set") + errContainerNotInPod = errors.New("container not found in pod spec") +) const ( managerName = "capi-operator" - - defaultProviderImageDirPath = "/var/lib/provider-images" ) func initScheme(scheme *runtime.Scheme) { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(configv1.AddToScheme(scheme)) - utilruntime.Must(apiextensionsv1.AddToScheme(scheme)) utilruntime.Must(appsv1.AddToScheme(scheme)) utilruntime.Must(operatorv1alpha1.AddToScheme(scheme)) } @@ -70,14 +67,7 @@ func main() { scheme := runtime.NewScheme() initScheme(scheme) - extraflags := flag.NewFlagSet("", flag.ContinueOnError) - providerImageDir := extraflags.String( - "provider-image-dir", - defaultProviderImageDirPath, - "Directory containing provider image manifests. In dev mode, set to a local directory to skip pod spec reading.", - ) - - log, operatorConfig, mgrOpts, initManager, err := commoncmdoptions.InitOperatorConfig(ctx, cfg, scheme, managerName, controllers.DefaultOperatorNamespace, extraflags) + log, operatorConfig, mgrOpts, initManager, err := commoncmdoptions.InitOperatorConfig(ctx, cfg, scheme, managerName, controllers.DefaultOperatorNamespace, nil) if err != nil { log.Error(err, "unable to initialize operator config") os.Exit(1) @@ -97,7 +87,7 @@ func main() { os.Exit(1) } - if err := setupControllers(ctx, log, mgr, operatorConfig, *providerImageDir, cancel); err != nil { + if err := setupControllers(ctx, log, mgr, operatorConfig, cancel); err != nil { log.Error(err, "unable to setup controllers") os.Exit(1) } @@ -110,7 +100,7 @@ func main() { } } -func setupControllers(ctx context.Context, log logr.Logger, mgr ctrl.Manager, operatorConfig commoncmdoptions.OperatorConfig, providerImageDir string, cancel context.CancelFunc) error { +func setupControllers(ctx context.Context, log logr.Logger, mgr ctrl.Manager, operatorConfig commoncmdoptions.OperatorConfig, cancel context.CancelFunc) error { infra, err := util.GetInfra(ctx, mgr.GetAPIReader()) if err != nil { return fmt.Errorf("unable to get infrastructure: %w", err) @@ -136,63 +126,45 @@ func setupControllers(ctx context.Context, log logr.Logger, mgr ctrl.Manager, op return fmt.Errorf("unable to create clusteroperator controller: %w", err) } - // The ClusterOperatorController MUST run if we were installed, otherwise - // our ClusterOperator will not be reconciled and installation will not - // progress. We don't run any other controllers if the current platform is - // not supported. - if !supportedPlatform { - return nil - } - - providerProfiles, err := loadProviderImages(ctx, mgr, providerImageDir) + // Get container image from own pod spec + containerImage, err := getContainerImage(ctx, mgr.GetAPIReader()) if err != nil { - return err - } - - for _, profile := range providerProfiles { - log.Info("loaded provider profile", "name", profile.Name, "imageRef", profile.ImageRef, "profile", profile.Profile) - } - - if err := (&revision.RevisionController{ - Client: mgr.GetClient(), - ProviderProfiles: providerProfiles, - ReleaseVersion: util.GetReleaseVersion(), - }).SetupWithManager(mgr, operatorConfig.TLSOptions); err != nil { - log.Error(err, "unable to create revision controller", "controller", "RevisionController") - return fmt.Errorf("unable to create revision controller: %w", err) + return fmt.Errorf("unable to get container image: %w", err) } - if err := installer.SetupWithManager(mgr, providerProfiles); err != nil { - return fmt.Errorf("unable to create installer controller: %w", err) + // Setup InstallerDeploymentController (runs on all platforms) + if err := (&installerdeployment.InstallerDeploymentReconciler{ + Client: mgr.GetClient(), + Namespace: *operatorConfig.OperatorNamespace, + ContainerImage: containerImage, + SupportedPlatform: supportedPlatform, + }).SetupWithManager(mgr); err != nil { + return fmt.Errorf("unable to create installerdeployment controller: %w", err) } return nil } -func loadProviderImages(ctx context.Context, mgr ctrl.Manager, providerImageDir string) ([]providerimages.ProviderImageManifests, error) { +// getContainerImage reads the container image from the capi-operator pod spec. +func getContainerImage(ctx context.Context, k8sClient client.Reader) (string, error) { podName := os.Getenv("POD_NAME") - podNamespace := os.Getenv("POD_NAMESPACE") + if podName == "" || podNamespace == "" { - return nil, errPodIdentityNotSet + return "", errPodIdentityNotSet } var pod corev1.Pod - if err := mgr.GetAPIReader().Get(ctx, types.NamespacedName{Name: podName, Namespace: podNamespace}, &pod); err != nil { - return nil, fmt.Errorf("unable to get pod %s/%s: %w", podNamespace, podName, err) - } - - imageRefMap, err := providerimages.BuildImageRefMap(pod.Spec, managerName) - if err != nil { - return nil, fmt.Errorf("unable to build image ref map from pod spec: %w", err) + if err := k8sClient.Get(ctx, types.NamespacedName{Name: podName, Namespace: podNamespace}, &pod); err != nil { + return "", fmt.Errorf("unable to get pod %s/%s: %w", podNamespace, podName, err) } - log := ctrl.LoggerFrom(ctx) - - providerProfiles, err := providerimages.ScanProviderImages(log, providerImageDir, imageRefMap) - if err != nil { - return nil, fmt.Errorf("unable to scan provider images: %w", err) + // Find the capi-operator container + for _, container := range pod.Spec.Containers { + if container.Name == managerName { + return container.Image, nil + } } - return providerProfiles, nil + return "", fmt.Errorf("%s: %w", managerName, errContainerNotInPod) } diff --git a/manifests/0000_30_cluster-api-installer_05_deployment.yaml b/manifests/0000_30_cluster-api-installer_05_deployment.yaml deleted file mode 100644 index 68eb30f4da..0000000000 --- a/manifests/0000_30_cluster-api-installer_05_deployment.yaml +++ /dev/null @@ -1,160 +0,0 @@ ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: capi-operator - namespace: openshift-cluster-api-operator - annotations: - config.openshift.io/inject-proxy: capi-operator - include.release.openshift.io/self-managed-high-availability: "true" - include.release.openshift.io/single-node-developer: "true" - exclude.release.openshift.io/internal-openshift-hosted: "true" - release.openshift.io/feature-gate: "ClusterAPIMachineManagement" - labels: - k8s-app: capi-operator -spec: - selector: - matchLabels: - k8s-app: capi-operator - replicas: 1 - template: - metadata: - annotations: - target.workload.openshift.io/management: '{"effect": "PreferredDuringScheduling"}' - openshift.io/required-scc: restricted-v2 - labels: - k8s-app: capi-operator - spec: - serviceAccountName: capi-operator - containers: - - name: capi-operator - image: registry.ci.openshift.org/openshift:cluster-capi-operator - command: - - /capi-operator - args: - - --diagnostics-address=:8443 - env: - - name: RELEASE_VERSION - value: "0.0.1-snapshot" - - name: POD_NAME - valueFrom: - fieldRef: - fieldPath: metadata.name - - name: POD_NAMESPACE - valueFrom: - fieldRef: - fieldPath: metadata.namespace - ports: - - containerPort: 9440 - name: health - protocol: TCP - - containerPort: 8443 - name: diagnostics - protocol: TCP - resources: - requests: - cpu: 10m - memory: 50Mi - terminationMessagePolicy: FallbackToLogsOnError - volumeMounts: - - name: metrics-cert - mountPath: /tmp/k8s-metrics-server/serving-certs - readOnly: true - - name: provider-aws - mountPath: /var/lib/provider-images/aws-cluster-api-controllers - readOnly: true - - name: provider-azure - mountPath: /var/lib/provider-images/azure-cluster-api-controllers - readOnly: true - - name: provider-baremetal - mountPath: /var/lib/provider-images/baremetal-cluster-api-controllers - readOnly: true - - name: provider-cluster-capi-controllers - mountPath: /var/lib/provider-images/cluster-capi-controllers - readOnly: true - - name: provider-cluster-capi-operator - mountPath: /var/lib/provider-images/cluster-capi-operator - readOnly: true - - name: provider-gcp - mountPath: /var/lib/provider-images/gcp-cluster-api-controllers - readOnly: true - - name: provider-ibmcloud - mountPath: /var/lib/provider-images/ibmcloud-cluster-api-controllers - readOnly: true - - name: provider-openstack - mountPath: /var/lib/provider-images/openstack-cluster-api-controllers - readOnly: true - - name: provider-openstack-resource-controller - mountPath: /var/lib/provider-images/openstack-resource-controller - readOnly: true - - name: provider-vsphere - mountPath: /var/lib/provider-images/vsphere-cluster-api-controllers - readOnly: true - livenessProbe: - httpGet: - path: /healthz - port: 9440 - initialDelaySeconds: 15 - periodSeconds: 20 - readinessProbe: - httpGet: - path: /readyz - port: 9440 - initialDelaySeconds: 5 - periodSeconds: 10 - nodeSelector: - node-role.kubernetes.io/control-plane: "" - priorityClassName: system-node-critical - restartPolicy: Always - tolerations: - - key: "node-role.kubernetes.io/master" - operator: "Exists" - effect: "NoSchedule" - - key: "node-role.kubernetes.io/control-plane" - operator: "Exists" - effect: "NoSchedule" - volumes: - - name: metrics-cert - secret: - defaultMode: 420 - secretName: capi-operator-metrics-tls - - name: provider-aws - image: - reference: registry.ci.openshift.org/openshift:aws-cluster-api-controllers - pullPolicy: IfNotPresent - - name: provider-azure - image: - reference: registry.ci.openshift.org/openshift:azure-cluster-api-controllers - pullPolicy: IfNotPresent - - name: provider-baremetal - image: - reference: registry.ci.openshift.org/openshift:baremetal-cluster-api-controllers - pullPolicy: IfNotPresent - - name: provider-cluster-capi-controllers - image: - reference: registry.ci.openshift.org/openshift:cluster-capi-controllers - pullPolicy: IfNotPresent - - name: provider-cluster-capi-operator - image: - reference: registry.ci.openshift.org/openshift:cluster-capi-operator - pullPolicy: IfNotPresent - - name: provider-gcp - image: - reference: registry.ci.openshift.org/openshift:gcp-cluster-api-controllers - pullPolicy: IfNotPresent - - name: provider-ibmcloud - image: - reference: registry.ci.openshift.org/openshift:ibmcloud-cluster-api-controllers - pullPolicy: IfNotPresent - - name: provider-openstack - image: - reference: registry.ci.openshift.org/openshift:openstack-cluster-api-controllers - pullPolicy: IfNotPresent - - name: provider-openstack-resource-controller - image: - reference: registry.ci.openshift.org/openshift:openstack-resource-controller - pullPolicy: IfNotPresent - - name: provider-vsphere - image: - reference: registry.ci.openshift.org/openshift:vsphere-cluster-api-controllers - pullPolicy: IfNotPresent diff --git a/manifests/0000_30_cluster-api-installer_00_namespace.yaml b/manifests/0000_30_cluster-api-operator_00_namespace.yaml similarity index 100% rename from manifests/0000_30_cluster-api-installer_00_namespace.yaml rename to manifests/0000_30_cluster-api-operator_00_namespace.yaml diff --git a/manifests/0000_30_cluster-api-installer_00_tombstones.yaml b/manifests/0000_30_cluster-api-operator_00_tombstones.yaml similarity index 100% rename from manifests/0000_30_cluster-api-installer_00_tombstones.yaml rename to manifests/0000_30_cluster-api-operator_00_tombstones.yaml diff --git a/manifests/0000_30_cluster-api-operator_01_capi-operator-servicemonitor.yaml b/manifests/0000_30_cluster-api-operator_01_capi-operator-servicemonitor.yaml new file mode 100644 index 0000000000..5732519c1c --- /dev/null +++ b/manifests/0000_30_cluster-api-operator_01_capi-operator-servicemonitor.yaml @@ -0,0 +1,23 @@ +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + annotations: + exclude.release.openshift.io/internal-openshift-hosted: "true" + include.release.openshift.io/self-managed-high-availability: "true" + include.release.openshift.io/single-node-developer: "true" + release.openshift.io/feature-gate: "ClusterAPIMachineManagement" + name: capi-operator + namespace: openshift-cluster-api-operator +spec: + endpoints: + - bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token + port: diagnostics + scheme: https + tlsConfig: + caFile: /etc/prometheus/configmaps/serving-certs-ca-bundle/service-ca.crt + certFile: /etc/prometheus/secrets/metrics-client-certs/tls.crt + keyFile: /etc/prometheus/secrets/metrics-client-certs/tls.key + serverName: capi-operator-metrics.openshift-cluster-api-operator.svc + selector: + matchLabels: + k8s-app: capi-operator diff --git a/manifests/0000_30_cluster-api-installer_01_metrics-service.yaml b/manifests/0000_30_cluster-api-operator_01_metrics-service.yaml similarity index 100% rename from manifests/0000_30_cluster-api-installer_01_metrics-service.yaml rename to manifests/0000_30_cluster-api-operator_01_metrics-service.yaml diff --git a/manifests/0000_30_cluster-api-installer_01_serviceaccount.yaml b/manifests/0000_30_cluster-api-operator_01_serviceaccount.yaml similarity index 100% rename from manifests/0000_30_cluster-api-installer_01_serviceaccount.yaml rename to manifests/0000_30_cluster-api-operator_01_serviceaccount.yaml diff --git a/manifests/0000_30_cluster-api-operator_02_capi-installer-metrics-service.yaml b/manifests/0000_30_cluster-api-operator_02_capi-installer-metrics-service.yaml new file mode 100644 index 0000000000..bcd9f2c60b --- /dev/null +++ b/manifests/0000_30_cluster-api-operator_02_capi-installer-metrics-service.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: Service +metadata: + annotations: + exclude.release.openshift.io/internal-openshift-hosted: "true" + include.release.openshift.io/self-managed-high-availability: "true" + include.release.openshift.io/single-node-developer: "true" + release.openshift.io/feature-gate: "ClusterAPIMachineManagement" + service.beta.openshift.io/serving-cert-secret-name: capi-installer-metrics-tls + name: capi-installer-metrics + namespace: openshift-cluster-api-operator +spec: + ports: + - name: diagnostics + port: 8443 + targetPort: diagnostics + selector: + k8s-app: capi-installer + type: ClusterIP + clusterIP: None + sessionAffinity: None diff --git a/manifests/0000_30_cluster-api-operator_02_capi-installer-serviceaccount.yaml b/manifests/0000_30_cluster-api-operator_02_capi-installer-serviceaccount.yaml new file mode 100644 index 0000000000..ae0deabb92 --- /dev/null +++ b/manifests/0000_30_cluster-api-operator_02_capi-installer-serviceaccount.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + namespace: openshift-cluster-api-operator + name: capi-installer + annotations: + exclude.release.openshift.io/internal-openshift-hosted: "true" + include.release.openshift.io/self-managed-high-availability: "true" + include.release.openshift.io/single-node-developer: "true" + release.openshift.io/feature-gate: "ClusterAPIMachineManagement" diff --git a/manifests/0000_30_cluster-api-operator_02_capi-installer-servicemonitor.yaml b/manifests/0000_30_cluster-api-operator_02_capi-installer-servicemonitor.yaml new file mode 100644 index 0000000000..d26c64399c --- /dev/null +++ b/manifests/0000_30_cluster-api-operator_02_capi-installer-servicemonitor.yaml @@ -0,0 +1,23 @@ +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + annotations: + exclude.release.openshift.io/internal-openshift-hosted: "true" + include.release.openshift.io/self-managed-high-availability: "true" + include.release.openshift.io/single-node-developer: "true" + release.openshift.io/feature-gate: "ClusterAPIMachineManagement" + name: capi-installer + namespace: openshift-cluster-api-operator +spec: + endpoints: + - bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token + port: diagnostics + scheme: https + tlsConfig: + caFile: /etc/prometheus/configmaps/serving-certs-ca-bundle/service-ca.crt + certFile: /etc/prometheus/secrets/metrics-client-certs/tls.crt + keyFile: /etc/prometheus/secrets/metrics-client-certs/tls.key + serverName: capi-installer-metrics.openshift-cluster-api-operator.svc + selector: + matchLabels: + k8s-app: capi-installer diff --git a/manifests/0000_30_cluster-api-operator_02_prometheus-role.yaml b/manifests/0000_30_cluster-api-operator_02_prometheus-role.yaml new file mode 100644 index 0000000000..f185e8152b --- /dev/null +++ b/manifests/0000_30_cluster-api-operator_02_prometheus-role.yaml @@ -0,0 +1,29 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + annotations: + exclude.release.openshift.io/internal-openshift-hosted: "true" + include.release.openshift.io/self-managed-high-availability: "true" + include.release.openshift.io/single-node-developer: "true" + release.openshift.io/feature-gate: "ClusterAPIMachineManagement" + name: prometheus-k8s + namespace: openshift-cluster-api-operator +rules: +- apiGroups: + - "" + resources: + - services + - endpoints + - pods + verbs: + - get + - list + - watch +- apiGroups: + - discovery.k8s.io + resources: + - endpointslices + verbs: + - get + - list + - watch diff --git a/manifests/0000_30_cluster-api-operator_02_prometheus-rolebinding.yaml b/manifests/0000_30_cluster-api-operator_02_prometheus-rolebinding.yaml new file mode 100644 index 0000000000..35cea38f66 --- /dev/null +++ b/manifests/0000_30_cluster-api-operator_02_prometheus-rolebinding.yaml @@ -0,0 +1,18 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + annotations: + exclude.release.openshift.io/internal-openshift-hosted: "true" + include.release.openshift.io/self-managed-high-availability: "true" + include.release.openshift.io/single-node-developer: "true" + release.openshift.io/feature-gate: "ClusterAPIMachineManagement" + name: prometheus-k8s + namespace: openshift-cluster-api-operator +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: prometheus-k8s +subjects: +- kind: ServiceAccount + name: prometheus-k8s + namespace: openshift-monitoring diff --git a/manifests/0000_30_cluster-api-installer_02_clusterrole.yaml b/manifests/0000_30_cluster-api-operator_03_clusterrole.yaml similarity index 100% rename from manifests/0000_30_cluster-api-installer_02_clusterrole.yaml rename to manifests/0000_30_cluster-api-operator_03_clusterrole.yaml diff --git a/manifests/0000_30_cluster-api-operator_04_capi-installer-clusterrolebinding.yaml b/manifests/0000_30_cluster-api-operator_04_capi-installer-clusterrolebinding.yaml new file mode 100644 index 0000000000..64eb10129c --- /dev/null +++ b/manifests/0000_30_cluster-api-operator_04_capi-installer-clusterrolebinding.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: openshift-capi-installer + annotations: + exclude.release.openshift.io/internal-openshift-hosted: "true" + include.release.openshift.io/self-managed-high-availability: "true" + include.release.openshift.io/single-node-developer: "true" + release.openshift.io/feature-gate: "ClusterAPIMachineManagement" +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: openshift-capi-operator +subjects: +- kind: ServiceAccount + name: capi-installer + namespace: openshift-cluster-api-operator diff --git a/manifests/0000_30_cluster-api-installer_03_clusterrolebinding.yaml b/manifests/0000_30_cluster-api-operator_04_clusterrolebinding.yaml similarity index 100% rename from manifests/0000_30_cluster-api-installer_03_clusterrolebinding.yaml rename to manifests/0000_30_cluster-api-operator_04_clusterrolebinding.yaml diff --git a/manifests/0000_30_cluster-api-operator_05_allow-egress-operators.yaml b/manifests/0000_30_cluster-api-operator_05_allow-egress-operators.yaml new file mode 100644 index 0000000000..4d50cfbc5b --- /dev/null +++ b/manifests/0000_30_cluster-api-operator_05_allow-egress-operators.yaml @@ -0,0 +1,27 @@ +# This NetworkPolicy allows egress traffic required for the CAPI operator +# deployments in the openshift-cluster-api-operator namespace. +# The operator and installer need broad internet access for cluster management +# operations, cloud provider API calls, and communication with various services. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + annotations: + exclude.release.openshift.io/internal-openshift-hosted: "true" + include.release.openshift.io/self-managed-high-availability: "true" + include.release.openshift.io/single-node-developer: "true" + release.openshift.io/feature-gate: "ClusterAPIMachineManagement" + name: allow-egress-operators + namespace: openshift-cluster-api-operator +spec: + egress: + # Allow all egress traffic - operator needs broad access + - {} # Empty rule allows all egress + podSelector: + matchExpressions: + - key: k8s-app + operator: In + values: + - capi-operator + - capi-installer + policyTypes: + - Egress diff --git a/manifests/0000_30_cluster-api-operator_05_provider-images-configmap.yaml b/manifests/0000_30_cluster-api-operator_05_provider-images-configmap.yaml new file mode 100644 index 0000000000..cf83d63ceb --- /dev/null +++ b/manifests/0000_30_cluster-api-operator_05_provider-images-configmap.yaml @@ -0,0 +1,22 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: capi-installer-images + namespace: openshift-cluster-api-operator + annotations: + include.release.openshift.io/self-managed-high-availability: "true" + include.release.openshift.io/single-node-developer: "true" + exclude.release.openshift.io/internal-openshift-hosted: "true" + release.openshift.io/feature-gate: "ClusterAPIMachineManagement" +data: + aws-cluster-api-controllers: registry.ci.openshift.org/openshift:aws-cluster-api-controllers + azure-cluster-api-controllers: registry.ci.openshift.org/openshift:azure-cluster-api-controllers + baremetal-cluster-api-controllers: registry.ci.openshift.org/openshift:baremetal-cluster-api-controllers + cluster-capi-controllers: registry.ci.openshift.org/openshift:cluster-capi-controllers + cluster-capi-operator: registry.ci.openshift.org/openshift:cluster-capi-operator + gcp-cluster-api-controllers: registry.ci.openshift.org/openshift:gcp-cluster-api-controllers + ibmcloud-cluster-api-controllers: registry.ci.openshift.org/openshift:ibmcloud-cluster-api-controllers + openstack-cluster-api-controllers: registry.ci.openshift.org/openshift:openstack-cluster-api-controllers + openstack-resource-controller: registry.ci.openshift.org/openshift:openstack-resource-controller + vsphere-cluster-api-controllers: registry.ci.openshift.org/openshift:vsphere-cluster-api-controllers diff --git a/manifests/0000_30_cluster-api-operator_06_deployment.yaml b/manifests/0000_30_cluster-api-operator_06_deployment.yaml new file mode 100644 index 0000000000..49480bdb58 --- /dev/null +++ b/manifests/0000_30_cluster-api-operator_06_deployment.yaml @@ -0,0 +1,90 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: capi-operator + namespace: openshift-cluster-api-operator + annotations: + config.openshift.io/inject-proxy: capi-operator + include.release.openshift.io/self-managed-high-availability: "true" + include.release.openshift.io/single-node-developer: "true" + exclude.release.openshift.io/internal-openshift-hosted: "true" + release.openshift.io/feature-gate: "ClusterAPIMachineManagement" + labels: + k8s-app: capi-operator +spec: + selector: + matchLabels: + k8s-app: capi-operator + replicas: 1 + template: + metadata: + annotations: + target.workload.openshift.io/management: '{"effect": "PreferredDuringScheduling"}' + openshift.io/required-scc: restricted-v2 + labels: + k8s-app: capi-operator + spec: + serviceAccountName: capi-operator + containers: + - name: capi-operator + image: registry.ci.openshift.org/openshift:cluster-capi-operator + command: + - /capi-operator + args: + - --diagnostics-address=:8443 + env: + - name: RELEASE_VERSION + value: "0.0.1-snapshot" + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + ports: + - containerPort: 9440 + name: health + protocol: TCP + - containerPort: 8443 + name: diagnostics + protocol: TCP + resources: + requests: + cpu: 10m + memory: 50Mi + terminationMessagePolicy: FallbackToLogsOnError + volumeMounts: + - name: metrics-cert + mountPath: /tmp/k8s-metrics-server/serving-certs + readOnly: true + livenessProbe: + httpGet: + path: /healthz + port: 9440 + initialDelaySeconds: 15 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /readyz + port: 9440 + initialDelaySeconds: 5 + periodSeconds: 10 + nodeSelector: + node-role.kubernetes.io/control-plane: "" + priorityClassName: system-cluster-critical + restartPolicy: Always + tolerations: + - key: "node-role.kubernetes.io/master" + operator: "Exists" + effect: "NoSchedule" + - key: "node-role.kubernetes.io/control-plane" + operator: "Exists" + effect: "NoSchedule" + volumes: + - name: metrics-cert + secret: + defaultMode: 420 + secretName: capi-operator-metrics-tls diff --git a/manifests/0000_30_cluster-api-installer_06_clusterapi.yaml b/manifests/0000_30_cluster-api-operator_07_clusterapi.yaml similarity index 100% rename from manifests/0000_30_cluster-api-installer_06_clusterapi.yaml rename to manifests/0000_30_cluster-api-operator_07_clusterapi.yaml diff --git a/manifests/0000_30_cluster-api_12_clusteroperator.yaml b/manifests/0000_30_cluster-api-operator_08_clusteroperator.yaml similarity index 100% rename from manifests/0000_30_cluster-api_12_clusteroperator.yaml rename to manifests/0000_30_cluster-api-operator_08_clusteroperator.yaml diff --git a/manifests/0000_30_cluster-api_10_capi-controllers-servicemonitor.yaml b/manifests/0000_30_cluster-api_10_capi-controllers-servicemonitor.yaml new file mode 100644 index 0000000000..e943de1642 --- /dev/null +++ b/manifests/0000_30_cluster-api_10_capi-controllers-servicemonitor.yaml @@ -0,0 +1,31 @@ +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + annotations: + exclude.release.openshift.io/internal-openshift-hosted: "true" + include.release.openshift.io/self-managed-high-availability: "true" + include.release.openshift.io/single-node-developer: "true" + release.openshift.io/feature-gate: "ClusterAPIMachineManagement" + name: capi-controllers + namespace: openshift-cluster-api +spec: + endpoints: + - bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token + port: capi-controllers-metrics + scheme: https + tlsConfig: + caFile: /etc/prometheus/configmaps/serving-certs-ca-bundle/service-ca.crt + certFile: /etc/prometheus/secrets/metrics-client-certs/tls.crt + keyFile: /etc/prometheus/secrets/metrics-client-certs/tls.key + serverName: capi-controllers-metrics.openshift-cluster-api.svc + - bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token + port: machine-api-migration-metrics + scheme: https + tlsConfig: + caFile: /etc/prometheus/configmaps/serving-certs-ca-bundle/service-ca.crt + certFile: /etc/prometheus/secrets/metrics-client-certs/tls.crt + keyFile: /etc/prometheus/secrets/metrics-client-certs/tls.key + serverName: capi-controllers-metrics.openshift-cluster-api.svc + selector: + matchLabels: + k8s-app: capi-controllers diff --git a/manifests/0000_30_cluster-api_10_prometheus-role.yaml b/manifests/0000_30_cluster-api_10_prometheus-role.yaml new file mode 100644 index 0000000000..2153c62cb8 --- /dev/null +++ b/manifests/0000_30_cluster-api_10_prometheus-role.yaml @@ -0,0 +1,29 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + annotations: + exclude.release.openshift.io/internal-openshift-hosted: "true" + include.release.openshift.io/self-managed-high-availability: "true" + include.release.openshift.io/single-node-developer: "true" + release.openshift.io/feature-gate: "ClusterAPIMachineManagement" + name: prometheus-k8s + namespace: openshift-cluster-api +rules: +- apiGroups: + - "" + resources: + - services + - endpoints + - pods + verbs: + - get + - list + - watch +- apiGroups: + - discovery.k8s.io + resources: + - endpointslices + verbs: + - get + - list + - watch diff --git a/manifests/0000_30_cluster-api_10_prometheus-rolebinding.yaml b/manifests/0000_30_cluster-api_10_prometheus-rolebinding.yaml new file mode 100644 index 0000000000..108ad05386 --- /dev/null +++ b/manifests/0000_30_cluster-api_10_prometheus-rolebinding.yaml @@ -0,0 +1,18 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + annotations: + exclude.release.openshift.io/internal-openshift-hosted: "true" + include.release.openshift.io/self-managed-high-availability: "true" + include.release.openshift.io/single-node-developer: "true" + release.openshift.io/feature-gate: "ClusterAPIMachineManagement" + name: prometheus-k8s + namespace: openshift-cluster-api +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: prometheus-k8s +subjects: +- kind: ServiceAccount + name: prometheus-k8s + namespace: openshift-monitoring diff --git a/manifests/0000_30_cluster-api_13_allow-ingress-to-metrics-controllers.yaml b/manifests/0000_30_cluster-api_11_allow-ingress-to-metrics-controllers.yaml similarity index 100% rename from manifests/0000_30_cluster-api_13_allow-ingress-to-metrics-controllers.yaml rename to manifests/0000_30_cluster-api_11_allow-ingress-to-metrics-controllers.yaml diff --git a/manifests/0000_30_cluster-api_14_allow-ingress-to-metrics-operators.yaml b/manifests/0000_30_cluster-api_12_allow-ingress-to-metrics-operators.yaml similarity index 99% rename from manifests/0000_30_cluster-api_14_allow-ingress-to-metrics-operators.yaml rename to manifests/0000_30_cluster-api_12_allow-ingress-to-metrics-operators.yaml index 1e60d183df..fc48999dd2 100644 --- a/manifests/0000_30_cluster-api_14_allow-ingress-to-metrics-operators.yaml +++ b/manifests/0000_30_cluster-api_12_allow-ingress-to-metrics-operators.yaml @@ -37,6 +37,7 @@ spec: operator: In values: - capi-operator + - capi-installer policyTypes: - Ingress --- diff --git a/manifests/0000_30_cluster-api_15_allow-egress-controllers.yaml b/manifests/0000_30_cluster-api_13_allow-egress-controllers.yaml similarity index 100% rename from manifests/0000_30_cluster-api_15_allow-egress-controllers.yaml rename to manifests/0000_30_cluster-api_13_allow-egress-controllers.yaml diff --git a/manifests/0000_30_cluster-api_14_allow-egress-operators.yaml b/manifests/0000_30_cluster-api_14_allow-egress-operators.yaml new file mode 100644 index 0000000000..ac6c77e784 --- /dev/null +++ b/manifests/0000_30_cluster-api_14_allow-egress-operators.yaml @@ -0,0 +1,29 @@ +# This NetworkPolicy allows egress traffic required for the CAPI controller +# deployments in the openshift-cluster-api namespace. +# The controllers need broad internet access for cluster management operations, +# cloud provider API calls, and communication with various services. +# +# This approach is more practical than overly granular rules since the operator +# needs broad access to function properly in various environments. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + annotations: + exclude.release.openshift.io/internal-openshift-hosted: "true" + include.release.openshift.io/self-managed-high-availability: "true" + include.release.openshift.io/single-node-developer: "true" + release.openshift.io/feature-gate: "ClusterAPIMachineManagement" + name: allow-egress-operators + namespace: openshift-cluster-api +spec: + egress: + # Allow all egress traffic - operator needs broad access + - {} # Empty rule allows all egress + podSelector: + matchExpressions: + - key: k8s-app + operator: In + values: + - capi-controllers + policyTypes: + - Egress diff --git a/manifests/0000_30_cluster-api_17_default-deny.yaml b/manifests/0000_30_cluster-api_15_default-deny.yaml similarity index 100% rename from manifests/0000_30_cluster-api_17_default-deny.yaml rename to manifests/0000_30_cluster-api_15_default-deny.yaml diff --git a/manifests/0000_30_cluster-api_16_allow-egress-operators.yaml b/manifests/0000_30_cluster-api_16_allow-egress-operators.yaml deleted file mode 100644 index 7f3f3863f9..0000000000 --- a/manifests/0000_30_cluster-api_16_allow-egress-operators.yaml +++ /dev/null @@ -1,58 +0,0 @@ -# These NetworkPolicies allows egress traffic required for the CAPI operator -# deployments. -# The operator needs broad internet access for cluster management operations, -# cloud provider API calls, and communication with various services. -# -# This policy allows all egress traffic from the capi-controllers pod, which is -# necessary because the operator needs to communicate with: -# - Kubernetes API server for cluster management operations -# - Cloud provider APIs for infrastructure management -# - Container registries and other external services -# -# This approach is more practical than overly granular rules since the operator -# needs broad access to function properly in various environments. -apiVersion: networking.k8s.io/v1 -kind: NetworkPolicy -metadata: - annotations: - exclude.release.openshift.io/internal-openshift-hosted: "true" - include.release.openshift.io/self-managed-high-availability: "true" - include.release.openshift.io/single-node-developer: "true" - release.openshift.io/feature-gate: "ClusterAPIMachineManagement" - name: allow-egress-operators - namespace: openshift-cluster-api -spec: - egress: - # Allow all egress traffic - operator needs broad access - - {} # Empty rule allows all egress - podSelector: - matchExpressions: - - key: k8s-app - operator: In - values: - - capi-controllers - policyTypes: - - Egress ---- -apiVersion: networking.k8s.io/v1 -kind: NetworkPolicy -metadata: - annotations: - exclude.release.openshift.io/internal-openshift-hosted: "true" - include.release.openshift.io/self-managed-high-availability: "true" - include.release.openshift.io/single-node-developer: "true" - release.openshift.io/feature-gate: "ClusterAPIMachineManagement" - name: allow-egress-operators - namespace: openshift-cluster-api-operator -spec: - egress: - # Allow all egress traffic - operator needs broad access - - {} # Empty rule allows all egress - podSelector: - matchExpressions: - - key: k8s-app - operator: In - values: - - capi-operator - policyTypes: - - Egress diff --git a/manifests/0000_30_cluster-api_18_allow-ingress-to-webhook.yaml b/manifests/0000_30_cluster-api_16_allow-ingress-to-webhook.yaml similarity index 100% rename from manifests/0000_30_cluster-api_18_allow-ingress-to-webhook.yaml rename to manifests/0000_30_cluster-api_16_allow-ingress-to-webhook.yaml diff --git a/manifests/0000_30_cluster-api_11_deployment.yaml b/manifests/0000_30_cluster-api_17_deployment.yaml similarity index 98% rename from manifests/0000_30_cluster-api_11_deployment.yaml rename to manifests/0000_30_cluster-api_17_deployment.yaml index ec740e1fc0..06f6cbc0a7 100644 --- a/manifests/0000_30_cluster-api_11_deployment.yaml +++ b/manifests/0000_30_cluster-api_17_deployment.yaml @@ -85,7 +85,7 @@ spec: readOnly: true nodeSelector: node-role.kubernetes.io/control-plane: "" - priorityClassName: system-node-critical + priorityClassName: system-cluster-critical restartPolicy: Always tolerations: - key: "node-role.kubernetes.io/master" diff --git a/pkg/controllers/installer/related_objects_test.go b/pkg/controllers/installer/related_objects_test.go index 0d92267ac6..27584a03ed 100644 --- a/pkg/controllers/installer/related_objects_test.go +++ b/pkg/controllers/installer/related_objects_test.go @@ -28,7 +28,7 @@ import ( func TestStaticRelatedObjectsMatchManifest(t *testing.T) { // This test ensures that staticRelatedObjects() and the ClusterOperator // manifest do not drift. - data, err := os.ReadFile("../../../manifests/0000_30_cluster-api_12_clusteroperator.yaml") + data, err := os.ReadFile("../../../manifests/0000_30_cluster-api-operator_08_clusteroperator.yaml") if err != nil { t.Fatalf("reading ClusterOperator manifest: %v", err) } diff --git a/pkg/controllers/installerdeployment/assets/deployment.yaml b/pkg/controllers/installerdeployment/assets/deployment.yaml new file mode 100644 index 0000000000..c54619d891 --- /dev/null +++ b/pkg/controllers/installerdeployment/assets/deployment.yaml @@ -0,0 +1,81 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: capi-installer + annotations: + config.openshift.io/inject-proxy: capi-installer + labels: + k8s-app: capi-installer +spec: + selector: + matchLabels: + k8s-app: capi-installer + replicas: 1 + template: + metadata: + annotations: + target.workload.openshift.io/management: '{"effect": "PreferredDuringScheduling"}' + openshift.io/required-scc: restricted-v2 + labels: + k8s-app: capi-installer + spec: + serviceAccountName: capi-installer + containers: + - name: capi-installer + command: + - /capi-installer + args: + - --diagnostics-address=:8443 + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + ports: + - containerPort: 9440 + name: health + protocol: TCP + - containerPort: 8443 + name: diagnostics + protocol: TCP + resources: + requests: + cpu: 10m + memory: 50Mi + terminationMessagePolicy: FallbackToLogsOnError + volumeMounts: + - name: metrics-cert + mountPath: /tmp/k8s-metrics-server/serving-certs + readOnly: true + livenessProbe: + httpGet: + path: /healthz + port: 9440 + initialDelaySeconds: 15 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /readyz + port: 9440 + initialDelaySeconds: 5 + periodSeconds: 10 + nodeSelector: + node-role.kubernetes.io/control-plane: "" + priorityClassName: system-cluster-critical + restartPolicy: Always + tolerations: + - key: "node-role.kubernetes.io/master" + operator: "Exists" + effect: "NoSchedule" + - key: "node-role.kubernetes.io/control-plane" + operator: "Exists" + effect: "NoSchedule" + volumes: + - name: metrics-cert + secret: + defaultMode: 420 + secretName: capi-installer-metrics-tls diff --git a/pkg/controllers/installerdeployment/controller.go b/pkg/controllers/installerdeployment/controller.go new file mode 100644 index 0000000000..740ba4f3d3 --- /dev/null +++ b/pkg/controllers/installerdeployment/controller.go @@ -0,0 +1,223 @@ +/* +Copyright 2026 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package installerdeployment + +import ( + "context" + "fmt" + + "github.com/go-logr/logr" + operatorv1alpha1 "github.com/openshift/api/operator/v1alpha1" + "github.com/openshift/cluster-capi-operator/pkg/providerimages" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +const ( + fieldManager = "capi-operator-installer-deployment" + clusterAPIName = "cluster" +) + +// InstallerDeploymentReconciler reconciles the capi-installer Deployment. +type InstallerDeploymentReconciler struct { + client.Client + Namespace string + ContainerImage string + SupportedPlatform bool +} + +// Reconcile reconciles the capi-installer Deployment by reading provider image refs +// from the ConfigMap and ClusterAPI revisions, then applying the desired deployment. +// On unsupported platforms, it deletes the deployment if it exists. +func (r *InstallerDeploymentReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + log := ctrl.LoggerFrom(ctx).WithName("InstallerDeploymentReconciler") + + // If platform is unsupported, delete deployment. + if !r.SupportedPlatform { + return r.deleteDeploymentIfExists(ctx, log) + } + + // Read ConfigMap with current-release provider image refs. + configMap, err := r.getConfigMap(ctx, log) + if err != nil { + return reconcile.Result{}, fmt.Errorf("failed to get ConfigMap: %w", err) + } + + configMapRefs, err := providerimages.ImageRefsFromConfigMap(configMap) + if err != nil { + return reconcile.Result{}, fmt.Errorf("failed to extract image refs from ConfigMap: %w", err) + } + + // Read ClusterAPI to get old revision image refs. + clusterAPI, err := r.getClusterAPI(ctx, log) + if err != nil { + return reconcile.Result{}, fmt.Errorf("failed to get ClusterAPI: %w", err) + } + + revisionRefs := providerimages.ImageRefsFromRevisions(clusterAPI.Status.Revisions) + + // Union all distinct image refs. + allImageRefs := configMapRefs.Union(revisionRefs) + + // Build desired deployment. + desired := buildDesiredDeployment(r.ContainerImage, r.Namespace, allImageRefs) + + // Apply deployment using Server-Side Apply. + if err := r.applyDeployment(ctx, log, desired); err != nil { + return reconcile.Result{}, fmt.Errorf("failed to apply Deployment: %w", err) + } + + log.Info("Successfully reconciled capi-installer Deployment") + + return reconcile.Result{}, nil +} + +// getConfigMap retrieves the capi-installer-images ConfigMap. +func (r *InstallerDeploymentReconciler) getConfigMap(ctx context.Context, log logr.Logger) (*corev1.ConfigMap, error) { + configMap := &corev1.ConfigMap{} + key := types.NamespacedName{ + Name: providerimages.ConfigMapName, + Namespace: r.Namespace, + } + + if err := r.Get(ctx, key, configMap); err != nil { + if apierrors.IsNotFound(err) { + log.Info("ConfigMap not found, using empty image refs", "name", key.Name) + + return &corev1.ConfigMap{Data: map[string]string{}}, nil + } + + return nil, fmt.Errorf("failed to get ConfigMap: %w", err) + } + + return configMap, nil +} + +// getClusterAPI retrieves the ClusterAPI singleton. +func (r *InstallerDeploymentReconciler) getClusterAPI(ctx context.Context, log logr.Logger) (*operatorv1alpha1.ClusterAPI, error) { + clusterAPI := &operatorv1alpha1.ClusterAPI{} + key := types.NamespacedName{ + Name: clusterAPIName, + } + + if err := r.Get(ctx, key, clusterAPI); err != nil { + if apierrors.IsNotFound(err) { + log.Info("ClusterAPI not found, using empty revisions") + + return &operatorv1alpha1.ClusterAPI{}, nil + } + + return nil, fmt.Errorf("failed to get ClusterAPI: %w", err) + } + + return clusterAPI, nil +} + +// applyDeployment applies the Deployment using Server-Side Apply. +func (r *InstallerDeploymentReconciler) applyDeployment(ctx context.Context, log logr.Logger, desired *appsv1.Deployment) error { + // Ensure TypeMeta is set for SSA + desired.TypeMeta = metav1.TypeMeta{ + APIVersion: appsv1.SchemeGroupVersion.String(), + Kind: "Deployment", + } + + if err := r.Patch(ctx, desired, client.Apply, &client.PatchOptions{ + FieldManager: fieldManager, + Force: ptr.To(true), + }); err != nil { + return fmt.Errorf("failed to patch Deployment: %w", err) + } + + log.Info("Applied capi-installer Deployment", "name", desired.Name) + + return nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *InstallerDeploymentReconciler) SetupWithManager(mgr ctrl.Manager) error { + if err := ctrl.NewControllerManagedBy(mgr). + For(&appsv1.Deployment{}, builder.WithPredicates(predicate.NewPredicateFuncs(func(obj client.Object) bool { + return obj.GetNamespace() == r.Namespace && obj.GetName() == deploymentName + }))). + Watches(&corev1.ConfigMap{}, handler.EnqueueRequestsFromMapFunc(r.mapConfigMapToReconcile)). + Watches(&operatorv1alpha1.ClusterAPI{}, handler.EnqueueRequestsFromMapFunc(r.mapClusterAPIToReconcile)). + Complete(r); err != nil { + return fmt.Errorf("failed to create controller: %w", err) + } + + return nil +} + +// mapConfigMapToReconcile maps ConfigMap events to reconcile requests. +func (r *InstallerDeploymentReconciler) mapConfigMapToReconcile(ctx context.Context, obj client.Object) []reconcile.Request { + if obj.GetName() == providerimages.ConfigMapName && obj.GetNamespace() == r.Namespace { + return []reconcile.Request{{NamespacedName: types.NamespacedName{ + Name: deploymentName, + Namespace: r.Namespace, + }}} + } + + return nil +} + +// mapClusterAPIToReconcile maps ClusterAPI events to reconcile requests. +func (r *InstallerDeploymentReconciler) mapClusterAPIToReconcile(ctx context.Context, obj client.Object) []reconcile.Request { + if obj.GetName() == clusterAPIName { + return []reconcile.Request{{NamespacedName: types.NamespacedName{ + Name: deploymentName, + Namespace: r.Namespace, + }}} + } + + return nil +} + +// deleteDeploymentIfExists deletes the capi-installer Deployment if it exists. +// Returns no error if the deployment doesn't exist. +func (r *InstallerDeploymentReconciler) deleteDeploymentIfExists(ctx context.Context, log logr.Logger) (reconcile.Result, error) { + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: deploymentName, + Namespace: r.Namespace, + }, + } + + err := r.Delete(ctx, deployment) + if err != nil { + if apierrors.IsNotFound(err) { + log.V(1).Info("Deployment does not exist, nothing to delete") + + return reconcile.Result{}, nil + } + + return reconcile.Result{}, fmt.Errorf("failed to delete Deployment: %w", err) + } + + log.Info("Deleted capi-installer Deployment on unsupported platform") + + return reconcile.Result{}, nil +} diff --git a/pkg/controllers/installerdeployment/controller_test.go b/pkg/controllers/installerdeployment/controller_test.go new file mode 100644 index 0000000000..6a9d69aa53 --- /dev/null +++ b/pkg/controllers/installerdeployment/controller_test.go @@ -0,0 +1,269 @@ +/* +Copyright 2026 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package installerdeployment + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + operatorv1alpha1 "github.com/openshift/api/operator/v1alpha1" + "github.com/openshift/cluster-api-actuator-pkg/testutils" + "github.com/openshift/cluster-capi-operator/pkg/providerimages" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest/komega" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +const ( + testTimeout = 10 * time.Second + testInterval = 100 * time.Millisecond +) + +var _ = Describe("InstallerDeployment Controller", func() { + var ( + ctx context.Context + reconciler *InstallerDeploymentReconciler + configMap *corev1.ConfigMap + clusterAPI *operatorv1alpha1.ClusterAPI + k komega.Komega + namespace string + ) + + BeforeEach(func() { + ctx = context.Background() + k = komega.New(cl).WithContext(ctx) + + // Create a unique test namespace. + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-installer-", + }, + } + Expect(cl.Create(ctx, ns)).To(Succeed()) + + namespace = ns.Name + + // Create the InstallerDeploymentReconciler. + reconciler = &InstallerDeploymentReconciler{ + Client: cl, + Namespace: namespace, + ContainerImage: "quay.io/openshift/cluster-capi-operator:test", + SupportedPlatform: true, + } + + // Create ConfigMap with provider image refs. + configMap = &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: providerimages.ConfigMapName, + Namespace: namespace, + }, + Data: map[string]string{ + "aws-cluster-api-controllers": "registry/aws@sha256:abc", + "core-cluster-api-controllers": "registry/core@sha256:def", + }, + } + Expect(cl.Create(ctx, configMap)).To(Succeed()) + + // Create ClusterAPI singleton. + clusterAPI = &operatorv1alpha1.ClusterAPI{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterAPIName, + }, + Spec: &operatorv1alpha1.ClusterAPISpec{}, + } + Expect(cl.Create(ctx, clusterAPI)).To(Succeed()) + + DeferCleanup(func() { + testutils.CleanupResources(Default, ctx, cfg, cl, namespace, + &corev1.ConfigMap{}, + &appsv1.Deployment{}, + ) + Expect(cl.Delete(ctx, clusterAPI)).To(Succeed()) + }) + }) + + It("should create a Deployment with image volumes for ConfigMap refs", func() { + _, err := reconciler.Reconcile(ctx, reconcile.Request{}) + Expect(err).NotTo(HaveOccurred()) + + // Verify deployment was created with correct number of image volumes + // (2 from ConfigMap + 1 metrics-cert from the embedded base). + Eventually(k.Object(&appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: deploymentName, + Namespace: namespace, + }, + })).WithTimeout(testTimeout).WithPolling(testInterval).Should(HaveField("Spec.Template.Spec.Volumes", HaveLen(3))) + + deployment := &appsv1.Deployment{} + Expect(cl.Get(ctx, client.ObjectKey{Name: deploymentName, Namespace: namespace}, deployment)).To(Succeed()) + + var imageRefs []string + + for _, vol := range deployment.Spec.Template.Spec.Volumes { + if vol.Image != nil { + imageRefs = append(imageRefs, vol.Image.Reference) + } + } + + Expect(imageRefs).To(ConsistOf("registry/aws@sha256:abc", "registry/core@sha256:def")) + }) + + It("should update Deployment when ConfigMap is updated", func() { + _, err := reconciler.Reconcile(ctx, reconcile.Request{}) + Expect(err).NotTo(HaveOccurred()) + + deployment := &appsv1.Deployment{} + + Eventually(func() error { + return cl.Get(ctx, client.ObjectKey{Name: deploymentName, Namespace: namespace}, deployment) + }).WithTimeout(testTimeout).WithPolling(testInterval).Should(Succeed()) + + Eventually(k.Update(configMap, func() { + configMap.Data["gcp-cluster-api-controllers"] = "registry/gcp@sha256:123" + })).Should(Succeed()) + + _, err = reconciler.Reconcile(ctx, reconcile.Request{}) + Expect(err).NotTo(HaveOccurred()) + + // Verify deployment has 4 volumes now (3 image + 1 metrics-cert). + Eventually(k.Object(&appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: deploymentName, + Namespace: namespace, + }, + })).WithTimeout(testTimeout).WithPolling(testInterval).Should(HaveField("Spec.Template.Spec.Volumes", HaveLen(4))) + + Expect(cl.Get(ctx, client.ObjectKey{Name: deploymentName, Namespace: namespace}, deployment)).To(Succeed()) + + var imageRefs []string + + for _, vol := range deployment.Spec.Template.Spec.Volumes { + if vol.Image != nil { + imageRefs = append(imageRefs, vol.Image.Reference) + } + } + + Expect(imageRefs).To(ContainElement("registry/gcp@sha256:123")) + }) + + It("should include old revision images not in ConfigMap", func() { + Eventually(k.UpdateStatus(clusterAPI, func() { + clusterAPI.Status.Revisions = []operatorv1alpha1.ClusterAPIInstallerRevision{ + { + Name: "rev-1", + Revision: 1, + ContentID: "old-content", + Components: []operatorv1alpha1.ClusterAPIInstallerComponent{ + { + Name: "old-provider", + ClusterAPIInstallerComponentSource: operatorv1alpha1.ClusterAPIInstallerComponentSource{ + Type: operatorv1alpha1.InstallerComponentTypeImage, + Image: operatorv1alpha1.ClusterAPIInstallerComponentImage{ + Ref: "registry.example.com/old@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + Profile: "default", + }, + }, + }, + }, + }, + } + clusterAPI.Status.DesiredRevision = "rev-1" + })).Should(Succeed()) + + _, err := reconciler.Reconcile(ctx, reconcile.Request{}) + Expect(err).NotTo(HaveOccurred()) + + // Verify deployment has volumes for both ConfigMap and old revision images + // (2 ConfigMap + 1 revision + 1 metrics-cert = 4). + deployment := &appsv1.Deployment{} + + Eventually(func() int { + if err := cl.Get(ctx, client.ObjectKey{Name: deploymentName, Namespace: namespace}, deployment); err != nil { + return 0 + } + + return len(deployment.Spec.Template.Spec.Volumes) + }).WithTimeout(testTimeout).WithPolling(testInterval).Should(BeNumerically(">=", 4)) + + var imageRefs []string + + for _, vol := range deployment.Spec.Template.Spec.Volumes { + if vol.Image != nil { + imageRefs = append(imageRefs, vol.Image.Reference) + } + } + + Expect(imageRefs).To(ContainElement("registry.example.com/old@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")) + }) + + Context("when platform is supported", func() { + It("should not error when reconciling with unchanged inputs", func() { + _, err := reconciler.Reconcile(ctx, reconcile.Request{}) + Expect(err).NotTo(HaveOccurred()) + + deployment := &appsv1.Deployment{} + + Eventually(func() error { + return cl.Get(ctx, client.ObjectKey{Name: deploymentName, Namespace: namespace}, deployment) + }).WithTimeout(testTimeout).WithPolling(testInterval).Should(Succeed()) + + _, err = reconciler.Reconcile(ctx, reconcile.Request{}) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Context("when platform is unsupported", func() { + It("should delete Deployment when it exists", func() { + _, err := reconciler.Reconcile(ctx, reconcile.Request{}) + Expect(err).NotTo(HaveOccurred()) + + deployment := &appsv1.Deployment{} + + Eventually(func() error { + return cl.Get(ctx, client.ObjectKey{Name: deploymentName, Namespace: namespace}, deployment) + }).WithTimeout(testTimeout).WithPolling(testInterval).Should(Succeed()) + + reconciler.SupportedPlatform = false + + _, err = reconciler.Reconcile(ctx, reconcile.Request{}) + Expect(err).NotTo(HaveOccurred()) + + Eventually(func() bool { + err := cl.Get(ctx, client.ObjectKey{Name: deploymentName, Namespace: namespace}, deployment) + + return err != nil + }).WithTimeout(testTimeout).WithPolling(testInterval).Should(BeTrue()) + }) + + It("should not error when Deployment does not exist", func() { + reconciler.SupportedPlatform = false + + _, err := reconciler.Reconcile(ctx, reconcile.Request{}) + Expect(err).NotTo(HaveOccurred()) + + deployment := &appsv1.Deployment{} + err = cl.Get(ctx, client.ObjectKey{Name: deploymentName, Namespace: namespace}, deployment) + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/pkg/controllers/installerdeployment/deployment.go b/pkg/controllers/installerdeployment/deployment.go new file mode 100644 index 0000000000..7781d9ef72 --- /dev/null +++ b/pkg/controllers/installerdeployment/deployment.go @@ -0,0 +1,103 @@ +/* +Copyright 2026 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package installerdeployment + +import ( + _ "embed" + "fmt" + "os" + + "github.com/openshift/cluster-capi-operator/pkg/providerimages" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apimachinery/pkg/util/yaml" +) + +const ( + deploymentName = "capi-installer" +) + +var ( + //go:embed assets/deployment.yaml + deploymentYAML []byte + + // staticDeployment holds the parsed deployment YAML. + //nolint:gochecknoglobals + staticDeployment = appsv1.Deployment{} +) + +func init() { + // Parse the embedded deployment YAML on startup + if err := yaml.UnmarshalStrict(deploymentYAML, &staticDeployment); err != nil { + panic(fmt.Errorf("failed to parse embedded deployment YAML: %w", err)) + } +} + +// buildDesiredDeployment constructs the desired capi-installer Deployment spec +// by parsing the embedded YAML base and overlaying dynamic fields: container image, +// namespace, RELEASE_VERSION env var, and image volumes/mounts. +func buildDesiredDeployment(containerImage, namespace string, imageRefs sets.Set[string]) *appsv1.Deployment { + deployment := staticDeployment.DeepCopy() + + // Overlay dynamic fields + deployment.Namespace = namespace + deployment.Spec.Template.Spec.Containers[0].Image = containerImage + + // Add RELEASE_VERSION env var from the operator's own environment + releaseVersion := os.Getenv("RELEASE_VERSION") + if releaseVersion == "" { + releaseVersion = "0.0.1-snapshot" + } + + deployment.Spec.Template.Spec.Containers[0].Env = append( + deployment.Spec.Template.Spec.Containers[0].Env, + corev1.EnvVar{ + Name: "RELEASE_VERSION", + Value: releaseVersion, + }, + ) + + // Build image volumes and volume mounts from image refs. + // sets.List sorts image refs for deterministic output. + for _, imageRef := range sets.List(imageRefs) { + name := providerimages.VolumeNameForImageRef(imageRef) + + deployment.Spec.Template.Spec.Volumes = append(deployment.Spec.Template.Spec.Volumes, + corev1.Volume{ + Name: name, + VolumeSource: corev1.VolumeSource{ + Image: &corev1.ImageVolumeSource{ + Reference: imageRef, + PullPolicy: corev1.PullIfNotPresent, + }, + }, + }, + ) + + deployment.Spec.Template.Spec.Containers[0].VolumeMounts = append( + deployment.Spec.Template.Spec.Containers[0].VolumeMounts, + corev1.VolumeMount{ + Name: name, + MountPath: fmt.Sprintf("%s/%s", providerimages.ProviderImageMountBase, name), + ReadOnly: true, + }, + ) + } + + return deployment +} diff --git a/pkg/controllers/installerdeployment/deployment_test.go b/pkg/controllers/installerdeployment/deployment_test.go new file mode 100644 index 0000000000..f99307c66f --- /dev/null +++ b/pkg/controllers/installerdeployment/deployment_test.go @@ -0,0 +1,110 @@ +/* +Copyright 2026 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package installerdeployment + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/util/sets" +) + +var _ = Describe("buildDesiredDeployment", func() { + const ( + testImage = "quay.io/openshift/cluster-capi-operator:latest" + ) + + It("should parse the embedded YAML and overlay dynamic fields", func() { + deployment := buildDesiredDeployment(testImage, testNamespace, sets.New[string]()) + + Expect(deployment.Name).To(Equal("capi-installer")) + Expect(deployment.Namespace).To(Equal(testNamespace)) + Expect(deployment.Spec.Template.Spec.Containers[0].Image).To(Equal(testImage)) + // Verify the static base content survived YAML parsing. + Expect(deployment.Spec.Template.Spec.ServiceAccountName).To(Equal("capi-installer")) + }) + + It("should create image volumes and mounts for all image refs", func() { + imageRefs := sets.New( + "registry/aws@sha256:abc", + "registry/core@sha256:def", + ) + + deployment := buildDesiredDeployment(testImage, testNamespace, imageRefs) + + volumes := deployment.Spec.Template.Spec.Volumes + // 2 image volumes + 1 metrics-cert volume from base. + Expect(volumes).To(HaveLen(3)) + + // Verify all image refs are present in volumes. + var volumeImageRefs []string + + for _, vol := range volumes { + if vol.Image != nil { + volumeImageRefs = append(volumeImageRefs, vol.Image.Reference) + } + } + + Expect(volumeImageRefs).To(ConsistOf( + "registry/aws@sha256:abc", + "registry/core@sha256:def", + )) + + // Verify volume mounts include both image mounts and metrics-cert. + container := deployment.Spec.Template.Spec.Containers[0] + // 2 image mounts + 1 metrics-cert mount from base. + Expect(container.VolumeMounts).To(HaveLen(3)) + }) + + It("should produce deterministic output when called multiple times", func() { + imageRefs := sets.New[string]( + "registry/gcp@sha256:123", + "registry/aws@sha256:abc", + "registry/core@sha256:def", + ) + + deployment1 := buildDesiredDeployment(testImage, testNamespace, imageRefs) + + deployment2 := buildDesiredDeployment(testImage, testNamespace, imageRefs) + + Expect(deployment1).To(Equal(deployment2)) + + // Verify image volumes are sorted by name for determinism. + var imageVolumeNames []string + + for _, vol := range deployment1.Spec.Template.Spec.Volumes { + if vol.Image != nil { + imageVolumeNames = append(imageVolumeNames, vol.Name) + } + } + + for i := 1; i < len(imageVolumeNames); i++ { + Expect(imageVolumeNames[i] > imageVolumeNames[i-1]).To(BeTrue()) + } + }) + + It("should have only base volumes when imageRefs is empty", func() { + deployment := buildDesiredDeployment(testImage, testNamespace, sets.New[string]()) + + // Only the metrics-cert volume from the base. + Expect(deployment.Spec.Template.Spec.Volumes).To(HaveLen(1)) + Expect(deployment.Spec.Template.Spec.Volumes[0].Name).To(Equal("metrics-cert")) + + // Only the metrics-cert volume mount from the base. + Expect(deployment.Spec.Template.Spec.Containers[0].VolumeMounts).To(HaveLen(1)) + Expect(deployment.Spec.Template.Spec.Containers[0].VolumeMounts[0].Name).To(Equal("metrics-cert")) + }) +}) diff --git a/pkg/controllers/installerdeployment/suite_test.go b/pkg/controllers/installerdeployment/suite_test.go new file mode 100644 index 0000000000..baebed8c8f --- /dev/null +++ b/pkg/controllers/installerdeployment/suite_test.go @@ -0,0 +1,67 @@ +/* +Copyright 2026 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package installerdeployment + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/openshift/cluster-capi-operator/pkg/test" + "k8s.io/client-go/rest" + "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + "sigs.k8s.io/controller-runtime/pkg/envtest/komega" + logf "sigs.k8s.io/controller-runtime/pkg/log" +) + +var ( + testEnv *envtest.Environment + cfg *rest.Config + cl client.WithWatch +) + +const ( + testNamespace = "test-namespace" +) + +func TestInstallerDeploymentController(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "InstallerDeployment Controller Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(klog.Background()) + + By("bootstrapping test environment") + + var err error + + testEnv = &envtest.Environment{} + cfg, cl, err = test.StartEnvTest(testEnv) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + Expect(cl).NotTo(BeNil()) + + komega.SetClient(cl) + + DeferCleanup(func() { + By("tearing down the test environment") + Expect(test.StopEnvTest(testEnv)).To(Succeed()) + }) +}) diff --git a/pkg/providerimages/configmap.go b/pkg/providerimages/configmap.go new file mode 100644 index 0000000000..adf608f5d7 --- /dev/null +++ b/pkg/providerimages/configmap.go @@ -0,0 +1,45 @@ +/* +Copyright 2026 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package providerimages + +import ( + "errors" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/sets" +) + +var errConfigMapNil = errors.New("ConfigMap cannot be nil") + +// ConfigMapName is the name of the ConfigMap containing current-release provider image references. +const ConfigMapName = "capi-installer-images" + +// ImageRefsFromConfigMap extracts provider image references from a ConfigMap. +// The ConfigMap data values are image references; the keys are discarded. +// Returns an error if the ConfigMap is nil. +func ImageRefsFromConfigMap(cm *corev1.ConfigMap) (sets.Set[string], error) { + if cm == nil { + return nil, errConfigMapNil + } + + result := sets.New[string]() + for _, v := range cm.Data { + result.Insert(v) + } + + return result, nil +} diff --git a/pkg/providerimages/configmap_test.go b/pkg/providerimages/configmap_test.go new file mode 100644 index 0000000000..3f816c58ee --- /dev/null +++ b/pkg/providerimages/configmap_test.go @@ -0,0 +1,68 @@ +/* +Copyright 2026 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package providerimages + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/sets" +) + +var _ = Describe("ImageRefsFromConfigMap", func() { + It("should return a map of provider names to image refs from ConfigMap data", func() { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "capi-installer-images", + Namespace: "openshift-cluster-api-operator", + }, + Data: map[string]string{ + "aws-cluster-api-controllers": "registry/aws@sha256:abc", + "gcp-cluster-api-controllers": "registry/gcp@sha256:def", + }, + } + + result, err := ImageRefsFromConfigMap(cm) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(sets.New[string]( + "registry/aws@sha256:abc", + "registry/gcp@sha256:def", + ))) + }) + + It("should return an empty map when ConfigMap data is empty", func() { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "capi-installer-images", + Namespace: "openshift-cluster-api-operator", + }, + Data: map[string]string{}, + } + + result, err := ImageRefsFromConfigMap(cm) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + + It("should return an error when ConfigMap is nil", func() { + result, err := ImageRefsFromConfigMap(nil) + Expect(err).To(HaveOccurred()) + Expect(err).To(MatchError(errConfigMapNil)) + Expect(result).To(BeNil()) + }) +}) diff --git a/pkg/providerimages/providerimages.go b/pkg/providerimages/providerimages.go index 4ea83fbd31..e17d7b0fc7 100644 --- a/pkg/providerimages/providerimages.go +++ b/pkg/providerimages/providerimages.go @@ -16,14 +16,18 @@ limitations under the License. package providerimages import ( + "crypto/sha256" + "encoding/hex" "errors" "fmt" "os" "path/filepath" + "strings" "github.com/go-logr/logr" "github.com/openshift/cluster-capi-operator/manifests-gen/providermetadata" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/sets" "sigs.k8s.io/yaml" ) @@ -31,6 +35,8 @@ const ( metadataFile = "metadata.yaml" manifestsFile = "manifests.yaml" capiOperatorManifestsDir = "capi-operator-manifests" + // ProviderImageMountBase is the base path where provider image volumes are mounted. + ProviderImageMountBase = "/var/lib/provider-images" // AttributeKeyType is the key for the provider type attribute. AttributeKeyType = providermetadata.AttributeKeyType @@ -55,28 +61,32 @@ var ( errNoCapiManifests = errors.New("no capi-manifests directory found") errMissingMetadata = errors.New("missing metadata.yaml in /capi-operator-manifests") errMissingManifests = errors.New("missing manifests.yaml in /capi-operator-manifests") - errImageRefNotFound = errors.New("image ref not found for provider") errContainerNotFound = errors.New("container not found in pod spec") ) // ScanProviderImages scans providerImageDir for subdirectories containing // provider profiles (metadata.yaml + manifests.yaml). imageRefMap maps -// subdirectory names to image references (built from the pod spec). +// expected subdirectory names to image references. func ScanProviderImages(logger logr.Logger, providerImageDir string, imageRefMap map[string]string) ([]ProviderImageManifests, error) { - entries, err := os.ReadDir(providerImageDir) - if err != nil { - return nil, fmt.Errorf("failed to read provider image directory %s: %w", providerImageDir, err) - } - var result []ProviderImageManifests - for _, entry := range entries { - if !entry.IsDir() { - continue + for _, subdir := range sets.List(sets.KeySet(imageRefMap)) { + subdirPath := filepath.Join(providerImageDir, subdir) + + info, err := os.Stat(subdirPath) + if err != nil { + if os.IsNotExist(err) { + logger.Info("Skipping provider directory: expected directory does not exist", "directory", subdir) + continue + } + + return nil, fmt.Errorf("failed to stat provider image directory %s: %w", subdirPath, err) } - subdir := entry.Name() - subdirPath := filepath.Join(providerImageDir, subdir) + if !info.IsDir() { + logger.Info("Skipping provider directory: expected path is not a directory", "directory", subdir) + continue + } profiles, err := discoverProfiles(subdirPath) if err != nil { @@ -91,12 +101,6 @@ func ScanProviderImages(logger logr.Logger, providerImageDir string, imageRefMap imageRef := imageRefMap[subdir] for _, profile := range profiles { - // If the provider has profiles but no image ref, return an error - // instead of a provider with an empty image ref. - if imageRef == "" { - return nil, fmt.Errorf("%w: %s", errImageRefNotFound, subdir) - } - manifestsPath := filepath.Join(subdirPath, capiOperatorManifestsDir, profile.Profile, manifestsFile) result = append(result, ProviderImageManifests{ @@ -111,6 +115,18 @@ func ScanProviderImages(logger logr.Logger, providerImageDir string, imageRefMap return result, nil } +// BuildImageRefMapFromRefs builds a mapping from expected mount subdirectory +// names to image references. +func BuildImageRefMapFromRefs(imageRefs sets.Set[string]) map[string]string { + imageRefMap := make(map[string]string, imageRefs.Len()) + + for _, imageRef := range sets.List(imageRefs) { + imageRefMap[VolumeNameForImageRef(imageRef)] = imageRef + } + + return imageRefMap +} + // BuildImageRefMap builds a mapping from mount subdirectory names to image // references by correlating image volumes with their volume mounts for the // named container in the given PodSpec. @@ -146,6 +162,39 @@ func BuildImageRefMap(podSpec corev1.PodSpec, containerName string) (map[string] return nil, fmt.Errorf("container %q: %w", containerName, errContainerNotFound) } +// VolumeNameForImageRef generates a deterministic, DNS-label-safe volume name +// from an image reference. The volume name consists of a prefix derived from +// the image name and a short hash of the full image reference. +func VolumeNameForImageRef(imageRef string) string { + parts := strings.Split(imageRef, "@") + if len(parts) == 0 { + parts = []string{imageRef} + } + + pathParts := strings.Split(parts[0], "/") + imageName := pathParts[len(pathParts)-1] + + imageName = strings.ToLower(imageName) + imageName = strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + return r + } + + return '-' + }, imageName) + + hash := sha256.Sum256([]byte(imageRef)) + shortHash := hex.EncodeToString(hash[:])[:8] + + volumeName := fmt.Sprintf("%s-%s", imageName, shortHash) + + if len(volumeName) > 0 && (volumeName[0] < 'a' || volumeName[0] > 'z') && (volumeName[0] < '0' || volumeName[0] > '9') { + volumeName = "img-" + volumeName + } + + return volumeName +} + // profileManifests holds parsed metadata and manifest content for a single profile. type profileManifests struct { Profile string diff --git a/pkg/providerimages/providerimages_test.go b/pkg/providerimages/providerimages_test.go index e65290365d..c98f7fb148 100644 --- a/pkg/providerimages/providerimages_test.go +++ b/pkg/providerimages/providerimages_test.go @@ -23,9 +23,8 @@ import ( "github.com/go-logr/logr/testr" . "github.com/onsi/gomega" - appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" - "sigs.k8s.io/yaml" + "k8s.io/apimachinery/pkg/util/sets" ) // createMetadataYAML generates valid metadata.yaml content. @@ -163,6 +162,17 @@ func Test_BuildImageRefMap(t *testing.T) { containerName: "my-container", expected: map[string]string{}, }, + { + name: "missing container returns error", + podSpec: corev1.PodSpec{ + Containers: []corev1.Container{{ + Name: "different-container", + }}, + }, + containerName: "my-container", + wantErr: true, + errContains: `container "my-container": container not found in pod spec`, + }, } for _, tt := range tests { @@ -173,10 +183,7 @@ func Test_BuildImageRefMap(t *testing.T) { if tt.wantErr { g.Expect(err).To(HaveOccurred()) - - if tt.errContains != "" { - g.Expect(err.Error()).To(ContainSubstring(tt.errContains)) - } + g.Expect(err.Error()).To(ContainSubstring(tt.errContains)) return } @@ -187,29 +194,34 @@ func Test_BuildImageRefMap(t *testing.T) { } } -func Test_BuildImageRefMap_DeploymentManifest(t *testing.T) { +func Test_VolumeNameForImageRef(t *testing.T) { g := NewWithT(t) - data, err := os.ReadFile("../../manifests/0000_30_cluster-api-installer_05_deployment.yaml") - g.Expect(err).NotTo(HaveOccurred()) - - var deployment appsv1.Deployment - g.Expect(yaml.Unmarshal(data, &deployment)).To(Succeed()) - - imageRefMap, err := BuildImageRefMap(deployment.Spec.Template.Spec, "capi-operator") - g.Expect(err).NotTo(HaveOccurred()) - - g.Expect(imageRefMap).To(Equal(map[string]string{ - "aws-cluster-api-controllers": "registry.ci.openshift.org/openshift:aws-cluster-api-controllers", - "azure-cluster-api-controllers": "registry.ci.openshift.org/openshift:azure-cluster-api-controllers", - "baremetal-cluster-api-controllers": "registry.ci.openshift.org/openshift:baremetal-cluster-api-controllers", - "cluster-capi-controllers": "registry.ci.openshift.org/openshift:cluster-capi-controllers", - "cluster-capi-operator": "registry.ci.openshift.org/openshift:cluster-capi-operator", - "gcp-cluster-api-controllers": "registry.ci.openshift.org/openshift:gcp-cluster-api-controllers", - "ibmcloud-cluster-api-controllers": "registry.ci.openshift.org/openshift:ibmcloud-cluster-api-controllers", - "openstack-cluster-api-controllers": "registry.ci.openshift.org/openshift:openstack-cluster-api-controllers", - "openstack-resource-controller": "registry.ci.openshift.org/openshift:openstack-resource-controller", - "vsphere-cluster-api-controllers": "registry.ci.openshift.org/openshift:vsphere-cluster-api-controllers", + name := VolumeNameForImageRef("registry.example.com/My.Provider_AWS@sha256:abc123") + g.Expect(name).To(MatchRegexp(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`), "expected a DNS-label-safe volume name") + + g.Expect(VolumeNameForImageRef("registry.example.com/core@sha256:def456")).To( + Equal(VolumeNameForImageRef("registry.example.com/core@sha256:def456")), + "expected the same image ref to always generate the same volume name", + ) + + g.Expect(VolumeNameForImageRef("registry.example.com/aws@sha256:abc")).NotTo( + Equal(VolumeNameForImageRef("registry.example.com/gcp@sha256:def")), + "expected different image refs to generate different volume names", + ) +} + +func Test_BuildImageRefMapFromRefs(t *testing.T) { + g := NewWithT(t) + + imageRefs := sets.New( + "registry.example.com/aws@sha256:abc", + "registry.example.com/gcp@sha256:def", + ) + + g.Expect(BuildImageRefMapFromRefs(imageRefs)).To(Equal(map[string]string{ + VolumeNameForImageRef("registry.example.com/aws@sha256:abc"): "registry.example.com/aws@sha256:abc", + VolumeNameForImageRef("registry.example.com/gcp@sha256:def"): "registry.example.com/gcp@sha256:def", })) } @@ -262,7 +274,9 @@ func Test_ScanProviderImages(t *testing.T) { t.Fatalf("failed to create directory: %v", err) } }, - imageRefMap: map[string]string{}, + imageRefMap: map[string]string{ + "empty-provider": "registry.example.com/empty-provider:v1.0.0", + }, validate: func(t *testing.T, g Gomega, result []ProviderImageManifests) { t.Helper() g.Expect(result).To(BeEmpty()) @@ -285,7 +299,9 @@ func Test_ScanProviderImages(t *testing.T) { t.Fatalf("failed to write file: %v", err) } }, - imageRefMap: map[string]string{}, + imageRefMap: map[string]string{ + "no-manifests-provider": "registry.example.com/no-manifests-provider:v1.0.0", + }, validate: func(t *testing.T, g Gomega, result []ProviderImageManifests) { t.Helper() g.Expect(result).To(BeEmpty()) @@ -300,7 +316,9 @@ func Test_ScanProviderImages(t *testing.T) { "apiVersion: v1\nkind: ConfigMap\n", ) }, - imageRefMap: map[string]string{}, + imageRefMap: map[string]string{ + "bad-provider": "registry.example.com/bad-provider:v1.0.0", + }, wantErr: true, errContains: "missing metadata.yaml", }, @@ -313,7 +331,9 @@ func Test_ScanProviderImages(t *testing.T) { "", // no manifests ) }, - imageRefMap: map[string]string{}, + imageRefMap: map[string]string{ + "bad-provider": "registry.example.com/bad-provider:v1.0.0", + }, wantErr: true, errContains: "missing manifests.yaml", }, @@ -326,7 +346,9 @@ func Test_ScanProviderImages(t *testing.T) { "apiVersion: v1\nkind: ConfigMap\n", ) }, - imageRefMap: map[string]string{}, + imageRefMap: map[string]string{ + "bad-provider": "registry.example.com/bad-provider:v1.0.0", + }, wantErr: true, errContains: "failed to parse metadata.yaml", }, @@ -465,7 +487,7 @@ func Test_ScanProviderImages(t *testing.T) { }, }, { - name: "missing image ref in map returns error", + name: "extra directories on disk not in map are ignored", setup: func(t *testing.T, dir string) { t.Helper() writeProfile(t, dir, "unknown-provider", "default", @@ -474,8 +496,23 @@ func Test_ScanProviderImages(t *testing.T) { ) }, imageRefMap: map[string]string{}, - wantErr: true, - errContains: "image ref not found for provider: unknown-provider", + validate: func(t *testing.T, g Gomega, result []ProviderImageManifests) { + t.Helper() + g.Expect(result).To(BeEmpty()) + }, + }, + { + name: "directory in map that does not exist on disk is skipped", + setup: func(t *testing.T, dir string) { + t.Helper() + }, + imageRefMap: map[string]string{ + "missing-provider": "registry.example.com/missing-provider:v1.0.0", + }, + validate: func(t *testing.T, g Gomega, result []ProviderImageManifests) { + t.Helper() + g.Expect(result).To(BeEmpty()) + }, }, } diff --git a/pkg/providerimages/revision_images.go b/pkg/providerimages/revision_images.go new file mode 100644 index 0000000000..e2c8efcad5 --- /dev/null +++ b/pkg/providerimages/revision_images.go @@ -0,0 +1,38 @@ +/* +Copyright 2026 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package providerimages + +import ( + operatorv1alpha1 "github.com/openshift/api/operator/v1alpha1" + "k8s.io/apimachinery/pkg/util/sets" +) + +// ImageRefsFromRevisions extracts unique image references from ClusterAPI revisions +// and returns a set of deduplicated image references. +func ImageRefsFromRevisions(revisions []operatorv1alpha1.ClusterAPIInstallerRevision) sets.Set[string] { + result := sets.New[string]() + + for _, revision := range revisions { + for _, component := range revision.Components { + if component.Type == operatorv1alpha1.InstallerComponentTypeImage { + result.Insert(string(component.Image.Ref)) + } + } + } + + return result +} diff --git a/pkg/providerimages/revision_images_test.go b/pkg/providerimages/revision_images_test.go new file mode 100644 index 0000000000..5a27a68750 --- /dev/null +++ b/pkg/providerimages/revision_images_test.go @@ -0,0 +1,117 @@ +/* +Copyright 2026 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package providerimages + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + operatorv1alpha1 "github.com/openshift/api/operator/v1alpha1" + "k8s.io/apimachinery/pkg/util/sets" +) + +var _ = Describe("ImageRefsFromRevisions", func() { + It("should return a set of image refs from distinct component images", func() { + revisions := []operatorv1alpha1.ClusterAPIInstallerRevision{ + { + Components: []operatorv1alpha1.ClusterAPIInstallerComponent{ + { + Name: "core", + ClusterAPIInstallerComponentSource: operatorv1alpha1.ClusterAPIInstallerComponentSource{ + Type: operatorv1alpha1.InstallerComponentTypeImage, + Image: operatorv1alpha1.ClusterAPIInstallerComponentImage{ + Ref: "registry/core@sha256:abc123", + }, + }, + }, + { + Name: "aws-infrastructure", + ClusterAPIInstallerComponentSource: operatorv1alpha1.ClusterAPIInstallerComponentSource{ + Type: operatorv1alpha1.InstallerComponentTypeImage, + Image: operatorv1alpha1.ClusterAPIInstallerComponentImage{ + Ref: "registry/aws@sha256:def456", + }, + }, + }, + }, + }, + { + Components: []operatorv1alpha1.ClusterAPIInstallerComponent{ + { + Name: "gcp-infrastructure", + ClusterAPIInstallerComponentSource: operatorv1alpha1.ClusterAPIInstallerComponentSource{ + Type: operatorv1alpha1.InstallerComponentTypeImage, + Image: operatorv1alpha1.ClusterAPIInstallerComponentImage{ + Ref: "registry/gcp@sha256:789abc", + }, + }, + }, + }, + }, + } + + result := ImageRefsFromRevisions(revisions) + Expect(result).To(Equal(sets.New[string]( + "registry/core@sha256:abc123", + "registry/aws@sha256:def456", + "registry/gcp@sha256:789abc", + ))) + }) + + It("should deduplicate overlapping image refs", func() { + revisions := []operatorv1alpha1.ClusterAPIInstallerRevision{ + { + Components: []operatorv1alpha1.ClusterAPIInstallerComponent{ + { + Name: "core", + ClusterAPIInstallerComponentSource: operatorv1alpha1.ClusterAPIInstallerComponentSource{ + Type: operatorv1alpha1.InstallerComponentTypeImage, + Image: operatorv1alpha1.ClusterAPIInstallerComponentImage{ + Ref: "registry/core@sha256:abc123", + }, + }, + }, + }, + }, + { + Components: []operatorv1alpha1.ClusterAPIInstallerComponent{ + { + Name: "core", + ClusterAPIInstallerComponentSource: operatorv1alpha1.ClusterAPIInstallerComponentSource{ + Type: operatorv1alpha1.InstallerComponentTypeImage, + Image: operatorv1alpha1.ClusterAPIInstallerComponentImage{ + Ref: "registry/core@sha256:abc123", + }, + }, + }, + }, + }, + } + + result := ImageRefsFromRevisions(revisions) + Expect(result).To(Equal(sets.New[string]("registry/core@sha256:abc123"))) + }) + + It("should return an empty set for empty revisions slice", func() { + result := ImageRefsFromRevisions([]operatorv1alpha1.ClusterAPIInstallerRevision{}) + Expect(result).To(BeEmpty()) + }) + + It("should return an empty set for nil revisions slice", func() { + result := ImageRefsFromRevisions(nil) + Expect(result).To(BeEmpty()) + }) +})