diff --git a/apis/repo-manager.pulpproject.org/v1/pulp_types.go b/apis/repo-manager.pulpproject.org/v1/pulp_types.go index ba0c4ca83..79b2f1ebf 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/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/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..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...) } @@ -1141,15 +1149,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..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 2b0cf55be..130cc0f4e 100644 --- a/controllers/ocp/utils.go +++ b/controllers/ocp/utils.go @@ -101,40 +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 -func mountCASpec(pulp *pulpv1.Pulp, volumes []corev1.Volume, volumeMounts []corev1.VolumeMount) ([]corev1.Volume, []corev1.VolumeMount) { - - if pulp.Spec.TrustedCa { - - // trustedCAVolume contains the configmap with the custom ca bundle - 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"}, - }, - }, - }, - } - 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 new file mode 100644 index 000000000..102e75e5e --- /dev/null +++ b/controllers/ocp/utils_test.go @@ -0,0 +1,386 @@ +/* +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" + "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 := 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)) + } + + 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: nil, // Empty means OpenShift mode + }, + } + pulp.Name = pulpName + + volumes := []corev1.Volume{} + volumeMounts := []corev1.VolumeMount{} + + resultVolumes := controllers.SetCAVolumes(pulp, volumes) + resultVolumeMounts := controllers.SetCAVolumeMounts(pulp, 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 := controllers.SetCAVolumes(pulp, volumes) + resultVolumeMounts := controllers.SetCAVolumeMounts(pulp, 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{} + resultVolumes := controllers.SetCAVolumes(pulp, volumes) + + 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) { + configMapName := "my-bund:ca-bundle.crt" + pulp := &pulpv1.Pulp{ + Spec: pulpv1.PulpSpec{ + TrustedCa: true, + TrustedCaConfigMapKey: &configMapName, + }, + } + 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 := controllers.SetCAVolumes(pulp, existingVolumes) + resultVolumeMounts := controllers.SetCAVolumeMounts(pulp, 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 := controllers.SetCAVolumes(pulp, volumes) + resultVolumeMounts := controllers.SetCAVolumeMounts(pulp, 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 := controllers.SetCAVolumeMounts(pulp, []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..8c81b1718 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/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/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 597fb7620..5cdddbb78 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 @@ -990,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 +} 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)