From 1d7b3a334997abb61927d6a4f7630332355d2965 Mon Sep 17 00:00:00 2001 From: "Joseph S. Tate" Date: Mon, 1 Dec 2025 17:29:50 -0500 Subject: [PATCH 1/2] Add support for mounting CA certificates from ConfigMaps on vanilla Kubernetes Extends mount_trusted_ca to support mounting CA certificates from ConfigMaps on vanilla Kubernetes while maintaining OpenShift CNO compatibility. Added mount_trusted_ca_configmap_key field (format: "configmap-name:key") to specify the ConfigMap and key containing CA bundles. The ConfigMap can be managed manually or kept up to date using cert-manager's trust-manager. Required on vanilla K8s when mount_trusted_ca is enabled; optional on OpenShift which uses CNO injection. Enhanced MountCASpec (exported from mountCASpec) to handle both modes: - ConfigMap mode: parses "name:key" format, maps key to tls-ca-bundle.pem - OpenShift mode: uses operator-created ConfigMap with CNO injection Both mount to /etc/pki/ca-trust/extracted/pem. Added defaultsForVanillaDeployment to configure CA mounting for vanilla K8s deployments (API, Content, Worker). Validates configuration and mounts only when both mount_trusted_ca and mount_trusted_ca_configmap_key are set. Refactored deployment hash calculation by introducing CalculateDeploymentHash to strip DeprecatedServiceAccount field before hashing, preventing reconciliation loops. Updated CheckDeploymentSpec, AddHashLabel, and HashFromMutated to use new function. Removed redundant AddHashLabel call in updateObject. Fixed mount_trusted_ca CSV description by consolidating multi-line comment to single-line format that operator-sdk recognizes. Includes unit tests, documentation, and example configuration showing trust-manager integration. --- .../v1/pulp_types.go | 10 +- .../pulp-operator.clusterserviceversion.yaml | 17 +- .../repo-manager.pulpproject.org_pulps.yaml | 9 +- .../repo-manager.pulpproject.org_pulps.yaml | 9 +- .../pulp-operator.clusterserviceversion.yaml | 17 +- config/samples/simple-trust-manager.yaml | 68 +++ controllers/deployment.go | 9 +- controllers/ocp/deployment.go | 2 +- controllers/ocp/utils.go | 53 ++- controllers/ocp/utils_test.go | 381 ++++++++++++++++ controllers/repo_manager/README.md | 3 +- controllers/repo_manager/deployment.go | 40 +- controllers/repo_manager/deployment_test.go | 191 ++++++++ controllers/utils.go | 41 +- controllers/utils_deployment_test.go | 413 ++++++++++++++++++ docs/admin/guides/configurations/customCA.md | 13 +- docs/trust-manager-integration.md | 256 +++++++++++ 17 files changed, 1491 insertions(+), 41 deletions(-) create mode 100644 config/samples/simple-trust-manager.yaml create mode 100644 controllers/ocp/utils_test.go create mode 100644 controllers/repo_manager/deployment_test.go create mode 100644 controllers/utils_deployment_test.go create mode 100644 docs/trust-manager-integration.md diff --git a/apis/repo-manager.pulpproject.org/v1/pulp_types.go b/apis/repo-manager.pulpproject.org/v1/pulp_types.go index ba0c4ca83..108220c49 100644 --- a/apis/repo-manager.pulpproject.org/v1/pulp_types.go +++ b/apis/repo-manager.pulpproject.org/v1/pulp_types.go @@ -316,12 +316,20 @@ type PulpSpec struct { // +operator-sdk:csv:customresourcedefinitions:type=spec,xDescriptors={"urn:alm:descriptor:io.kubernetes:Secret","urn:alm:descriptor:com.tectonic.ui:advanced"} SSOSecret string `json:"sso_secret,omitempty"` - // Define if the operator should or should not mount the custom CA certificates added to the cluster via cluster-wide proxy config. + // Enable mounting of custom CA certificates. On OpenShift, mounts CA certificates added to the cluster via cluster-wide proxy config. On vanilla Kubernetes with cert-manager's trust-manager, requires mount_trusted_ca_configmap_key to specify the ConfigMap and key containing the CA bundle. // Default: false // +kubebuilder:validation:Optional // +operator-sdk:csv:customresourcedefinitions:type=spec,xDescriptors={"urn:alm:descriptor:com.tectonic.ui:hidden"} TrustedCa bool `json:"mount_trusted_ca,omitempty"` + // Specifies the ConfigMap and key containing the CA bundle for vanilla Kubernetes clusters. + // The ConfigMap can be managed manually or kept up to date using cert-manager's trust-manager. + // Format: "configmap-name:key" (e.g., "vault-ca-defaults-bundle:ca.crt") + // Required on vanilla Kubernetes when mount_trusted_ca is true. Optional on OpenShift. + // +kubebuilder:validation:Optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Trusted CA ConfigMap Key",xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced","urn:alm:descriptor:com.tectonic.ui:fieldDependency:mount_trusted_ca:true"} + TrustedCaConfigMapKey string `json:"mount_trusted_ca_configmap_key,omitempty"` + // Job to reset pulp admin password AdminPasswordJob PulpJob `json:"admin_password_job,omitempty"` diff --git a/bundle/manifests/pulp-operator.clusterserviceversion.yaml b/bundle/manifests/pulp-operator.clusterserviceversion.yaml index 4b7da1916..765226279 100644 --- a/bundle/manifests/pulp-operator.clusterserviceversion.yaml +++ b/bundle/manifests/pulp-operator.clusterserviceversion.yaml @@ -788,13 +788,24 @@ spec: x-descriptors: - urn:alm:descriptor:com.tectonic.ui:resourceRequirements - urn:alm:descriptor:com.tectonic.ui:advanced - - description: 'Define if the operator should or should not mount the custom - CA certificates added to the cluster via cluster-wide proxy config. Default: - false' + - description: 'Enable mounting of custom CA certificates. On OpenShift, mounts + CA certificates added to the cluster via cluster-wide proxy config. On vanilla + Kubernetes with cert-manager''s trust-manager, requires mount_trusted_ca_configmap_key + to specify the ConfigMap and key containing the CA bundle. Default: false' displayName: Trusted Ca path: mount_trusted_ca x-descriptors: - urn:alm:descriptor:com.tectonic.ui:hidden + - description: 'Specifies the ConfigMap and key containing the CA bundle for + vanilla Kubernetes clusters. The ConfigMap can be managed manually or kept + up to date using cert-manager''s trust-manager. Format: "configmap-name:key" + (e.g., "vault-ca-defaults-bundle:ca.crt") Required on vanilla Kubernetes + when mount_trusted_ca is true. Optional on OpenShift.' + displayName: Trusted CA ConfigMap Key + path: mount_trusted_ca_configmap_key + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:advanced + - urn:alm:descriptor:com.tectonic.ui:fieldDependency:mount_trusted_ca:true - description: 'The client max body size for Nginx Ingress. Default: "10m"' displayName: Nginx Max Body Size path: nginx_client_max_body_size diff --git a/bundle/manifests/repo-manager.pulpproject.org_pulps.yaml b/bundle/manifests/repo-manager.pulpproject.org_pulps.yaml index 6fdd565cb..385123f9e 100644 --- a/bundle/manifests/repo-manager.pulpproject.org_pulps.yaml +++ b/bundle/manifests/repo-manager.pulpproject.org_pulps.yaml @@ -7730,9 +7730,16 @@ spec: type: object mount_trusted_ca: description: |- - Define if the operator should or should not mount the custom CA certificates added to the cluster via cluster-wide proxy config. + Enable mounting of custom CA certificates. On OpenShift, mounts CA certificates added to the cluster via cluster-wide proxy config. On vanilla Kubernetes with cert-manager's trust-manager, requires mount_trusted_ca_configmap_key to specify the ConfigMap and key containing the CA bundle. Default: false type: boolean + mount_trusted_ca_configmap_key: + description: |- + Specifies the ConfigMap and key containing the CA bundle for vanilla Kubernetes clusters. + The ConfigMap can be managed manually or kept up to date using cert-manager's trust-manager. + Format: "configmap-name:key" (e.g., "vault-ca-defaults-bundle:ca.crt") + Required on vanilla Kubernetes when mount_trusted_ca is true. Optional on OpenShift. + type: string nginx_client_max_body_size: description: |- The client max body size for Nginx Ingress. diff --git a/config/crd/bases/repo-manager.pulpproject.org_pulps.yaml b/config/crd/bases/repo-manager.pulpproject.org_pulps.yaml index 8892cf78f..bee1ea22c 100644 --- a/config/crd/bases/repo-manager.pulpproject.org_pulps.yaml +++ b/config/crd/bases/repo-manager.pulpproject.org_pulps.yaml @@ -7730,9 +7730,16 @@ spec: type: object mount_trusted_ca: description: |- - Define if the operator should or should not mount the custom CA certificates added to the cluster via cluster-wide proxy config. + Enable mounting of custom CA certificates. On OpenShift, mounts CA certificates added to the cluster via cluster-wide proxy config. On vanilla Kubernetes with cert-manager's trust-manager, requires mount_trusted_ca_configmap_key to specify the ConfigMap and key containing the CA bundle. Default: false type: boolean + mount_trusted_ca_configmap_key: + description: |- + Specifies the ConfigMap and key containing the CA bundle for vanilla Kubernetes clusters. + The ConfigMap can be managed manually or kept up to date using cert-manager's trust-manager. + Format: "configmap-name:key" (e.g., "vault-ca-defaults-bundle:ca.crt") + Required on vanilla Kubernetes when mount_trusted_ca is true. Optional on OpenShift. + type: string nginx_client_max_body_size: description: |- The client max body size for Nginx Ingress. diff --git a/config/manifests/bases/pulp-operator.clusterserviceversion.yaml b/config/manifests/bases/pulp-operator.clusterserviceversion.yaml index 4c18abd07..9d323f040 100644 --- a/config/manifests/bases/pulp-operator.clusterserviceversion.yaml +++ b/config/manifests/bases/pulp-operator.clusterserviceversion.yaml @@ -798,13 +798,24 @@ spec: x-descriptors: - urn:alm:descriptor:com.tectonic.ui:resourceRequirements - urn:alm:descriptor:com.tectonic.ui:advanced - - description: 'Define if the operator should or should not mount the custom - CA certificates added to the cluster via cluster-wide proxy config. Default: - false' + - description: 'Enable mounting of custom CA certificates. On OpenShift, mounts + CA certificates added to the cluster via cluster-wide proxy config. On vanilla + Kubernetes with cert-manager''s trust-manager, requires mount_trusted_ca_configmap_key + to specify the ConfigMap and key containing the CA bundle. Default: false' displayName: Trusted Ca path: mount_trusted_ca x-descriptors: - urn:alm:descriptor:com.tectonic.ui:hidden + - description: 'Specifies the ConfigMap and key containing the CA bundle for + vanilla Kubernetes clusters. The ConfigMap can be managed manually or kept + up to date using cert-manager''s trust-manager. Format: "configmap-name:key" + (e.g., "vault-ca-defaults-bundle:ca.crt") Required on vanilla Kubernetes + when mount_trusted_ca is true. Optional on OpenShift.' + displayName: Trusted CA ConfigMap Key + path: mount_trusted_ca_configmap_key + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:advanced + - urn:alm:descriptor:com.tectonic.ui:fieldDependency:mount_trusted_ca:true - description: 'The client max body size for Nginx Ingress. Default: "10m"' displayName: Nginx Max Body Size path: nginx_client_max_body_size diff --git a/config/samples/simple-trust-manager.yaml b/config/samples/simple-trust-manager.yaml new file mode 100644 index 000000000..682a6f395 --- /dev/null +++ b/config/samples/simple-trust-manager.yaml @@ -0,0 +1,68 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: 'example-pulp-admin-password' +stringData: + password: 'password' + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: settings +data: + analytics: "False" + +--- +# Bundle resource managed by cert-manager's trust-manager +# This will create a ConfigMap containing the aggregated CA bundle +# See: https://cert-manager.io/docs/trust/trust-manager/ +apiVersion: trust.cert-manager.io/v1alpha1 +kind: Bundle +metadata: + name: example-pulp-trusted-ca-bundle +spec: + sources: + # Include the default system CA certificates + - useDefaultCAs: true + # Optionally include custom CAs from ConfigMaps + # - configMap: + # name: custom-ca-certs + # key: ca.crt + # Optionally include CAs from cert-manager Certificate resources + # - inLine: | + # -----BEGIN CERTIFICATE----- + # ... + # -----END CERTIFICATE----- + target: + configMap: + key: "ca-bundle.crt" + # The ConfigMap will be created with the same name as the Bundle + # In this case: example-pulp-trusted-ca-bundle + +--- +apiVersion: repo-manager.pulpproject.org/v1 +kind: Pulp +metadata: + name: example-pulp +spec: + api: + replicas: 1 + custom_pulp_settings: settings + admin_password_secret: "example-pulp-admin-password" + + # Enable trust-manager CA bundle mounting + # Format: "configmap-name:key" or just "configmap-name" (mounts all keys) + mount_trusted_ca: true + mount_trusted_ca_configmap_key: "example-pulp-trusted-ca-bundle:ca-bundle.crt" + + database: + postgres_storage_class: standard + + file_storage_access_mode: "ReadWriteOnce" + file_storage_size: "2Gi" + file_storage_storage_class: standard + + ingress_type: ingress + ingress_host: pulp.example.com diff --git a/controllers/deployment.go b/controllers/deployment.go index 3920f227c..aa62bb412 100644 --- a/controllers/deployment.go +++ b/controllers/deployment.go @@ -1141,15 +1141,16 @@ func AddHashLabel(r FunctionResources, deployment *appsv1.Deployment) { if err := r.Create(r.Context, deployment, client.DryRunAll); err != nil { hash = HashFromMutated(deployment, r) } else { + // Create a copy to avoid modifying the original deployment + depCopy := deployment.DeepCopy() + // When HPA is enabled, exclude replicas from hash calculation // to avoid race condition between operator and HPA if isHPAManagedDeployment(deployment.Name, r.Pulp) { - depCopy := deployment.DeepCopy() depCopy.Spec.Replicas = nil - hash = CalculateHash(depCopy.Spec) - } else { - hash = CalculateHash(deployment.Spec) } + + hash = CalculateDeploymentHash(depCopy.Spec) } SetHashLabel(hash, deployment) diff --git a/controllers/ocp/deployment.go b/controllers/ocp/deployment.go index b434ef736..6b132a504 100644 --- a/controllers/ocp/deployment.go +++ b/controllers/ocp/deployment.go @@ -34,7 +34,7 @@ func defaultsForOCPDeployment(deployment *appsv1.Deployment, pulp *pulpv1.Pulp) volumeMounts := deployment.Spec.Template.Spec.Containers[0].VolumeMounts // append the CA configmap to the volumes/volumemounts slice - volumes, volumeMounts = mountCASpec(pulp, volumes, volumeMounts) + volumes, volumeMounts = MountCASpec(pulp, volumes, volumeMounts) deployment.Spec.Template.Spec.Volumes = volumes deployment.Spec.Template.Spec.Containers[0].VolumeMounts = volumeMounts } diff --git a/controllers/ocp/utils.go b/controllers/ocp/utils.go index 2b0cf55be..ccda95304 100644 --- a/controllers/ocp/utils.go +++ b/controllers/ocp/utils.go @@ -18,6 +18,7 @@ package ocp import ( "context" + "strings" "github.com/go-logr/logr" pulpv1 "github.com/pulp/pulp-operator/apis/repo-manager.pulpproject.org/v1" @@ -101,23 +102,55 @@ func CreateEmptyConfigMap(r client.Client, scheme *runtime.Scheme, ctx context.C return ctrl.Result{}, nil } -// mountCASpec adds the trusted-ca bundle into []volume and []volumeMount if pulp.Spec.TrustedCA is true -func mountCASpec(pulp *pulpv1.Pulp, volumes []corev1.Volume, volumeMounts []corev1.VolumeMount) ([]corev1.Volume, []corev1.VolumeMount) { +// MountCASpec adds the trusted-ca bundle into []volume and []volumeMount if pulp.Spec.TrustedCA is true +// On OpenShift: uses the operator-created ConfigMap with CNO injection +// On vanilla K8s: uses a user-specified ConfigMap (which can be managed manually or by trust-manager) +func MountCASpec(pulp *pulpv1.Pulp, volumes []corev1.Volume, volumeMounts []corev1.VolumeMount) ([]corev1.Volume, []corev1.VolumeMount) { if pulp.Spec.TrustedCa { + var configMapName string + var configMapKey string + + // Determine ConfigMap name and key based on configuration + if len(pulp.Spec.TrustedCaConfigMapKey) > 0 { + // Vanilla K8s mode: parse "configmap-name:key" format + // If no separator, assume it's just the ConfigMap name and use the first key in the map + parts := strings.Split(pulp.Spec.TrustedCaConfigMapKey, ":") + if len(parts) == 2 { + configMapName = parts[0] + configMapKey = parts[1] + } else { + // Just ConfigMap name provided, use empty key to get first key in map + configMapName = parts[0] + configMapKey = "" + } + } else { + // OpenShift mode: use the operator-created ConfigMap with CNO injection + configMapName = settings.EmptyCAConfigMapName(pulp.Name) + configMapKey = "ca-bundle.crt" + } // trustedCAVolume contains the configmap with the custom ca bundle + defaultMode := int32(420) + configMapVolumeSource := &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: configMapName, + }, + DefaultMode: &defaultMode, + } + + // If a specific key is provided, map it to the expected path + // Otherwise, mount all keys from the ConfigMap (first key will be used) + if configMapKey != "" { + configMapVolumeSource.Items = []corev1.KeyToPath{ + {Key: configMapKey, Path: "tls-ca-bundle.pem"}, + } + } + trustedCAVolume := corev1.Volume{ Name: "trusted-ca", VolumeSource: corev1.VolumeSource{ - ConfigMap: &corev1.ConfigMapVolumeSource{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: settings.EmptyCAConfigMapName(pulp.Name), - }, - Items: []corev1.KeyToPath{ - {Key: "ca-bundle.crt", Path: "tls-ca-bundle.pem"}, - }, - }, + ConfigMap: configMapVolumeSource, }, } volumes = append(volumes, trustedCAVolume) diff --git a/controllers/ocp/utils_test.go b/controllers/ocp/utils_test.go new file mode 100644 index 000000000..51db78340 --- /dev/null +++ b/controllers/ocp/utils_test.go @@ -0,0 +1,381 @@ +/* +Copyright 2022. + +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 ocp + +import ( + "testing" + + pulpv1 "github.com/pulp/pulp-operator/apis/repo-manager.pulpproject.org/v1" + "github.com/pulp/pulp-operator/controllers/settings" + corev1 "k8s.io/api/core/v1" +) + +// TestMountCASpec_Disabled verifies that no volumes are added when TrustedCa is false +func TestMountCASpec_Disabled(t *testing.T) { + pulp := &pulpv1.Pulp{ + Spec: pulpv1.PulpSpec{ + TrustedCa: false, + }, + } + + volumes := []corev1.Volume{} + volumeMounts := []corev1.VolumeMount{} + + resultVolumes, resultVolumeMounts := MountCASpec(pulp, volumes, volumeMounts) + + if len(resultVolumes) != 0 { + t.Errorf("Expected 0 volumes when TrustedCa is false, got %d", len(resultVolumes)) + } + + if len(resultVolumeMounts) != 0 { + t.Errorf("Expected 0 volumeMounts when TrustedCa is false, got %d", len(resultVolumeMounts)) + } +} + +// TestMountCASpec_OpenShiftMode verifies OpenShift mode (no trust-manager key specified) +func TestMountCASpec_OpenShiftMode(t *testing.T) { + pulpName := "test-pulp" + pulp := &pulpv1.Pulp{ + Spec: pulpv1.PulpSpec{ + TrustedCa: true, + TrustedCaConfigMapKey: "", // Empty means OpenShift mode + }, + } + pulp.Name = pulpName + + volumes := []corev1.Volume{} + volumeMounts := []corev1.VolumeMount{} + + resultVolumes, resultVolumeMounts := MountCASpec(pulp, volumes, volumeMounts) + + // Verify we got exactly one volume and one mount + if len(resultVolumes) != 1 { + t.Fatalf("Expected 1 volume in OpenShift mode, got %d", len(resultVolumes)) + } + + if len(resultVolumeMounts) != 1 { + t.Fatalf("Expected 1 volumeMount in OpenShift mode, got %d", len(resultVolumeMounts)) + } + + // Verify volume configuration + volume := resultVolumes[0] + if volume.Name != "trusted-ca" { + t.Errorf("Expected volume name 'trusted-ca', got '%s'", volume.Name) + } + + expectedConfigMapName := settings.EmptyCAConfigMapName(pulpName) + if volume.ConfigMap.Name != expectedConfigMapName { + t.Errorf("Expected ConfigMap name '%s', got '%s'", expectedConfigMapName, volume.ConfigMap.Name) + } + + if len(volume.ConfigMap.Items) != 1 { + t.Fatalf("Expected 1 item in ConfigMap volume, got %d", len(volume.ConfigMap.Items)) + } + + if volume.ConfigMap.Items[0].Key != "ca-bundle.crt" { + t.Errorf("Expected ConfigMap key 'ca-bundle.crt', got '%s'", volume.ConfigMap.Items[0].Key) + } + + if volume.ConfigMap.Items[0].Path != "tls-ca-bundle.pem" { + t.Errorf("Expected path 'tls-ca-bundle.pem', got '%s'", volume.ConfigMap.Items[0].Path) + } + + // Verify volume mount configuration + volumeMount := resultVolumeMounts[0] + if volumeMount.Name != "trusted-ca" { + t.Errorf("Expected volumeMount name 'trusted-ca', got '%s'", volumeMount.Name) + } + + if volumeMount.MountPath != "/etc/pki/ca-trust/extracted/pem" { + t.Errorf("Expected mountPath '/etc/pki/ca-trust/extracted/pem', got '%s'", volumeMount.MountPath) + } + + if !volumeMount.ReadOnly { + t.Error("Expected volumeMount to be ReadOnly") + } +} + +// TestMountCASpec_TrustManagerMode verifies trust-manager mode with just ConfigMap name (no key specified) +func TestMountCASpec_TrustManagerMode(t *testing.T) { + pulpName := "test-pulp" + configMapName := "my-ca-bundle" + + pulp := &pulpv1.Pulp{ + Spec: pulpv1.PulpSpec{ + TrustedCa: true, + TrustedCaConfigMapKey: configMapName, // Just ConfigMap name, no ":" + }, + } + pulp.Name = pulpName + + volumes := []corev1.Volume{} + volumeMounts := []corev1.VolumeMount{} + + resultVolumes, resultVolumeMounts := MountCASpec(pulp, volumes, volumeMounts) + + // Verify we got exactly one volume and one mount + if len(resultVolumes) != 1 { + t.Fatalf("Expected 1 volume in trust-manager mode, got %d", len(resultVolumes)) + } + + if len(resultVolumeMounts) != 1 { + t.Fatalf("Expected 1 volumeMount in trust-manager mode, got %d", len(resultVolumeMounts)) + } + + // Verify volume configuration + volume := resultVolumes[0] + if volume.Name != "trusted-ca" { + t.Errorf("Expected volume name 'trusted-ca', got '%s'", volume.Name) + } + + if volume.ConfigMap.Name != configMapName { + t.Errorf("Expected ConfigMap name '%s', got '%s'", configMapName, volume.ConfigMap.Name) + } + + // When no key is specified, Items should be empty (mounts all keys) + if len(volume.ConfigMap.Items) != 0 { + t.Errorf("Expected 0 items in ConfigMap volume (mount all keys), got %d", len(volume.ConfigMap.Items)) + } + + // Verify volume mount configuration + volumeMount := resultVolumeMounts[0] + if volumeMount.Name != "trusted-ca" { + t.Errorf("Expected volumeMount name 'trusted-ca', got '%s'", volumeMount.Name) + } + + if volumeMount.MountPath != "/etc/pki/ca-trust/extracted/pem" { + t.Errorf("Expected mountPath '/etc/pki/ca-trust/extracted/pem', got '%s'", volumeMount.MountPath) + } + + if !volumeMount.ReadOnly { + t.Error("Expected volumeMount to be ReadOnly") + } +} + +// TestMountCASpec_CustomKey verifies trust-manager mode with "configmap:key" format +func TestMountCASpec_CustomKey(t *testing.T) { + pulpName := "test-pulp" + configMapName := "my-custom-bundle" + customKey := "custom-ca-bundle.pem" + separatorFormat := configMapName + ":" + customKey + + pulp := &pulpv1.Pulp{ + Spec: pulpv1.PulpSpec{ + TrustedCa: true, + TrustedCaConfigMapKey: separatorFormat, + }, + } + pulp.Name = pulpName + + volumes := []corev1.Volume{} + volumeMounts := []corev1.VolumeMount{} + + resultVolumes, _ := MountCASpec(pulp, volumes, volumeMounts) + + if len(resultVolumes) != 1 { + t.Fatalf("Expected 1 volume, got %d", len(resultVolumes)) + } + + volume := resultVolumes[0] + if volume.ConfigMap.Name != configMapName { + t.Errorf("Expected ConfigMap name '%s', got '%s'", configMapName, volume.ConfigMap.Name) + } + + if len(volume.ConfigMap.Items) != 1 { + t.Fatalf("Expected 1 item in ConfigMap volume, got %d", len(volume.ConfigMap.Items)) + } + + if volume.ConfigMap.Items[0].Key != customKey { + t.Errorf("Expected ConfigMap key '%s', got '%s'", customKey, volume.ConfigMap.Items[0].Key) + } +} + +// TestMountCASpec_PreservesExistingVolumes verifies that existing volumes are preserved +func TestMountCASpec_PreservesExistingVolumes(t *testing.T) { + pulp := &pulpv1.Pulp{ + Spec: pulpv1.PulpSpec{ + TrustedCa: true, + TrustedCaConfigMapKey: "my-bundle:ca-bundle.crt", + }, + } + pulp.Name = "test-pulp" + + // Start with existing volumes and mounts + existingVolumes := []corev1.Volume{ + {Name: "existing-volume-1"}, + {Name: "existing-volume-2"}, + } + existingVolumeMounts := []corev1.VolumeMount{ + {Name: "existing-mount-1"}, + {Name: "existing-mount-2"}, + } + + resultVolumes, resultVolumeMounts := MountCASpec(pulp, existingVolumes, existingVolumeMounts) + + // Should have 3 volumes (2 existing + 1 new) + if len(resultVolumes) != 3 { + t.Errorf("Expected 3 volumes (2 existing + 1 new), got %d", len(resultVolumes)) + } + + // Should have 3 mounts (2 existing + 1 new) + if len(resultVolumeMounts) != 3 { + t.Errorf("Expected 3 volumeMounts (2 existing + 1 new), got %d", len(resultVolumeMounts)) + } + + // Verify existing volumes are preserved + if resultVolumes[0].Name != "existing-volume-1" { + t.Error("First existing volume was not preserved") + } + if resultVolumes[1].Name != "existing-volume-2" { + t.Error("Second existing volume was not preserved") + } + if resultVolumes[2].Name != "trusted-ca" { + t.Error("New trusted-ca volume not added correctly") + } + + // Verify existing mounts are preserved + if resultVolumeMounts[0].Name != "existing-mount-1" { + t.Error("First existing mount was not preserved") + } + if resultVolumeMounts[1].Name != "existing-mount-2" { + t.Error("Second existing mount was not preserved") + } + if resultVolumeMounts[2].Name != "trusted-ca" { + t.Error("New trusted-ca mount not added correctly") + } +} + +// TestMountCASpec_SeparatorFormat verifies the "configmap-name:key" separator format +func TestMountCASpec_SeparatorFormat(t *testing.T) { + pulpName := "test-pulp" + configMapName := "vault-ca-defaults-bundle" + configMapKey := "ca.crt" + separatorFormat := configMapName + ":" + configMapKey + + pulp := &pulpv1.Pulp{ + Spec: pulpv1.PulpSpec{ + TrustedCa: true, + TrustedCaConfigMapKey: separatorFormat, + }, + } + pulp.Name = pulpName + + volumes := []corev1.Volume{} + volumeMounts := []corev1.VolumeMount{} + + resultVolumes, resultVolumeMounts := MountCASpec(pulp, volumes, volumeMounts) + + // Verify we got exactly one volume and one mount + if len(resultVolumes) != 1 { + t.Fatalf("Expected 1 volume with separator format, got %d", len(resultVolumes)) + } + + if len(resultVolumeMounts) != 1 { + t.Fatalf("Expected 1 volumeMount with separator format, got %d", len(resultVolumeMounts)) + } + + // Verify volume configuration - should use the parsed ConfigMap name + volume := resultVolumes[0] + if volume.Name != "trusted-ca" { + t.Errorf("Expected volume name 'trusted-ca', got '%s'", volume.Name) + } + + if volume.ConfigMap.Name != configMapName { + t.Errorf("Expected ConfigMap name '%s', got '%s'", configMapName, volume.ConfigMap.Name) + } + + if len(volume.ConfigMap.Items) != 1 { + t.Fatalf("Expected 1 item in ConfigMap volume, got %d", len(volume.ConfigMap.Items)) + } + + if volume.ConfigMap.Items[0].Key != configMapKey { + t.Errorf("Expected ConfigMap key '%s', got '%s'", configMapKey, volume.ConfigMap.Items[0].Key) + } + + if volume.ConfigMap.Items[0].Path != "tls-ca-bundle.pem" { + t.Errorf("Expected path 'tls-ca-bundle.pem', got '%s'", volume.ConfigMap.Items[0].Path) + } + + // Verify volume mount configuration + volumeMount := resultVolumeMounts[0] + if volumeMount.Name != "trusted-ca" { + t.Errorf("Expected volumeMount name 'trusted-ca', got '%s'", volumeMount.Name) + } + + if volumeMount.MountPath != "/etc/pki/ca-trust/extracted/pem" { + t.Errorf("Expected mountPath '/etc/pki/ca-trust/extracted/pem', got '%s'", volumeMount.MountPath) + } + + if !volumeMount.ReadOnly { + t.Error("Expected volumeMount to be ReadOnly") + } +} + +// TestMountCASpec_MountPathConsistency verifies mount path is consistent across modes +func TestMountCASpec_MountPathConsistency(t *testing.T) { + pulpName := "test-pulp" + expectedMountPath := "/etc/pki/ca-trust/extracted/pem" + + testCases := []struct { + name string + trustedCa bool + trustedCaConfigMapKey string + description string + }{ + { + name: "OpenShift mode", + trustedCa: true, + trustedCaConfigMapKey: "", + description: "OpenShift CNO injection mode", + }, + { + name: "trust-manager mode - ConfigMap name only", + trustedCa: true, + trustedCaConfigMapKey: "my-ca-bundle", + description: "trust-manager mode with ConfigMap name only", + }, + { + name: "trust-manager mode - with separator", + trustedCa: true, + trustedCaConfigMapKey: "my-ca-bundle:ca-bundle.crt", + description: "trust-manager mode with configmap:key format", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + pulp := &pulpv1.Pulp{ + Spec: pulpv1.PulpSpec{ + TrustedCa: tc.trustedCa, + TrustedCaConfigMapKey: tc.trustedCaConfigMapKey, + }, + } + pulp.Name = pulpName + + _, resultVolumeMounts := MountCASpec(pulp, []corev1.Volume{}, []corev1.VolumeMount{}) + + if len(resultVolumeMounts) != 1 { + t.Fatalf("Expected 1 volumeMount, got %d", len(resultVolumeMounts)) + } + + if resultVolumeMounts[0].MountPath != expectedMountPath { + t.Errorf("%s: Expected mountPath '%s', got '%s'", + tc.description, expectedMountPath, resultVolumeMounts[0].MountPath) + } + }) + } +} diff --git a/controllers/repo_manager/README.md b/controllers/repo_manager/README.md index cff5bf3cd..f02353e37 100644 --- a/controllers/repo_manager/README.md +++ b/controllers/repo_manager/README.md @@ -250,7 +250,8 @@ PulpSpec defines the desired state of Pulp | sa_annotations | ServiceAccount.metadata.annotations that will be used in Pulp pods. | map[string]string | false | | sa_labels | ServiceAccount.metadata.labels that will be used in Pulp pods. | map[string]string | false | | sso_secret | Secret where Single Sign-on configuration can be found | string | false | -| mount_trusted_ca | Define if the operator should or should not mount the custom CA certificates added to the cluster via cluster-wide proxy config. Default: false | bool | false | +| mount_trusted_ca | Enable mounting of custom CA certificates. On OpenShift, mounts CA certificates added to the cluster via cluster-wide proxy config. On vanilla Kubernetes with cert-manager's trust-manager, requires mount_trusted_ca_configmap_key to specify the ConfigMap and key containing the CA bundle. Default: false | bool | false | +| mount_trusted_ca_configmap_key | Specifies the ConfigMap and key containing the CA bundle for vanilla Kubernetes clusters. The ConfigMap can be managed manually or kept up to date using cert-manager's trust-manager. Format: \"configmap-name:key\" (e.g., \"vault-ca-defaults-bundle:ca.crt\") Required on vanilla Kubernetes when mount_trusted_ca is true. Optional on OpenShift. | string | false | | admin_password_job | Job to reset pulp admin password | [PulpJob](#pulpjob) | false | | migration_job | Job to run django migrations | [PulpJob](#pulpjob) | false | | signing_job | Job to store signing metadata scripts | [PulpJob](#pulpjob) | false | diff --git a/controllers/repo_manager/deployment.go b/controllers/repo_manager/deployment.go index e13a73840..78a3bea22 100644 --- a/controllers/repo_manager/deployment.go +++ b/controllers/repo_manager/deployment.go @@ -16,8 +16,10 @@ limitations under the License. package repo_manager import ( + pulpv1 "github.com/pulp/pulp-operator/apis/repo-manager.pulpproject.org/v1" "github.com/pulp/pulp-operator/controllers" pulp_ocp "github.com/pulp/pulp-operator/controllers/ocp" + appsv1 "k8s.io/api/apps/v1" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -70,13 +72,41 @@ type Deployer interface { Deploy(controllers.FunctionResources) client.Object } +// defaultsForVanillaDeployment sets the common Deployment configurations for vanilla k8s clusters +// This includes CA bundle mounting from ConfigMaps when configured +func defaultsForVanillaDeployment(deployment client.Object, pulp *pulpv1.Pulp) { + dep := deployment.(*appsv1.Deployment) + + // Validate CA ConfigMap configuration on vanilla K8s + if pulp.Spec.TrustedCa && len(pulp.Spec.TrustedCaConfigMapKey) == 0 { + controllers.CustomZapLogger().Error(`mount_trusted_ca is true but mount_trusted_ca_configmap_key is not set. ` + + `On vanilla Kubernetes, you must specify mount_trusted_ca_configmap_key to reference a ConfigMap containing CA certificates. ` + + `This field is only optional on OpenShift where CNO injection is used.`) + return + } + + // Only mount CA bundles if ConfigMap is specified + if pulp.Spec.TrustedCa && len(pulp.Spec.TrustedCaConfigMapKey) > 0 { + // get the current volume mount points + volumes := dep.Spec.Template.Spec.Volumes + volumeMounts := dep.Spec.Template.Spec.Containers[0].VolumeMounts + + // append the CA configmap to the volumes/volumemounts slice + volumes, volumeMounts = pulp_ocp.MountCASpec(pulp, volumes, volumeMounts) + dep.Spec.Template.Spec.Volumes = volumes + dep.Spec.Template.Spec.Containers[0].VolumeMounts = volumeMounts + } +} + // DeploymentAPIVanilla is the pulpcore-api Deployment definition for common k8s distributions type DeploymentAPIVanilla struct{} // Deploy returns a pulp-api Deployment object func (DeploymentAPIVanilla) Deploy(resources controllers.FunctionResources) client.Object { dep := controllers.DeploymentAPICommon{} - return dep.Deploy(resources) + deployment := dep.Deploy(resources) + defaultsForVanillaDeployment(deployment, resources.Pulp) + return deployment } // DeploymentContentVanilla is the pulpcore-content Deployment definition for common k8s distributions @@ -85,7 +115,9 @@ type DeploymentContentVanilla struct{} // Deploy returns a pulp-content Deployment object func (DeploymentContentVanilla) Deploy(resources controllers.FunctionResources) client.Object { dep := controllers.DeploymentContentCommon{} - return dep.Deploy(resources) + deployment := dep.Deploy(resources) + defaultsForVanillaDeployment(deployment, resources.Pulp) + return deployment } // DeploymentWorkerVanilla is the pulpcore-worker Deployment definition for common k8s distributions @@ -94,5 +126,7 @@ type DeploymentWorkerVanilla struct{} // Deploy returns a pulp-worker Deployment object func (DeploymentWorkerVanilla) Deploy(resources controllers.FunctionResources) client.Object { dep := controllers.DeploymentWorkerCommon{} - return dep.Deploy(resources) + deployment := dep.Deploy(resources) + defaultsForVanillaDeployment(deployment, resources.Pulp) + return deployment } diff --git a/controllers/repo_manager/deployment_test.go b/controllers/repo_manager/deployment_test.go new file mode 100644 index 000000000..c4890bb7d --- /dev/null +++ b/controllers/repo_manager/deployment_test.go @@ -0,0 +1,191 @@ +/* +Copyright 2022. + +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 repo_manager + +import ( + "testing" + + pulpv1 "github.com/pulp/pulp-operator/apis/repo-manager.pulpproject.org/v1" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" +) + +// TestDefaultsForVanillaDeployment_NoTrustManager verifies no changes when trust-manager is not configured +func TestDefaultsForVanillaDeployment_NoTrustManager(t *testing.T) { + pulp := &pulpv1.Pulp{ + Spec: pulpv1.PulpSpec{ + TrustedCa: false, + TrustedCaConfigMapKey: "", + }, + } + pulp.Name = "test-pulp" + + deployment := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Volumes: []corev1.Volume{ + {Name: "existing-volume"}, + }, + Containers: []corev1.Container{ + { + Name: "pulp", + VolumeMounts: []corev1.VolumeMount{ + {Name: "existing-mount"}, + }, + }, + }, + }, + }, + }, + } + + defaultsForVanillaDeployment(deployment, pulp) + + // Should still have only the original volume and mount + if len(deployment.Spec.Template.Spec.Volumes) != 1 { + t.Errorf("Expected 1 volume when trust-manager not configured, got %d", len(deployment.Spec.Template.Spec.Volumes)) + } + + if len(deployment.Spec.Template.Spec.Containers[0].VolumeMounts) != 1 { + t.Errorf("Expected 1 volumeMount when trust-manager not configured, got %d", len(deployment.Spec.Template.Spec.Containers[0].VolumeMounts)) + } +} + +// TestDefaultsForVanillaDeployment_TrustedCaEnabledNoKey verifies error handling when TrustedCa is set but ConfigMapKey is not +func TestDefaultsForVanillaDeployment_TrustedCaEnabledNoKey(t *testing.T) { + pulp := &pulpv1.Pulp{ + Spec: pulpv1.PulpSpec{ + TrustedCa: true, + TrustedCaConfigMapKey: "", // No key - invalid configuration on vanilla K8s + }, + } + pulp.Name = "test-pulp" + + deployment := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Volumes: []corev1.Volume{ + {Name: "existing-volume"}, + }, + Containers: []corev1.Container{ + { + Name: "pulp", + VolumeMounts: []corev1.VolumeMount{ + {Name: "existing-mount"}, + }, + }, + }, + }, + }, + }, + } + + // This should log an error and return early without modifying the deployment + defaultsForVanillaDeployment(deployment, pulp) + + // Should still have only the original volume and mount (invalid config, no changes made) + if len(deployment.Spec.Template.Spec.Volumes) != 1 { + t.Errorf("Expected 1 volume when ConfigMapKey not set (invalid config), got %d", len(deployment.Spec.Template.Spec.Volumes)) + } + + if len(deployment.Spec.Template.Spec.Containers[0].VolumeMounts) != 1 { + t.Errorf("Expected 1 volumeMount when ConfigMapKey not set (invalid config), got %d", len(deployment.Spec.Template.Spec.Containers[0].VolumeMounts)) + } +} + +// TestDefaultsForVanillaDeployment_WithTrustManager verifies CA mounting when trust-manager is configured +func TestDefaultsForVanillaDeployment_WithTrustManager(t *testing.T) { + pulpName := "test-pulp" + configMapName := "vault-ca-defaults-bundle" + configMapKey := "ca.crt" + separatorFormat := configMapName + ":" + configMapKey + + pulp := &pulpv1.Pulp{ + Spec: pulpv1.PulpSpec{ + TrustedCa: true, + TrustedCaConfigMapKey: separatorFormat, + }, + } + pulp.Name = pulpName + + deployment := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Volumes: []corev1.Volume{ + {Name: "existing-volume"}, + }, + Containers: []corev1.Container{ + { + Name: "pulp", + VolumeMounts: []corev1.VolumeMount{ + {Name: "existing-mount"}, + }, + }, + }, + }, + }, + }, + } + + defaultsForVanillaDeployment(deployment, pulp) + + // Should now have 2 volumes (existing + trusted-ca) + if len(deployment.Spec.Template.Spec.Volumes) != 2 { + t.Fatalf("Expected 2 volumes with trust-manager configured, got %d", len(deployment.Spec.Template.Spec.Volumes)) + } + + // Should now have 2 mounts (existing + trusted-ca) + if len(deployment.Spec.Template.Spec.Containers[0].VolumeMounts) != 2 { + t.Fatalf("Expected 2 volumeMounts with trust-manager configured, got %d", len(deployment.Spec.Template.Spec.Containers[0].VolumeMounts)) + } + + // Verify the CA volume was added correctly + caVolume := deployment.Spec.Template.Spec.Volumes[1] + if caVolume.Name != "trusted-ca" { + t.Errorf("Expected volume name 'trusted-ca', got '%s'", caVolume.Name) + } + + if caVolume.ConfigMap.Name != configMapName { + t.Errorf("Expected ConfigMap name '%s', got '%s'", configMapName, caVolume.ConfigMap.Name) + } + + if len(caVolume.ConfigMap.Items) != 1 { + t.Fatalf("Expected 1 item in ConfigMap volume, got %d", len(caVolume.ConfigMap.Items)) + } + + if caVolume.ConfigMap.Items[0].Key != configMapKey { + t.Errorf("Expected ConfigMap key '%s', got '%s'", configMapKey, caVolume.ConfigMap.Items[0].Key) + } + + // Verify the CA mount was added correctly + caMount := deployment.Spec.Template.Spec.Containers[0].VolumeMounts[1] + if caMount.Name != "trusted-ca" { + t.Errorf("Expected volumeMount name 'trusted-ca', got '%s'", caMount.Name) + } + + if caMount.MountPath != "/etc/pki/ca-trust/extracted/pem" { + t.Errorf("Expected mountPath '/etc/pki/ca-trust/extracted/pem', got '%s'", caMount.MountPath) + } +} + +// Note: Full integration tests with Deploy() require additional setup +// (k8s client, schemes, etc.) and are covered by the controller_test.go +// integration test suite. These unit tests focus on the defaultsForVanillaDeployment +// function behavior in isolation. diff --git a/controllers/utils.go b/controllers/utils.go index 597fb7620..2f14c5395 100644 --- a/controllers/utils.go +++ b/controllers/utils.go @@ -463,6 +463,12 @@ func checkSpecModification(fields ...interface{}) bool { return !equality.Semantic.DeepDerivative(expectedField, currentField) } +// stripDeprecatedServiceAccount removes the deprecated serviceAccount field +// before hash calculation to ensure compatibility across Kubernetes versions +func stripDeprecatedServiceAccount(spec *appsv1.DeploymentSpec) { + spec.Template.Spec.DeprecatedServiceAccount = "" +} + // CheckDeployment returns true if a spec from deployment is not // with the expected contents defined in Pulp CR func CheckDeploymentSpec(fields ...interface{}) bool { @@ -470,13 +476,13 @@ func CheckDeploymentSpec(fields ...interface{}) bool { current := fields[1].(appsv1.Deployment) resources := fields[2].(FunctionResources) + // Create copies to avoid modifying the original objects + expectedCopy := expected.DeepCopy() + currentCopy := current.DeepCopy() + // When HPA is enabled, exclude replicas field from comparison // to avoid race condition between operator and HPA if isHPAManagedDeployment(expected.Name, resources.Pulp) { - // Create copies to avoid modifying the original objects - expectedCopy := expected.DeepCopy() - currentCopy := current.DeepCopy() - // Set both replicas to nil for comparison purposes // This ensures HPA-managed replica counts don't trigger reconciliation expectedCopy.Spec.Replicas = nil @@ -484,13 +490,15 @@ func CheckDeploymentSpec(fields ...interface{}) bool { hashFromLabel := GetCurrentHash(¤t) hashFromExpected := HashFromMutated(expectedCopy, resources) - hashFromCurrent := CalculateHash(currentCopy.Spec) + hashFromCurrent := CalculateDeploymentHash(currentCopy.Spec) + return deploymentChanged(hashFromLabel, hashFromExpected, hashFromCurrent) } hashFromLabel := GetCurrentHash(¤t) - hashFromExpected := HashFromMutated(&expected, resources) - hashFromCurrent := CalculateHash(current.Spec) + hashFromExpected := HashFromMutated(expectedCopy, resources) + hashFromCurrent := CalculateDeploymentHash(currentCopy.Spec) + return deploymentChanged(hashFromLabel, hashFromExpected, hashFromCurrent) } @@ -856,6 +864,18 @@ func CalculateHash(obj any) string { return hex.EncodeToString(calculatedHash.Sum(nil)) } +// CalculateDeploymentHash calculates the hash of a deployment spec after +// stripping fields that should not trigger reconciliation (like DeprecatedServiceAccount) +func CalculateDeploymentHash(spec appsv1.DeploymentSpec) string { + // Create a copy to avoid modifying the original + specCopy := spec.DeepCopy() + + // Strip fields that shouldn't affect the hash + stripDeprecatedServiceAccount(specCopy) + + return CalculateHash(*specCopy) +} + // SetHashLabel appends the operator's hash label into object func SetHashLabel(label string, obj client.Object) { currentLabels := obj.GetLabels() @@ -876,15 +896,16 @@ func HashFromMutated(dep *appsv1.Deployment, resources FunctionResources) string // mutated configurations resources.Update(resources.Context, dep, client.DryRunAll) + // Create a copy to strip fields we want to exclude from hash calculation + depCopy := dep.DeepCopy() + // When HPA is enabled, exclude replicas from hash calculation // to avoid race condition between operator and HPA if isHPAManagedDeployment(dep.Name, resources.Pulp) { - depCopy := dep.DeepCopy() depCopy.Spec.Replicas = nil - return CalculateHash(depCopy.Spec) } - return CalculateHash(dep.Spec) + return CalculateDeploymentHash(depCopy.Spec) } // pulpcoreEnvVars retuns the list of variable names that are defined by pulp-operator diff --git a/controllers/utils_deployment_test.go b/controllers/utils_deployment_test.go new file mode 100644 index 000000000..90fa9e93d --- /dev/null +++ b/controllers/utils_deployment_test.go @@ -0,0 +1,413 @@ +/* +Copyright 2022. + +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 controllers + +import ( + "context" + "testing" + + pulpv1 "github.com/pulp/pulp-operator/apis/repo-manager.pulpproject.org/v1" + "github.com/pulp/pulp-operator/controllers/settings" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/log/zap" +) + +// TestDeprecatedServiceAccountField verifies that the deprecated serviceAccount field +// is properly stripped during hash calculation to prevent false positives +func TestDeprecatedServiceAccountField(t *testing.T) { + scheme := runtime.NewScheme() + _ = pulpv1.AddToScheme(scheme) + _ = appsv1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + + pulp := &pulpv1.Pulp{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pulp", + Namespace: "test-namespace", + }, + Spec: pulpv1.PulpSpec{ + Api: pulpv1.Api{ + Replicas: 1, + }, + }, + } + + serviceAccountName := settings.PulpServiceAccount(pulp.Name) + + tests := []struct { + name string + currentHasDeprecatedServiceAcct bool + shouldDetectChange bool + description string + }{ + { + name: "Kubernetes populates DeprecatedServiceAccount - should NOT detect change", + currentHasDeprecatedServiceAcct: true, + shouldDetectChange: false, + description: "When Kubernetes populates DeprecatedServiceAccount, it's stripped during comparison", + }, + { + name: "Current without DeprecatedServiceAccount - should NOT detect change", + currentHasDeprecatedServiceAcct: false, + shouldDetectChange: false, + description: "When field is not set, no change detected since we strip it anyway", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pulp).Build() + + // Create expected deployment (operator-created, never has DeprecatedServiceAccount) + expectedDep := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: settings.API.DeploymentName(pulp.Name), + Namespace: pulp.Namespace, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: int32Ptr(1), + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "pulp-api"}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": "pulp-api"}, + }, + Spec: corev1.PodSpec{ + ServiceAccountName: serviceAccountName, + Containers: []corev1.Container{ + { + Name: "api", + Image: "test:latest", + }, + }, + }, + }, + }, + } + + // Create current deployment - this simulates what Kubernetes API server returns + currentDep := expectedDep.DeepCopy() + if tt.currentHasDeprecatedServiceAcct { + // Kubernetes automatically populates this field from ServiceAccountName + currentDep.Spec.Template.Spec.DeprecatedServiceAccount = serviceAccountName + } + + // Create FunctionResources with a logger + funcResources := FunctionResources{ + Context: context.Background(), + Client: client, + Pulp: pulp, + Scheme: scheme, + Logger: zap.New(zap.UseDevMode(true)), + } + + // Calculate hash with the field stripped and set as label + // This simulates what AddHashLabel() does + currentCopy := currentDep.DeepCopy() + currentCopy.Spec.Template.Spec.DeprecatedServiceAccount = "" + currentHash := CalculateHash(currentCopy.Spec) + SetHashLabel(currentHash, currentDep) + + // Test the CheckDeploymentSpec function + changed := CheckDeploymentSpec(*expectedDep, *currentDep, funcResources) + + if changed != tt.shouldDetectChange { + t.Errorf("%s: expected change detection = %v, got %v. %s", + tt.name, tt.shouldDetectChange, changed, tt.description) + } + }) + } +} + +// TestHashLabelUpdateDuringReconciliation verifies that the hash label is properly +// updated when a deployment is updated, preventing reconciliation loops +func TestHashLabelUpdateDuringReconciliation(t *testing.T) { + scheme := runtime.NewScheme() + _ = pulpv1.AddToScheme(scheme) + _ = appsv1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + + pulp := &pulpv1.Pulp{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pulp", + Namespace: "test-namespace", + }, + Spec: pulpv1.PulpSpec{ + Api: pulpv1.Api{ + Replicas: 1, + }, + }, + } + + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pulp).Build() + serviceAccountName := settings.PulpServiceAccount(pulp.Name) + + // Create a deployment with an OLD configuration + oldDep := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: settings.API.DeploymentName(pulp.Name), + Namespace: pulp.Namespace, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: int32Ptr(1), + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "pulp-api"}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": "pulp-api"}, + }, + Spec: corev1.PodSpec{ + ServiceAccountName: serviceAccountName, + Containers: []corev1.Container{ + { + Name: "api", + Image: "test:old", + }, + }, + }, + }, + }, + } + + // Calculate and set hash for OLD deployment (with DeprecatedServiceAccount stripped) + oldCopy := oldDep.DeepCopy() + oldCopy.Spec.Template.Spec.DeprecatedServiceAccount = "" + oldHash := CalculateHash(oldCopy.Spec) + SetHashLabel(oldHash, oldDep) + + // Create a NEW deployment with updated configuration + newDep := oldDep.DeepCopy() + newDep.Spec.Template.Spec.Containers[0].Image = "test:new" + + // This simulates the bug: AddHashLabel was called when the deployment was first created, + // but the hash label still reflects the OLD spec + funcResources := FunctionResources{ + Context: context.Background(), + Client: client, + Pulp: pulp, + Scheme: scheme, + Logger: zap.New(zap.UseDevMode(true)), + } + + // Before the fix, this would have the OLD hash + oldHashFromLabel := GetCurrentHash(newDep) + + // Simulate what happens in updateObject when a change is detected + // This is the fix: update the hash label before sending to Kubernetes + AddHashLabel(funcResources, newDep) + newHashFromLabel := GetCurrentHash(newDep) + + // Calculate what the NEW hash should be (with DeprecatedServiceAccount stripped) + newCopy := newDep.DeepCopy() + newCopy.Spec.Template.Spec.DeprecatedServiceAccount = "" + expectedNewHash := CalculateHash(newCopy.Spec) + + // Verify the hash was updated + if oldHashFromLabel == newHashFromLabel { + t.Errorf("Hash label was not updated after AddHashLabel call") + t.Logf("Old hash: %s, New hash: %s", oldHashFromLabel, newHashFromLabel) + } + + if newHashFromLabel != expectedNewHash { + t.Errorf("New hash label doesn't match expected hash") + t.Logf("Expected: %s, Got: %s", expectedNewHash, newHashFromLabel) + } + + // Verify that subsequent reconciliation would NOT detect a change + // (because the hash label now matches the spec) + currentDep := newDep.DeepCopy() + changed := CheckDeploymentSpec(*newDep, *currentDep, funcResources) + + if changed { + t.Errorf("CheckDeploymentSpec detected a change after hash label update, should be stable") + t.Logf("Hash from label: %s", GetCurrentHash(currentDep)) + currentCopy := currentDep.DeepCopy() + currentCopy.Spec.Template.Spec.DeprecatedServiceAccount = "" + t.Logf("Hash from spec: %s", CalculateHash(currentCopy.Spec)) + } +} + +// TestReconciliationLoopPrevention verifies that the operator doesn't enter a +// reconciliation loop when deployments are stable +func TestReconciliationLoopPrevention(t *testing.T) { + scheme := runtime.NewScheme() + _ = pulpv1.AddToScheme(scheme) + _ = appsv1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + + pulp := &pulpv1.Pulp{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pulp", + Namespace: "test-namespace", + }, + Spec: pulpv1.PulpSpec{ + Api: pulpv1.Api{ + Replicas: 1, + }, + }, + } + + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pulp).Build() + serviceAccountName := settings.PulpServiceAccount(pulp.Name) + + // Create a properly configured deployment with DeprecatedServiceAccount + dep := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: settings.API.DeploymentName(pulp.Name), + Namespace: pulp.Namespace, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: int32Ptr(1), + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "pulp-api"}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": "pulp-api"}, + }, + Spec: corev1.PodSpec{ + ServiceAccountName: serviceAccountName, + DeprecatedServiceAccount: serviceAccountName, + Containers: []corev1.Container{ + { + Name: "api", + Image: "test:latest", + }, + }, + }, + }, + }, + } + + funcResources := FunctionResources{ + Context: context.Background(), + Client: client, + Pulp: pulp, + Scheme: scheme, + Logger: zap.New(zap.UseDevMode(true)), + } + + // Set the hash label + AddHashLabel(funcResources, dep) + + // Simulate multiple reconciliation loops + for i := 0; i < 5; i++ { + currentDep := dep.DeepCopy() + expectedDep := dep.DeepCopy() + + // Check if deployment has changed + changed := CheckDeploymentSpec(*expectedDep, *currentDep, funcResources) + + if changed { + t.Errorf("Reconciliation loop %d detected a change when deployment is stable", i+1) + t.Logf("Expected hash: %s", HashFromMutated(expectedDep, funcResources)) + t.Logf("Current hash: %s", CalculateHash(currentDep.Spec)) + t.Logf("Hash from label: %s", GetCurrentHash(currentDep)) + break + } + } +} + +// TestKubernetesAPIMutationHandling verifies that the operator correctly handles +// Kubernetes API server mutations through dry-run +func TestKubernetesAPIMutationHandling(t *testing.T) { + scheme := runtime.NewScheme() + _ = pulpv1.AddToScheme(scheme) + _ = appsv1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + + pulp := &pulpv1.Pulp{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pulp", + Namespace: "test-namespace", + }, + Spec: pulpv1.PulpSpec{ + Api: pulpv1.Api{ + Replicas: 1, + }, + }, + } + + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pulp).Build() + + // Create a deployment WITHOUT Kubernetes defaults + dep := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: settings.API.DeploymentName(pulp.Name), + Namespace: pulp.Namespace, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: int32Ptr(1), + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "pulp-api"}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": "pulp-api"}, + }, + Spec: corev1.PodSpec{ + ServiceAccountName: settings.PulpServiceAccount(pulp.Name), + DeprecatedServiceAccount: settings.PulpServiceAccount(pulp.Name), + Containers: []corev1.Container{ + { + Name: "api", + Image: "test:latest", + }, + }, + }, + }, + }, + } + + funcResources := FunctionResources{ + Context: context.Background(), + Client: client, + Pulp: pulp, + Scheme: scheme, + Logger: zap.New(zap.UseDevMode(true)), + } + + // Calculate hash before dry-run + hashBefore := CalculateHash(dep.Spec) + + // HashFromMutated performs a dry-run which simulates what the Kubernetes API would do + hashAfterMutation := HashFromMutated(dep, funcResources) + + // The hash might be different after mutation due to Kubernetes API defaults + // But our fix ensures that we calculate the hash AFTER mutation, so it matches + // what will be in the cluster + + // Verify that when we set this hash on the deployment label, + // subsequent checks won't detect spurious changes + SetHashLabel(hashAfterMutation, dep) + + currentDep := dep.DeepCopy() + changed := CheckDeploymentSpec(*dep, *currentDep, funcResources) + + if changed { + t.Errorf("Detected spurious change after handling API mutation") + t.Logf("Hash before mutation: %s", hashBefore) + t.Logf("Hash after mutation: %s", hashAfterMutation) + t.Logf("Current hash: %s", CalculateHash(currentDep.Spec)) + } +} diff --git a/docs/admin/guides/configurations/customCA.md b/docs/admin/guides/configurations/customCA.md index 14e4cd7c9..1b26a52f9 100644 --- a/docs/admin/guides/configurations/customCA.md +++ b/docs/admin/guides/configurations/customCA.md @@ -1,12 +1,19 @@ # Certificate injection in Pulp containers -In OpenShift environments, it is possible to [mount additional trust bundles](https://docs.openshift.com/container-platform/4.10/networking/configuring-a-custom-pki.html#certificate-injection-using-operators_configuring-a-custom-pki) into Pulp containers. +Pulp operator supports mounting trusted CA certificates into Pulp containers on both OpenShift and vanilla Kubernetes. -Pulp operator handles part of the process. +## OpenShift -When `trusted_ca: true` Pulp operator will automatically create and mount a `ConfigMap` with the custom CA into Pulp pods, but before doing so users need to first follow the steps from [Enabling the cluster-wide proxy](https://docs.openshift.com/container-platform/4.10/networking/configuring-a-custom-pki.html#nw-proxy-configure-object_configuring-a-custom-pki) to "register" the custom CA certificate into the cluster. +In OpenShift environments, it is possible to [mount additional trust bundles](https://docs.openshift.com/container-platform/4.10/networking/configuring-a-custom-pki.html#certificate-injection-using-operators_configuring-a-custom-pki) into Pulp containers. +When `mount_trusted_ca: true`, Pulp operator will automatically create and mount a `ConfigMap` with the custom CA into Pulp pods. Before enabling this, users need to follow the steps from [Enabling the cluster-wide proxy](https://docs.openshift.com/container-platform/4.10/networking/configuring-a-custom-pki.html#nw-proxy-configure-object_configuring-a-custom-pki) to register the custom CA certificate into the cluster. !!! info It is recommended to execute the previous steps in a maintenance window because, since this is cluster-wide modification, the cluster can get unavailable if executed wrong (some cluster operators pods will be restarted). + +## Vanilla Kubernetes + +On vanilla Kubernetes, you can mount CA certificates from a ConfigMap. The ConfigMap can be managed manually or automatically using cert-manager's trust-manager. + +See the [CA Certificate Management guide](../../../trust-manager-integration.md) for detailed configuration instructions. diff --git a/docs/trust-manager-integration.md b/docs/trust-manager-integration.md new file mode 100644 index 000000000..4765d069d --- /dev/null +++ b/docs/trust-manager-integration.md @@ -0,0 +1,256 @@ +# CA Certificate Management + +This guide explains how to configure Pulp Operator to mount trusted CA certificates into Pulp pods. + +## Overview + +Pulp Operator supports two modes for mounting trusted CA certificates into Pulp pods: + +1. **OpenShift Mode**: Uses OpenShift's Cluster Network Operator (CNO) to inject CA bundles +2. **ConfigMap Mode**: Mounts CA bundles from a user-specified ConfigMap on vanilla Kubernetes + +On vanilla Kubernetes, the ConfigMap can be managed manually or kept up to date automatically using cert-manager's trust-manager. + +## Prerequisites + +For ConfigMap mode on vanilla Kubernetes, you need: + +1. A Kubernetes cluster (non-OpenShift) +2. A ConfigMap containing CA certificates + +### Option A: Manual ConfigMap Management + +Create a ConfigMap with your CA certificates in the same namespace as your Pulp installation: + +```bash +kubectl create configmap my-ca-bundle \ + --from-file=ca.crt=my-ca-bundle.pem \ + --namespace +``` + +### Option B: Automated Management with trust-manager + +For automatic CA bundle updates, install cert-manager and trust-manager: + +```bash +# Install cert-manager +kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.13.0/cert-manager.yaml + +# Install trust-manager +helm repo add jetstack https://charts.jetstack.io +helm repo update +helm install trust-manager jetstack/trust-manager --namespace cert-manager +``` + +## Configuration + +### Option A: Manual ConfigMap + +If managing the ConfigMap manually, configure your Pulp CR in the same namespace as the ConfigMap: + +```yaml +apiVersion: repo-manager.pulpproject.org/v1 +kind: Pulp +metadata: + name: example-pulp + namespace: +spec: + # Enable CA bundle mounting + mount_trusted_ca: true + + # Specify the ConfigMap and key containing CA certificates + mount_trusted_ca_configmap_key: "my-ca-bundle:ca.crt" + + # ... other Pulp configuration +``` + +### Option B: Using trust-manager + +#### Step 1: Create a Bundle Resource + +Create a `Bundle` resource in the same namespace as your Pulp installation. The Bundle will create a ConfigMap in that namespace: + +```yaml +apiVersion: trust.cert-manager.io/v1alpha1 +kind: Bundle +metadata: + name: example-pulp-trusted-ca-bundle + namespace: +spec: + sources: + # Include default system CAs + - useDefaultCAs: true + + # Optional: Include custom CAs from ConfigMaps + # - configMap: + # name: my-custom-ca + # key: ca.crt + + # Optional: Include CAs from cert-manager Certificates + # - secret: + # name: "my-cert" + # key: "ca.crt" + + target: + configMap: + key: "ca-bundle.crt" +``` + +This will create a ConfigMap named `example-pulp-trusted-ca-bundle` containing the aggregated CA bundle. + +#### Step 2: Configure Pulp to Use the Bundle + +In your Pulp CR, reference the trust-manager ConfigMap: + +```yaml +apiVersion: repo-manager.pulpproject.org/v1 +kind: Pulp +metadata: + name: example-pulp + namespace: +spec: + # Enable CA bundle mounting + mount_trusted_ca: true + + # Specify the ConfigMap and key (format: "configmap-name:key") + # The ConfigMap must be in the same namespace as the Pulp CR + mount_trusted_ca_configmap_key: "example-pulp-trusted-ca-bundle:ca-bundle.crt" + + # ... other Pulp configuration +``` + +## How It Works + +### OpenShift Mode (Automatic) + +On OpenShift clusters, when you set `mount_trusted_ca: true`: + +1. Operator creates an empty ConfigMap in the Pulp namespace with the label `config.openshift.io/inject-trusted-cabundle: true` +2. OpenShift's CNO automatically injects the cluster's CA bundle into this ConfigMap +3. Operator mounts the ConfigMap at `/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem` + +**Note:** You should NOT set `mount_trusted_ca_configmap_key` on OpenShift. + +### ConfigMap Mode (Explicit Configuration) + +On vanilla Kubernetes clusters: + +1. A ConfigMap containing CA certificates exists in the Pulp namespace (created manually or by trust-manager) +2. When both `mount_trusted_ca: true` and `mount_trusted_ca_configmap_key` are set, operator mounts the ConfigMap at `/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem` + +**Important:** The ConfigMap must be in the same namespace as the Pulp CR. Cross-namespace ConfigMap references are not supported. + +### Using trust-manager (Optional) + +If using trust-manager for automated CA bundle management: + +1. Trust-manager watches `Bundle` resources +2. Trust-manager aggregates CAs from the specified sources +3. Trust-manager creates/updates the target ConfigMap with the CA bundle +4. Pulp operator mounts the ConfigMap into pods + +## ConfigMap Key Format + +The `mount_trusted_ca_configmap_key` field uses the format: + +``` +configmap-name:key +``` + +For example: `my-ca-bundle:ca.crt` refers to the `ca.crt` key in the `my-ca-bundle` ConfigMap. + +## Mount Path + +Both modes mount the CA bundle at the same location to ensure compatibility with Red Hat/Fedora-based container images: + +``` +/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem +``` + +This is the standard system-wide CA bundle location on RHEL/Fedora systems. + +## Complete Example + +See [config/samples/simple-trust-manager.yaml](../config/samples/simple-trust-manager.yaml) for a complete working example. + +## Affected Components + +The CA bundle is mounted in all three Pulp core components: + +- `pulpcore-api` pods +- `pulpcore-content` pods +- `pulpcore-worker` pods + +## Troubleshooting + +### ConfigMap Not Found + +If you see errors about the ConfigMap not being found: + +1. Verify the Bundle resource was created: `kubectl get bundle -n ` +2. Check trust-manager logs: `kubectl logs -n cert-manager -l app.kubernetes.io/name=trust-manager` +3. Verify the ConfigMap exists in the Pulp namespace: `kubectl get configmap -n ` +4. Ensure the ConfigMap is in the same namespace as the Pulp CR + +### CA Bundle Not Being Used + +If applications in Pulp pods are not trusting your CAs: + +1. Verify the mount is present in the pods: + ```bash + kubectl exec -it -n -- ls -la /etc/pki/ca-trust/extracted/pem/ + ``` + +2. Check the CA bundle content: + ```bash + kubectl exec -it -n -- cat /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem + ``` + +3. Ensure your application is configured to use the system CA bundle (most applications do this by default) + +### Specifying Different Keys + +The key name in the ConfigMap can be anything: + +1. Update the Bundle's `target.configMap.key` field +2. Update the Pulp CR's `mount_trusted_ca_configmap_key` field to match + +Example: +```yaml +# Bundle (in pulp namespace) +apiVersion: trust.cert-manager.io/v1alpha1 +kind: Bundle +metadata: + name: example-pulp-trusted-ca-bundle + namespace: +spec: + target: + configMap: + key: "custom-bundle.pem" + +# Pulp CR (in same namespace) +apiVersion: repo-manager.pulpproject.org/v1 +kind: Pulp +metadata: + name: example-pulp + namespace: +spec: + mount_trusted_ca_configmap_key: "example-pulp-trusted-ca-bundle:custom-bundle.pem" +``` + +## Migration from OpenShift to Vanilla Kubernetes + +If you're migrating a Pulp instance from OpenShift to vanilla Kubernetes: + +1. Install trust-manager on the vanilla Kubernetes cluster +2. Create a Bundle resource with the desired CAs +3. Update the Pulp CR to add `mount_trusted_ca_configmap_key` +4. The existing `mount_trusted_ca: true` field can remain + +The operator will automatically detect the presence of `mount_trusted_ca_configmap_key` and switch to trust-manager mode. + +## References + +- [cert-manager Documentation](https://cert-manager.io/docs/) +- [trust-manager Documentation](https://cert-manager.io/docs/trust/trust-manager/) +- [OpenShift Cluster-wide Proxy Configuration](https://docs.openshift.com/container-platform/latest/networking/enable-cluster-wide-proxy.html) From bde2afdaf6fc90f1ed10c4101da728c2f9dbfbe7 Mon Sep 17 00:00:00 2001 From: git-hyagi <45576767+git-hyagi@users.noreply.github.com> Date: Tue, 23 Dec 2025 12:50:14 -0300 Subject: [PATCH 2/2] Fix a reconciliation issue with mount_ca_trust [noissue] --- .../v1/pulp_types.go | 2 +- .../v1/zz_generated.deepcopy.go | 5 + controllers/deployment.go | 8 + controllers/ocp/deployment.go | 9 - controllers/ocp/utils.go | 67 ------ controllers/ocp/utils_test.go | 35 ++-- controllers/repo_manager/README.md | 2 +- controllers/repo_manager/configmap.go | 51 +++++ controllers/repo_manager/controller.go | 9 + controllers/repo_manager/deployment.go | 40 +--- controllers/repo_manager/deployment_test.go | 191 ------------------ controllers/repo_manager/precheck.go | 20 ++ controllers/utils.go | 83 ++++++++ 13 files changed, 201 insertions(+), 321 deletions(-) create mode 100644 controllers/repo_manager/configmap.go delete mode 100644 controllers/repo_manager/deployment_test.go diff --git a/apis/repo-manager.pulpproject.org/v1/pulp_types.go b/apis/repo-manager.pulpproject.org/v1/pulp_types.go index 108220c49..79b2f1ebf 100644 --- a/apis/repo-manager.pulpproject.org/v1/pulp_types.go +++ b/apis/repo-manager.pulpproject.org/v1/pulp_types.go @@ -328,7 +328,7 @@ type PulpSpec struct { // Required on vanilla Kubernetes when mount_trusted_ca is true. Optional on OpenShift. // +kubebuilder:validation:Optional // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Trusted CA ConfigMap Key",xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced","urn:alm:descriptor:com.tectonic.ui:fieldDependency:mount_trusted_ca:true"} - TrustedCaConfigMapKey string `json:"mount_trusted_ca_configmap_key,omitempty"` + TrustedCaConfigMapKey *string `json:"mount_trusted_ca_configmap_key,omitempty"` // Job to reset pulp admin password AdminPasswordJob PulpJob `json:"admin_password_job,omitempty"` diff --git a/apis/repo-manager.pulpproject.org/v1/zz_generated.deepcopy.go b/apis/repo-manager.pulpproject.org/v1/zz_generated.deepcopy.go index 5a5134ac1..bd87395a5 100644 --- a/apis/repo-manager.pulpproject.org/v1/zz_generated.deepcopy.go +++ b/apis/repo-manager.pulpproject.org/v1/zz_generated.deepcopy.go @@ -708,6 +708,11 @@ func (in *PulpSpec) DeepCopyInto(out *PulpSpec) { (*out)[key] = val } } + if in.TrustedCaConfigMapKey != nil { + in, out := &in.TrustedCaConfigMapKey, &out.TrustedCaConfigMapKey + *out = new(string) + **out = **in + } in.AdminPasswordJob.DeepCopyInto(&out.AdminPasswordJob) in.MigrationJob.DeepCopyInto(&out.MigrationJob) in.SigningJob.DeepCopyInto(&out.SigningJob) diff --git a/controllers/deployment.go b/controllers/deployment.go index aa62bb412..de064b338 100644 --- a/controllers/deployment.go +++ b/controllers/deployment.go @@ -554,6 +554,10 @@ func (d *CommonDeployment) setVolumes(resources any, pulpcoreType settings.Pulpc } volumes = append(volumes, containerTokenSecretVolume) } + + // append the CA configmap to the volumes + volumes = SetCAVolumes(&pulp, volumes) + d.volumes = append([]corev1.Volume(nil), volumes...) } @@ -724,6 +728,10 @@ func (d *CommonDeployment) setVolumeMounts(pulp pulpv1.Pulp, pulpcoreType settin } volumeMounts = append(volumeMounts, containerTokenSecretMount...) } + + // append the CA configmap to the volumeMounts + volumeMounts = SetCAVolumeMounts(&pulp, volumeMounts) + d.volumeMounts = append([]corev1.VolumeMount(nil), volumeMounts...) } diff --git a/controllers/ocp/deployment.go b/controllers/ocp/deployment.go index 6b132a504..c9a32c2f3 100644 --- a/controllers/ocp/deployment.go +++ b/controllers/ocp/deployment.go @@ -28,15 +28,6 @@ import ( func defaultsForOCPDeployment(deployment *appsv1.Deployment, pulp *pulpv1.Pulp) { // in OCP we use SCC so there is no need to define PodSecurityContext deployment.Spec.Template.Spec.SecurityContext = &corev1.PodSecurityContext{} - - // get the current volume mount points - volumes := deployment.Spec.Template.Spec.Volumes - volumeMounts := deployment.Spec.Template.Spec.Containers[0].VolumeMounts - - // append the CA configmap to the volumes/volumemounts slice - volumes, volumeMounts = MountCASpec(pulp, volumes, volumeMounts) - deployment.Spec.Template.Spec.Volumes = volumes - deployment.Spec.Template.Spec.Containers[0].VolumeMounts = volumeMounts } // DeploymentAPIOCP is the pulpcore-api Deployment definition for common OCP clusters diff --git a/controllers/ocp/utils.go b/controllers/ocp/utils.go index ccda95304..130cc0f4e 100644 --- a/controllers/ocp/utils.go +++ b/controllers/ocp/utils.go @@ -18,7 +18,6 @@ package ocp import ( "context" - "strings" "github.com/go-logr/logr" pulpv1 "github.com/pulp/pulp-operator/apis/repo-manager.pulpproject.org/v1" @@ -102,72 +101,6 @@ func CreateEmptyConfigMap(r client.Client, scheme *runtime.Scheme, ctx context.C return ctrl.Result{}, nil } -// MountCASpec adds the trusted-ca bundle into []volume and []volumeMount if pulp.Spec.TrustedCA is true -// On OpenShift: uses the operator-created ConfigMap with CNO injection -// On vanilla K8s: uses a user-specified ConfigMap (which can be managed manually or by trust-manager) -func MountCASpec(pulp *pulpv1.Pulp, volumes []corev1.Volume, volumeMounts []corev1.VolumeMount) ([]corev1.Volume, []corev1.VolumeMount) { - - if pulp.Spec.TrustedCa { - var configMapName string - var configMapKey string - - // Determine ConfigMap name and key based on configuration - if len(pulp.Spec.TrustedCaConfigMapKey) > 0 { - // Vanilla K8s mode: parse "configmap-name:key" format - // If no separator, assume it's just the ConfigMap name and use the first key in the map - parts := strings.Split(pulp.Spec.TrustedCaConfigMapKey, ":") - if len(parts) == 2 { - configMapName = parts[0] - configMapKey = parts[1] - } else { - // Just ConfigMap name provided, use empty key to get first key in map - configMapName = parts[0] - configMapKey = "" - } - } else { - // OpenShift mode: use the operator-created ConfigMap with CNO injection - configMapName = settings.EmptyCAConfigMapName(pulp.Name) - configMapKey = "ca-bundle.crt" - } - - // trustedCAVolume contains the configmap with the custom ca bundle - defaultMode := int32(420) - configMapVolumeSource := &corev1.ConfigMapVolumeSource{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: configMapName, - }, - DefaultMode: &defaultMode, - } - - // If a specific key is provided, map it to the expected path - // Otherwise, mount all keys from the ConfigMap (first key will be used) - if configMapKey != "" { - configMapVolumeSource.Items = []corev1.KeyToPath{ - {Key: configMapKey, Path: "tls-ca-bundle.pem"}, - } - } - - trustedCAVolume := corev1.Volume{ - Name: "trusted-ca", - VolumeSource: corev1.VolumeSource{ - ConfigMap: configMapVolumeSource, - }, - } - volumes = append(volumes, trustedCAVolume) - - // trustedCAMount defines the mount point of the configmap - // with the custom ca bundle - trustedCAMount := corev1.VolumeMount{ - Name: "trusted-ca", - MountPath: "/etc/pki/ca-trust/extracted/pem", - ReadOnly: true, - } - volumeMounts = append(volumeMounts, trustedCAMount) - } - - return volumes, volumeMounts -} - // GetRouteHost defines route host based on ingress default cluster domain if no .spec.route_host defined func GetRouteHost(pulp *pulpv1.Pulp) string { if len(pulp.Spec.RouteHost) == 0 { diff --git a/controllers/ocp/utils_test.go b/controllers/ocp/utils_test.go index 51db78340..102e75e5e 100644 --- a/controllers/ocp/utils_test.go +++ b/controllers/ocp/utils_test.go @@ -20,6 +20,7 @@ import ( "testing" pulpv1 "github.com/pulp/pulp-operator/apis/repo-manager.pulpproject.org/v1" + "github.com/pulp/pulp-operator/controllers" "github.com/pulp/pulp-operator/controllers/settings" corev1 "k8s.io/api/core/v1" ) @@ -35,7 +36,8 @@ func TestMountCASpec_Disabled(t *testing.T) { volumes := []corev1.Volume{} volumeMounts := []corev1.VolumeMount{} - resultVolumes, resultVolumeMounts := MountCASpec(pulp, volumes, volumeMounts) + resultVolumes := controllers.SetCAVolumes(pulp, volumes) + resultVolumeMounts := controllers.SetCAVolumeMounts(pulp, volumeMounts) if len(resultVolumes) != 0 { t.Errorf("Expected 0 volumes when TrustedCa is false, got %d", len(resultVolumes)) @@ -52,7 +54,7 @@ func TestMountCASpec_OpenShiftMode(t *testing.T) { pulp := &pulpv1.Pulp{ Spec: pulpv1.PulpSpec{ TrustedCa: true, - TrustedCaConfigMapKey: "", // Empty means OpenShift mode + TrustedCaConfigMapKey: nil, // Empty means OpenShift mode }, } pulp.Name = pulpName @@ -60,7 +62,8 @@ func TestMountCASpec_OpenShiftMode(t *testing.T) { volumes := []corev1.Volume{} volumeMounts := []corev1.VolumeMount{} - resultVolumes, resultVolumeMounts := MountCASpec(pulp, volumes, volumeMounts) + resultVolumes := controllers.SetCAVolumes(pulp, volumes) + resultVolumeMounts := controllers.SetCAVolumeMounts(pulp, volumeMounts) // Verify we got exactly one volume and one mount if len(resultVolumes) != 1 { @@ -117,7 +120,7 @@ func TestMountCASpec_TrustManagerMode(t *testing.T) { pulp := &pulpv1.Pulp{ Spec: pulpv1.PulpSpec{ TrustedCa: true, - TrustedCaConfigMapKey: configMapName, // Just ConfigMap name, no ":" + TrustedCaConfigMapKey: &configMapName, // Just ConfigMap name, no ":" }, } pulp.Name = pulpName @@ -125,7 +128,8 @@ func TestMountCASpec_TrustManagerMode(t *testing.T) { volumes := []corev1.Volume{} volumeMounts := []corev1.VolumeMount{} - resultVolumes, resultVolumeMounts := MountCASpec(pulp, volumes, volumeMounts) + resultVolumes := controllers.SetCAVolumes(pulp, volumes) + resultVolumeMounts := controllers.SetCAVolumeMounts(pulp, volumeMounts) // Verify we got exactly one volume and one mount if len(resultVolumes) != 1 { @@ -176,15 +180,13 @@ func TestMountCASpec_CustomKey(t *testing.T) { pulp := &pulpv1.Pulp{ Spec: pulpv1.PulpSpec{ TrustedCa: true, - TrustedCaConfigMapKey: separatorFormat, + TrustedCaConfigMapKey: &separatorFormat, }, } pulp.Name = pulpName volumes := []corev1.Volume{} - volumeMounts := []corev1.VolumeMount{} - - resultVolumes, _ := MountCASpec(pulp, volumes, volumeMounts) + resultVolumes := controllers.SetCAVolumes(pulp, volumes) if len(resultVolumes) != 1 { t.Fatalf("Expected 1 volume, got %d", len(resultVolumes)) @@ -206,10 +208,11 @@ func TestMountCASpec_CustomKey(t *testing.T) { // TestMountCASpec_PreservesExistingVolumes verifies that existing volumes are preserved func TestMountCASpec_PreservesExistingVolumes(t *testing.T) { + configMapName := "my-bund:ca-bundle.crt" pulp := &pulpv1.Pulp{ Spec: pulpv1.PulpSpec{ TrustedCa: true, - TrustedCaConfigMapKey: "my-bundle:ca-bundle.crt", + TrustedCaConfigMapKey: &configMapName, }, } pulp.Name = "test-pulp" @@ -224,7 +227,8 @@ func TestMountCASpec_PreservesExistingVolumes(t *testing.T) { {Name: "existing-mount-2"}, } - resultVolumes, resultVolumeMounts := MountCASpec(pulp, existingVolumes, existingVolumeMounts) + resultVolumes := controllers.SetCAVolumes(pulp, existingVolumes) + resultVolumeMounts := controllers.SetCAVolumeMounts(pulp, existingVolumeMounts) // Should have 3 volumes (2 existing + 1 new) if len(resultVolumes) != 3 { @@ -269,7 +273,7 @@ func TestMountCASpec_SeparatorFormat(t *testing.T) { pulp := &pulpv1.Pulp{ Spec: pulpv1.PulpSpec{ TrustedCa: true, - TrustedCaConfigMapKey: separatorFormat, + TrustedCaConfigMapKey: &separatorFormat, }, } pulp.Name = pulpName @@ -277,7 +281,8 @@ func TestMountCASpec_SeparatorFormat(t *testing.T) { volumes := []corev1.Volume{} volumeMounts := []corev1.VolumeMount{} - resultVolumes, resultVolumeMounts := MountCASpec(pulp, volumes, volumeMounts) + resultVolumes := controllers.SetCAVolumes(pulp, volumes) + resultVolumeMounts := controllers.SetCAVolumeMounts(pulp, volumeMounts) // Verify we got exactly one volume and one mount if len(resultVolumes) != 1 { @@ -361,12 +366,12 @@ func TestMountCASpec_MountPathConsistency(t *testing.T) { pulp := &pulpv1.Pulp{ Spec: pulpv1.PulpSpec{ TrustedCa: tc.trustedCa, - TrustedCaConfigMapKey: tc.trustedCaConfigMapKey, + TrustedCaConfigMapKey: &tc.trustedCaConfigMapKey, }, } pulp.Name = pulpName - _, resultVolumeMounts := MountCASpec(pulp, []corev1.Volume{}, []corev1.VolumeMount{}) + resultVolumeMounts := controllers.SetCAVolumeMounts(pulp, []corev1.VolumeMount{}) if len(resultVolumeMounts) != 1 { t.Fatalf("Expected 1 volumeMount, got %d", len(resultVolumeMounts)) diff --git a/controllers/repo_manager/README.md b/controllers/repo_manager/README.md index f02353e37..8c81b1718 100644 --- a/controllers/repo_manager/README.md +++ b/controllers/repo_manager/README.md @@ -251,7 +251,7 @@ PulpSpec defines the desired state of Pulp | sa_labels | ServiceAccount.metadata.labels that will be used in Pulp pods. | map[string]string | false | | sso_secret | Secret where Single Sign-on configuration can be found | string | false | | mount_trusted_ca | Enable mounting of custom CA certificates. On OpenShift, mounts CA certificates added to the cluster via cluster-wide proxy config. On vanilla Kubernetes with cert-manager's trust-manager, requires mount_trusted_ca_configmap_key to specify the ConfigMap and key containing the CA bundle. Default: false | bool | false | -| mount_trusted_ca_configmap_key | Specifies the ConfigMap and key containing the CA bundle for vanilla Kubernetes clusters. The ConfigMap can be managed manually or kept up to date using cert-manager's trust-manager. Format: \"configmap-name:key\" (e.g., \"vault-ca-defaults-bundle:ca.crt\") Required on vanilla Kubernetes when mount_trusted_ca is true. Optional on OpenShift. | string | false | +| mount_trusted_ca_configmap_key | Specifies the ConfigMap and key containing the CA bundle for vanilla Kubernetes clusters. The ConfigMap can be managed manually or kept up to date using cert-manager's trust-manager. Format: \"configmap-name:key\" (e.g., \"vault-ca-defaults-bundle:ca.crt\") Required on vanilla Kubernetes when mount_trusted_ca is true. Optional on OpenShift. | *string | false | | admin_password_job | Job to reset pulp admin password | [PulpJob](#pulpjob) | false | | migration_job | Job to run django migrations | [PulpJob](#pulpjob) | false | | signing_job | Job to store signing metadata scripts | [PulpJob](#pulpjob) | false | diff --git a/controllers/repo_manager/configmap.go b/controllers/repo_manager/configmap.go new file mode 100644 index 000000000..9178c00ac --- /dev/null +++ b/controllers/repo_manager/configmap.go @@ -0,0 +1,51 @@ +package repo_manager + +import ( + "context" + + pulpv1 "github.com/pulp/pulp-operator/apis/repo-manager.pulpproject.org/v1" + "github.com/pulp/pulp-operator/controllers" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" +) + +func (r *RepoManagerReconciler) configMapTasks(ctx context.Context, pulp *pulpv1.Pulp) (*ctrl.Result, error) { + + needsPulpcoreRestart := false + + // if mount_trusted_ca_configmap_key is defined, check if it was modified + if pulp.Spec.TrustedCaConfigMapKey != nil { + trustedCAConfigMap := &corev1.ConfigMap{} + caConfigMapName, _ := controllers.SplitCAConfigMapNameKey(*pulp) + + r.Get(ctx, types.NamespacedName{Name: caConfigMapName, Namespace: pulp.Namespace}, trustedCAConfigMap) + if r.caConfigMapChanged(ctx, trustedCAConfigMap) { + needsPulpcoreRestart = true + } + } + + // TODO: check pulp-web configmap change + + // restart pulpcore pods if any of the configmaps changed + if needsPulpcoreRestart { + r.restartPulpCorePods(ctx, pulp) + return &ctrl.Result{Requeue: true}, nil + } + return nil, nil +} + +func (r *RepoManagerReconciler) caConfigMapChanged(ctx context.Context, cm *corev1.ConfigMap) bool { + currentHash := controllers.GetCurrentHash(cm) + calculatedHash := controllers.CalculateHash(cm.Data) + + if currentHash == calculatedHash { + return false + } + + controllers.SetHashLabel(calculatedHash, cm) + if err := r.Update(ctx, cm); err != nil { + r.RawLogger.Error(err, "Failed to update "+cm.Name+" ConfigMap label!") + } + return true +} diff --git a/controllers/repo_manager/controller.go b/controllers/repo_manager/controller.go index e950e3a07..cdee45590 100644 --- a/controllers/repo_manager/controller.go +++ b/controllers/repo_manager/controller.go @@ -194,6 +194,11 @@ func pulpCoreTasks(ctx context.Context, pulp *pulpv1.Pulp, r RepoManagerReconcil return pulpController, err } + log.V(1).Info("Running configMap tasks ...") + if pulpController, err := r.configMapTasks(ctx, pulp); pulpController != nil || err != nil { + return pulpController, err + } + log.V(1).Info("Running API tasks") if pulpController, err := r.pulpApiController(ctx, pulp, log); needsRequeue(err, pulpController) { return &pulpController, err @@ -329,6 +334,10 @@ func indexerFunc(obj client.Object) []string { if customSettings := pulp.Spec.CustomPulpSettings; customSettings != "" { keys = append(keys, customSettings) } + if pulp.Spec.TrustedCaConfigMapKey != nil { + caConfigMap, _ := controllers.SplitCAConfigMapNameKey(*pulp) + keys = append(keys, caConfigMap) + } return keys } diff --git a/controllers/repo_manager/deployment.go b/controllers/repo_manager/deployment.go index 78a3bea22..e13a73840 100644 --- a/controllers/repo_manager/deployment.go +++ b/controllers/repo_manager/deployment.go @@ -16,10 +16,8 @@ limitations under the License. package repo_manager import ( - pulpv1 "github.com/pulp/pulp-operator/apis/repo-manager.pulpproject.org/v1" "github.com/pulp/pulp-operator/controllers" pulp_ocp "github.com/pulp/pulp-operator/controllers/ocp" - appsv1 "k8s.io/api/apps/v1" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -72,41 +70,13 @@ type Deployer interface { Deploy(controllers.FunctionResources) client.Object } -// defaultsForVanillaDeployment sets the common Deployment configurations for vanilla k8s clusters -// This includes CA bundle mounting from ConfigMaps when configured -func defaultsForVanillaDeployment(deployment client.Object, pulp *pulpv1.Pulp) { - dep := deployment.(*appsv1.Deployment) - - // Validate CA ConfigMap configuration on vanilla K8s - if pulp.Spec.TrustedCa && len(pulp.Spec.TrustedCaConfigMapKey) == 0 { - controllers.CustomZapLogger().Error(`mount_trusted_ca is true but mount_trusted_ca_configmap_key is not set. ` + - `On vanilla Kubernetes, you must specify mount_trusted_ca_configmap_key to reference a ConfigMap containing CA certificates. ` + - `This field is only optional on OpenShift where CNO injection is used.`) - return - } - - // Only mount CA bundles if ConfigMap is specified - if pulp.Spec.TrustedCa && len(pulp.Spec.TrustedCaConfigMapKey) > 0 { - // get the current volume mount points - volumes := dep.Spec.Template.Spec.Volumes - volumeMounts := dep.Spec.Template.Spec.Containers[0].VolumeMounts - - // append the CA configmap to the volumes/volumemounts slice - volumes, volumeMounts = pulp_ocp.MountCASpec(pulp, volumes, volumeMounts) - dep.Spec.Template.Spec.Volumes = volumes - dep.Spec.Template.Spec.Containers[0].VolumeMounts = volumeMounts - } -} - // DeploymentAPIVanilla is the pulpcore-api Deployment definition for common k8s distributions type DeploymentAPIVanilla struct{} // Deploy returns a pulp-api Deployment object func (DeploymentAPIVanilla) Deploy(resources controllers.FunctionResources) client.Object { dep := controllers.DeploymentAPICommon{} - deployment := dep.Deploy(resources) - defaultsForVanillaDeployment(deployment, resources.Pulp) - return deployment + return dep.Deploy(resources) } // DeploymentContentVanilla is the pulpcore-content Deployment definition for common k8s distributions @@ -115,9 +85,7 @@ type DeploymentContentVanilla struct{} // Deploy returns a pulp-content Deployment object func (DeploymentContentVanilla) Deploy(resources controllers.FunctionResources) client.Object { dep := controllers.DeploymentContentCommon{} - deployment := dep.Deploy(resources) - defaultsForVanillaDeployment(deployment, resources.Pulp) - return deployment + return dep.Deploy(resources) } // DeploymentWorkerVanilla is the pulpcore-worker Deployment definition for common k8s distributions @@ -126,7 +94,5 @@ type DeploymentWorkerVanilla struct{} // Deploy returns a pulp-worker Deployment object func (DeploymentWorkerVanilla) Deploy(resources controllers.FunctionResources) client.Object { dep := controllers.DeploymentWorkerCommon{} - deployment := dep.Deploy(resources) - defaultsForVanillaDeployment(deployment, resources.Pulp) - return deployment + return dep.Deploy(resources) } diff --git a/controllers/repo_manager/deployment_test.go b/controllers/repo_manager/deployment_test.go deleted file mode 100644 index c4890bb7d..000000000 --- a/controllers/repo_manager/deployment_test.go +++ /dev/null @@ -1,191 +0,0 @@ -/* -Copyright 2022. - -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 repo_manager - -import ( - "testing" - - pulpv1 "github.com/pulp/pulp-operator/apis/repo-manager.pulpproject.org/v1" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" -) - -// TestDefaultsForVanillaDeployment_NoTrustManager verifies no changes when trust-manager is not configured -func TestDefaultsForVanillaDeployment_NoTrustManager(t *testing.T) { - pulp := &pulpv1.Pulp{ - Spec: pulpv1.PulpSpec{ - TrustedCa: false, - TrustedCaConfigMapKey: "", - }, - } - pulp.Name = "test-pulp" - - deployment := &appsv1.Deployment{ - Spec: appsv1.DeploymentSpec{ - Template: corev1.PodTemplateSpec{ - Spec: corev1.PodSpec{ - Volumes: []corev1.Volume{ - {Name: "existing-volume"}, - }, - Containers: []corev1.Container{ - { - Name: "pulp", - VolumeMounts: []corev1.VolumeMount{ - {Name: "existing-mount"}, - }, - }, - }, - }, - }, - }, - } - - defaultsForVanillaDeployment(deployment, pulp) - - // Should still have only the original volume and mount - if len(deployment.Spec.Template.Spec.Volumes) != 1 { - t.Errorf("Expected 1 volume when trust-manager not configured, got %d", len(deployment.Spec.Template.Spec.Volumes)) - } - - if len(deployment.Spec.Template.Spec.Containers[0].VolumeMounts) != 1 { - t.Errorf("Expected 1 volumeMount when trust-manager not configured, got %d", len(deployment.Spec.Template.Spec.Containers[0].VolumeMounts)) - } -} - -// TestDefaultsForVanillaDeployment_TrustedCaEnabledNoKey verifies error handling when TrustedCa is set but ConfigMapKey is not -func TestDefaultsForVanillaDeployment_TrustedCaEnabledNoKey(t *testing.T) { - pulp := &pulpv1.Pulp{ - Spec: pulpv1.PulpSpec{ - TrustedCa: true, - TrustedCaConfigMapKey: "", // No key - invalid configuration on vanilla K8s - }, - } - pulp.Name = "test-pulp" - - deployment := &appsv1.Deployment{ - Spec: appsv1.DeploymentSpec{ - Template: corev1.PodTemplateSpec{ - Spec: corev1.PodSpec{ - Volumes: []corev1.Volume{ - {Name: "existing-volume"}, - }, - Containers: []corev1.Container{ - { - Name: "pulp", - VolumeMounts: []corev1.VolumeMount{ - {Name: "existing-mount"}, - }, - }, - }, - }, - }, - }, - } - - // This should log an error and return early without modifying the deployment - defaultsForVanillaDeployment(deployment, pulp) - - // Should still have only the original volume and mount (invalid config, no changes made) - if len(deployment.Spec.Template.Spec.Volumes) != 1 { - t.Errorf("Expected 1 volume when ConfigMapKey not set (invalid config), got %d", len(deployment.Spec.Template.Spec.Volumes)) - } - - if len(deployment.Spec.Template.Spec.Containers[0].VolumeMounts) != 1 { - t.Errorf("Expected 1 volumeMount when ConfigMapKey not set (invalid config), got %d", len(deployment.Spec.Template.Spec.Containers[0].VolumeMounts)) - } -} - -// TestDefaultsForVanillaDeployment_WithTrustManager verifies CA mounting when trust-manager is configured -func TestDefaultsForVanillaDeployment_WithTrustManager(t *testing.T) { - pulpName := "test-pulp" - configMapName := "vault-ca-defaults-bundle" - configMapKey := "ca.crt" - separatorFormat := configMapName + ":" + configMapKey - - pulp := &pulpv1.Pulp{ - Spec: pulpv1.PulpSpec{ - TrustedCa: true, - TrustedCaConfigMapKey: separatorFormat, - }, - } - pulp.Name = pulpName - - deployment := &appsv1.Deployment{ - Spec: appsv1.DeploymentSpec{ - Template: corev1.PodTemplateSpec{ - Spec: corev1.PodSpec{ - Volumes: []corev1.Volume{ - {Name: "existing-volume"}, - }, - Containers: []corev1.Container{ - { - Name: "pulp", - VolumeMounts: []corev1.VolumeMount{ - {Name: "existing-mount"}, - }, - }, - }, - }, - }, - }, - } - - defaultsForVanillaDeployment(deployment, pulp) - - // Should now have 2 volumes (existing + trusted-ca) - if len(deployment.Spec.Template.Spec.Volumes) != 2 { - t.Fatalf("Expected 2 volumes with trust-manager configured, got %d", len(deployment.Spec.Template.Spec.Volumes)) - } - - // Should now have 2 mounts (existing + trusted-ca) - if len(deployment.Spec.Template.Spec.Containers[0].VolumeMounts) != 2 { - t.Fatalf("Expected 2 volumeMounts with trust-manager configured, got %d", len(deployment.Spec.Template.Spec.Containers[0].VolumeMounts)) - } - - // Verify the CA volume was added correctly - caVolume := deployment.Spec.Template.Spec.Volumes[1] - if caVolume.Name != "trusted-ca" { - t.Errorf("Expected volume name 'trusted-ca', got '%s'", caVolume.Name) - } - - if caVolume.ConfigMap.Name != configMapName { - t.Errorf("Expected ConfigMap name '%s', got '%s'", configMapName, caVolume.ConfigMap.Name) - } - - if len(caVolume.ConfigMap.Items) != 1 { - t.Fatalf("Expected 1 item in ConfigMap volume, got %d", len(caVolume.ConfigMap.Items)) - } - - if caVolume.ConfigMap.Items[0].Key != configMapKey { - t.Errorf("Expected ConfigMap key '%s', got '%s'", configMapKey, caVolume.ConfigMap.Items[0].Key) - } - - // Verify the CA mount was added correctly - caMount := deployment.Spec.Template.Spec.Containers[0].VolumeMounts[1] - if caMount.Name != "trusted-ca" { - t.Errorf("Expected volumeMount name 'trusted-ca', got '%s'", caMount.Name) - } - - if caMount.MountPath != "/etc/pki/ca-trust/extracted/pem" { - t.Errorf("Expected mountPath '/etc/pki/ca-trust/extracted/pem', got '%s'", caMount.MountPath) - } -} - -// Note: Full integration tests with Deploy() require additional setup -// (k8s client, schemes, etc.) and are covered by the controller_test.go -// integration test suite. These unit tests focus on the defaultsForVanillaDeployment -// function behavior in isolation. diff --git a/controllers/repo_manager/precheck.go b/controllers/repo_manager/precheck.go index 6b39b9afd..ca84de204 100644 --- a/controllers/repo_manager/precheck.go +++ b/controllers/repo_manager/precheck.go @@ -84,6 +84,11 @@ func prechecks(ctx context.Context, r *RepoManagerReconciler, pulp *pulpv1.Pulp) return reconcile, nil } + // verify if configmap is defined when mount_trusted_ca is true + if reconcile := checkCAConfigmap(r, *pulp); reconcile != nil { + return reconcile, nil + } + return nil, nil } @@ -303,3 +308,18 @@ func checkSigningScripts(r *RepoManagerReconciler, pulp *pulpv1.Pulp) *ctrl.Resu return nil } + +// checCAConfigmap validates CA ConfigMap configuration on vanilla K8s +func checkCAConfigmap(r *RepoManagerReconciler, pulp pulpv1.Pulp) *ctrl.Result { + if isOpenShift, _ := controllers.IsOpenShift(); isOpenShift { + return nil + } + + if pulp.Spec.TrustedCa && pulp.Spec.TrustedCaConfigMapKey == nil { + r.RawLogger.Error(nil, `mount_trusted_ca is true but mount_trusted_ca_configmap_key is not set. `+ + `On vanilla Kubernetes, you must specify mount_trusted_ca_configmap_key to reference a ConfigMap containing CA certificates. `+ + `This field is only optional on OpenShift where CNO injection is used.`) + return &ctrl.Result{} + } + return nil +} diff --git a/controllers/utils.go b/controllers/utils.go index 2f14c5395..5cdddbb78 100644 --- a/controllers/utils.go +++ b/controllers/utils.go @@ -1011,3 +1011,86 @@ func SetDefaultSecurityContext() *corev1.SecurityContext { func Ipv6Disabled(pulp pulpv1.Pulp) bool { return pulp.Spec.IPv6Disabled != nil && *pulp.Spec.IPv6Disabled } + +// SetCAVolumes adds the trusted-ca bundle into []volume +// On OpenShift: uses the operator-created ConfigMap with CNO injection +// On vanilla K8s: uses a user-specified ConfigMap (which can be managed manually or by trust-manager) +func SetCAVolumes(pulp *pulpv1.Pulp, volumes []corev1.Volume) []corev1.Volume { + + if !pulp.Spec.TrustedCa { + return volumes + } + + var configMapName string + var configMapKey string + + // Determine ConfigMap name and key based on configuration + if pulp.Spec.TrustedCaConfigMapKey != nil { + configMapName, configMapKey = SplitCAConfigMapNameKey(*pulp) + } else { + // OpenShift mode: use the operator-created ConfigMap with CNO injection + configMapName = settings.EmptyCAConfigMapName(pulp.Name) + configMapKey = "ca-bundle.crt" + } + + // trustedCAVolume contains the configmap with the custom ca bundle + defaultMode := int32(420) + configMapVolumeSource := &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: configMapName, + }, + DefaultMode: &defaultMode, + } + + // If a specific key is provided, map it to the expected path + // Otherwise, mount all keys from the ConfigMap (first key will be used) + if configMapKey != "" { + configMapVolumeSource.Items = []corev1.KeyToPath{ + {Key: configMapKey, Path: "tls-ca-bundle.pem"}, + } + } + + trustedCAVolume := corev1.Volume{ + Name: "trusted-ca", + VolumeSource: corev1.VolumeSource{ + ConfigMap: configMapVolumeSource, + }, + } + volumes = append(volumes, trustedCAVolume) + + return volumes +} + +// SetCAVolumeMounts defines the container mount point for the trusted ca configmap +func SetCAVolumeMounts(pulp *pulpv1.Pulp, volumeMounts []corev1.VolumeMount) []corev1.VolumeMount { + if !pulp.Spec.TrustedCa { + return volumeMounts + } + + // trustedCAMount defines the mount point of the configmap + // with the custom ca bundle + trustedCAMount := corev1.VolumeMount{ + Name: "trusted-ca", + MountPath: "/etc/pki/ca-trust/extracted/pem", + ReadOnly: true, + } + return append(volumeMounts, trustedCAMount) +} + +// SplitCAConfigMapNameKey returns the configmap name and the key from mount_trusted_ca_configmap_key +func SplitCAConfigMapNameKey(pulp pulpv1.Pulp) (string, string) { + + var configMapName, configMapKey string + // Vanilla K8s mode: parse "configmap-name:key" format + // If no separator, assume it's just the ConfigMap name and use the first key in the map + parts := strings.Split(*pulp.Spec.TrustedCaConfigMapKey, ":") + if len(parts) == 2 { + configMapName = parts[0] + configMapKey = parts[1] + } else { + // Just ConfigMap name provided, use empty key to get first key in map + configMapName = parts[0] + configMapKey = "" + } + return configMapName, configMapKey +}