diff --git a/README.md b/README.md index 33caa2a..3cdd0fb 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ The setup of the Service Provider Landscaper within an OpenControlPlane landsca | Status reporting & error messages | ✅ | | | Operation annotations | ⚠️ | `landscaper.services.openmcp.cloud/operation: reconcile` is processed. `openmcp.cloud/operation: ignore` is not processed. | | API stability policy | ✅ | | -| Custom CA support | ❌ | Custom CA bundle propagation to Landscaper components is not implemented. | +| Custom CA support | ✅ | | | Release artifacts (image + OCM) | ✅ | | | Testing | ⚠️ | Unit and envtest suites exist; full lifecycle tests against a real cluster are not present. | | Ownership and maintenance docs | ✅ | | diff --git a/api/crds/manifests/landscaper.services.open-control-plane.io_providerconfigs.yaml b/api/crds/manifests/landscaper.services.open-control-plane.io_providerconfigs.yaml index 54de09e..d544fae 100644 --- a/api/crds/manifests/landscaper.services.open-control-plane.io_providerconfigs.yaml +++ b/api/crds/manifests/landscaper.services.open-control-plane.io_providerconfigs.yaml @@ -43,6 +43,31 @@ spec: description: ProviderConfigSpec is the specification of the Landscaper Service Provider configuration properties: + caBundleRef: + description: |- + CABundleRef is a reference to a config map containing a PEM-encoded certificate bundle. + It will be installed on the OpenControlPlane and configured for the domain service. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or its key must be + defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic deployment: description: Deployment specifies the OCI image locations and available versions of the landscaper diff --git a/api/v1alpha2/providerconfiguration_types.go b/api/v1alpha2/providerconfiguration_types.go index 1657f9d..2bc2c8f 100644 --- a/api/v1alpha2/providerconfiguration_types.go +++ b/api/v1alpha2/providerconfiguration_types.go @@ -18,6 +18,7 @@ package v1alpha2 import ( "github.com/openmcp-project/openmcp-operator/api/common" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" ) @@ -34,6 +35,10 @@ const ( type ProviderConfigSpec struct { // +kubebuilder:validation:Required Deployment Deployment `json:"deployment"` + // CABundleRef is a reference to a config map containing a PEM-encoded certificate bundle. + // It will be installed on the OpenControlPlane and configured for the domain service. + // +kubebuilder:validation:Optional + CABundleRef *corev1.ConfigMapKeySelector `json:"caBundleRef,omitempty"` } // ProviderConfigStatus is the status of the Landscaper Service Provider configuration diff --git a/api/v1alpha2/zz_generated.deepcopy.go b/api/v1alpha2/zz_generated.deepcopy.go index 12db088..22a7be5 100644 --- a/api/v1alpha2/zz_generated.deepcopy.go +++ b/api/v1alpha2/zz_generated.deepcopy.go @@ -255,6 +255,11 @@ func (in *ProviderConfigList) DeepCopyObject() runtime.Object { func (in *ProviderConfigSpec) DeepCopyInto(out *ProviderConfigSpec) { *out = *in in.Deployment.DeepCopyInto(&out.Deployment) + if in.CABundleRef != nil { + in, out := &in.CABundleRef, &out.CABundleRef + *out = new(v1.ConfigMapKeySelector) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderConfigSpec. diff --git a/docs/README.md b/docs/README.md index 492a2f6..0546450 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,6 +7,7 @@ ## Technical Documentation +- [Custom CA Bundle Configuration](technical/custom-ca.md) - [Installing the Service Provider Landscaper](technical/install.md) - [Packages](technical/packages.md) diff --git a/docs/resources/overview.md b/docs/resources/overview.md index 271adf1..0cf89f4 100644 --- a/docs/resources/overview.md +++ b/docs/resources/overview.md @@ -86,6 +86,26 @@ spec: image: my.registry.example/custom-manifest-deployer ``` +### Custom CA Certificates + +The `CABundleRef` property in the `ProviderConfig` allows you to configure custom Certificate Authority (CA) bundles for the Landscaper instances as shown below. +For a more detailed guide see the [custom CA guide](../technical/custom-ca.md). + +```yaml +apiVersion: landscaper.services.openmcp.cloud/v1alpha2 +kind: ProviderConfig +metadata: + name: default + labels: + landscaper.services.openmcp.cloud/providertype: default +spec: + # ... other configuration ... + + caBundleRef: + name: custom-ca-bundle # Name of the ConfigMap + key: ca-bundle.crt # Key within the ConfigMap containing the certificate bundle +``` + ### Default ProviderConfig If the label `landscaper.services.openmcp.cloud/providertype: default` is set, this `ProviderConfig` is used by all `Landscaper` resources that do not explicitly reference a provider configuration. diff --git a/docs/technical/custom-ca.md b/docs/technical/custom-ca.md new file mode 100644 index 0000000..d4802ba --- /dev/null +++ b/docs/technical/custom-ca.md @@ -0,0 +1,115 @@ +# Custom CA Bundle Configuration + +## Overview + +The `CABundleRef` property in the `ProviderConfig` allows you to configure custom Certificate Authority (CA) bundles for Landscaper instances. This is essential when your infrastructure uses private or self-signed certificates that need to be trusted by Landscaper. + +## Use Cases + +- **Private Source Registries**: When pulling Helm charts from registries with self-signed certificates +- **Air-gapped Environments**: When operating in isolated networks with custom certificate infrastructure + +## Configuration + +### ProviderConfig Specification + +The `caBundleRef` field is an optional reference to a Kubernetes ConfigMap containing your custom CA certificate bundle: + +```yaml +apiVersion: landscaper.services.openmcp.cloud/v1alpha2 +kind: ProviderConfig +metadata: + name: default + labels: + landscaper.services.openmcp.cloud/providertype: default +spec: + # ... other configuration ... + + caBundleRef: + name: custom-ca-bundle # Name of the ConfigMap + key: ca-bundle.crt # Key within the ConfigMap containing the certificate bundle +``` + +### Creating the CA Bundle ConfigMap + +First, create a ConfigMap in the Platform cluster containing your CA certificate bundle. The certificate bundle should be in PEM format and can contain multiple certificates concatenated together. + +#### Example: Single CA Certificate + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: custom-ca-bundle + namespace: openmcp-system # Must be in the same namespace as the service-provider-landscaper +data: + ca-bundle.crt: | + -----BEGIN CERTIFICATE----- + MIIDADCCAeigAwIBAgIUU0jjGMPVbvVbUen942ixQO2k2V4wDQYJKoZIhvcNAQEL + BQAwGDEWMBQGA1UEAxMNc2VsZnNpZ25lZC1jYTAeFw0yNjAyMDkyMDQ5MTdaFw0y + ... (certificate content) ... + -----END CERTIFICATE----- +``` + +#### Example: Multiple CA Certificates + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: custom-ca-bundle + namespace: openmcp-system +data: + ca-bundle.crt: | + -----BEGIN CERTIFICATE----- + MIIDADCCAeigAwIBAgIUU0jjGMPVbvVbUen942ixQO2k2V4wDQYJKoZIhvcNAQEL + ... (first certificate) ... + -----END CERTIFICATE----- + -----BEGIN CERTIFICATE----- + MIIDADCCAeigAwIBAgIUU0jjGMPVbvVbUen942ixQO2k2V4wDQYJKoZIhvcNAQEL + ... (second certificate) ... + -----END CERTIFICATE----- +``` + +#### Creating from a Certificate File + +If you have CA certificates in files, you can create the ConfigMap directly: + +```bash +kubectl create configmap custom-ca-bundle \ + --from-file=ca-bundle.crt=/path/to/your/ca-bundle.crt \ + --namespace=openmcp-system +``` + +## How It Works + +```mermaid +flowchart TB + + subgraph PC[Platform Cluster] + subgraph SPN[Service Provider Namespace] + customcacm([custom-ca-configmap]) + end + end + + subgraph MCP[WorkloadCluster] + subgraph FS[landscaper namespace] + customcacmcopy([custom-ca-configmap]) + HelmDeployer -. uses .-> customcacmcopy + ManifestDeployer -. uses .-> customcacmcopy + ControllerDeployment -. uses .-> customcacmcopy + MainDeployment -. uses .-> customcacmcopy + WebhooksServerDeployment -. uses .-> customcacmcopy + end + end + + customcacm -- copied to --> customcacmcopy +``` + +When you configure `spec.caBundleRef` in a ProviderConfig, the service provider reads the referenced ConfigMap (name/key) from the provider namespace on the platform cluster and copies it into the Landscaper instance namespace on the workload cluster. + +Each Landscaper component (controller, main, webhooks, helm deployer, manifest deployer) mounts the copied ConfigMap as a volume named `custom-ca-bundle` at `/etc/open-control-plane/custom-ca`. Only the selected ConfigMap key is mounted as a file with the key as filename `/etc/open-control-plane/custom-ca/`. + +To make the bundle effective, the pods set `SSL_CERT_DIR=:/etc/open-control-plane/custom-ca`. This keeps the system trust store and extends it with your custom CA bundle. + +**Notice:** If your container runtime also needs to trust a private registry, install the CA bundle on the cluster nodes as well. \ No newline at end of file diff --git a/internal/controller/landscaper_controller.go b/internal/controller/landscaper_controller.go index f14a613..be7402b 100644 --- a/internal/controller/landscaper_controller.go +++ b/internal/controller/landscaper_controller.go @@ -121,6 +121,9 @@ func (r *LandscaperReconciler) SetupWithManager(mgr ctrl.Manager) error { WatchesRawSource(source.Kind(r.PlatformCluster.Cluster().GetCache(), &corev1.Secret{}, handler.TypedEnqueueRequestsFromMapFunc(r.mapImagePullSecretToRequests(mgr)), )). + WatchesRawSource(source.Kind(r.PlatformCluster.Cluster().GetCache(), &corev1.ConfigMap{}, + handler.TypedEnqueueRequestsFromMapFunc(r.mapCABundleConfigMapToRequests(mgr)), + )). Named(controllerName). Complete(r) } @@ -247,6 +250,60 @@ func referencesSecret(refs []common.LocalObjectReference, name string) bool { return false } +// mapCABundleConfigMapToRequests returns a handler function that triggers reconciliation of Landscaper resources +// whenever a ProviderConfig-referenced CA bundle ConfigMap changes. +func (r *LandscaperReconciler) mapCABundleConfigMapToRequests(mgr ctrl.Manager) func(context.Context, *corev1.ConfigMap) []ctrl.Request { + return func(ctx context.Context, configMap *corev1.ConfigMap) []ctrl.Request { + log := logging.Wrap(mgr.GetLogger()).WithName(controllerName + "/ConfigMap") + + if configMap.Namespace != r.ProviderNamespace { + return nil + } + + if !r.isReferencedCaConfigMap(ctx, configMap.Name) { + return nil + } + + log.Debug("CA bundle configmap changed, triggering reconcile", "configMap", configMap.Name) + + landscapers := &v1alpha2.LandscaperList{} + if err := r.OnboardingCluster.Client().List(ctx, landscapers); err != nil { + log.Error(err, "Failed to list Landscaper resources") + return nil + } + + for _, landscaper := range landscapers.Items { + if err := controller.EnsureAnnotation( + ctx, r.OnboardingCluster.Client(), + &landscaper, + v1alpha2.LandscaperOperation, v1alpha2.OperationReconcile, + true, controller.OVERWRITE); err != nil { + log.Error(err, "Failed to set reconcile annotation for Landscaper resource", "landscaper", landscaper.Name, "namespace", landscaper.Namespace) + } + } + return nil + } +} + +// isReferencedCaConfigMap checks whether the given configmap name is referenced as a CA bundle +// in any ProviderConfig resource on the platform cluster. +func (r *LandscaperReconciler) isReferencedCaConfigMap(ctx context.Context, configMapName string) bool { + log := logging.Wrap(ctrl.Log).WithName(controllerName + "/ConfigMap") + + providerConfigList := &v1alpha2.ProviderConfigList{} + if err := r.PlatformCluster.Client().List(ctx, providerConfigList); err != nil { + log.Error(err, "Failed to list ProviderConfig resources") + return false + } + + for _, providerConfig := range providerConfigList.Items { + if providerConfig.Spec.CABundleRef != nil && providerConfig.Spec.CABundleRef.Name == configMapName { + return true + } + } + return false +} + func getMCPPermissions() []clustersv1alpha1.PermissionsRequest { defaultVerbs := []string{"get", "list", "watch", "create", "update", "patch", "delete"} diff --git a/internal/controller/landscaper_controller_test.go b/internal/controller/landscaper_controller_test.go index b67f17d..219beb9 100644 --- a/internal/controller/landscaper_controller_test.go +++ b/internal/controller/landscaper_controller_test.go @@ -10,6 +10,7 @@ import ( "k8s.io/utils/ptr" "github.com/openmcp-project/service-provider-landscaper/internal/dns" + configmapsync "github.com/openmcp-project/service-provider-landscaper/internal/shared/configmaps" "github.com/openmcp-project/service-provider-landscaper/internal/shared/identity" @@ -42,7 +43,9 @@ import ( ) const ( - controllerName = "test-controller" + controllerName = "test-controller" + caConfigMapName = "ca-bundle" + caConfigMapKey = "ca.crt" ) func setDeploymentReady(ctx context.Context, deployment *appsv1.Deployment, c client.Client) { @@ -79,6 +82,52 @@ func expectImagePullSecretsValid(ctx context.Context, c client.Client, deploymen } } +// expectCaConfigMapValid verifies that a deployment mounts CA configmaps at the expected path +// and that each referenced configmap exists in the given namespace with the expected key. +func expectCaConfigMapValid(ctx context.Context, c client.Client, deployment *appsv1.Deployment, namespace string) { + var foundVolumeMount bool + var foundVolume bool + var foundEnvVar bool + + for _, container := range deployment.Spec.Template.Spec.Containers { + for _, volumeMount := range container.VolumeMounts { + if volumeMount.Name == configmapsync.CustomCaVolumeName { + foundVolumeMount = true + break + } + } + } + + for _, volume := range deployment.Spec.Template.Spec.Volumes { + if volume.Name == configmapsync.CustomCaVolumeName { + foundVolume = true + break + } + } + + for _, container := range deployment.Spec.Template.Spec.Containers { + for _, envVar := range container.Env { + if envVar.Value == configmapsync.SSLCertDirEnvValue() { + foundEnvVar = true + } + } + } + + Expect(foundVolumeMount).To(BeTrue()) + Expect(foundVolume).To(BeTrue()) + Expect(foundEnvVar).To(BeTrue()) + + configMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: caConfigMapName, + Namespace: namespace, + }, + } + Expect(c.Get(ctx, client.ObjectKeyFromObject(configMap), configMap)).To(Succeed()) + Expect(configMap.Data).To(HaveKey(caConfigMapKey)) + +} + type testInstanceClusterAccess struct { mcpCluster *clusters.Cluster workloadCluster *clusters.Cluster @@ -396,6 +445,13 @@ var _ = Describe("Landscaper Controller", func() { expectImagePullSecretsValid(env.Ctx, env.Client(), manifestDeployerDeployment, 2, installationNamespace) expectImagePullSecretsValid(env.Ctx, env.Client(), helmDeployerDeployment, 1, installationNamespace) + // expect the synced CA configmap to be mounted for all component deployments + expectCaConfigMapValid(env.Ctx, env.Client(), lsControllerDeployment, installationNamespace) + expectCaConfigMapValid(env.Ctx, env.Client(), lsMainDeployment, installationNamespace) + expectCaConfigMapValid(env.Ctx, env.Client(), lsWebhooksServerDeployment, installationNamespace) + expectCaConfigMapValid(env.Ctx, env.Client(), manifestDeployerDeployment, installationNamespace) + expectCaConfigMapValid(env.Ctx, env.Client(), helmDeployerDeployment, installationNamespace) + // set deployments to ready setDeploymentReady(env.Ctx, lsControllerDeployment, env.Client()) setDeploymentReady(env.Ctx, lsMainDeployment, env.Client()) diff --git a/internal/controller/reconcile.go b/internal/controller/reconcile.go index 1c25af3..0192d50 100644 --- a/internal/controller/reconcile.go +++ b/internal/controller/reconcile.go @@ -9,6 +9,7 @@ import ( "time" "github.com/openmcp-project/controller-utils/pkg/clusters" + "github.com/openmcp-project/controller-utils/pkg/resources" "github.com/openmcp-project/openmcp-operator/api/common" "github.com/openmcp-project/openmcp-operator/api/provider/v1alpha1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -27,6 +28,7 @@ import ( "github.com/openmcp-project/service-provider-landscaper/api/v1alpha2" "github.com/openmcp-project/service-provider-landscaper/internal/installer/instance" + configmapsync "github.com/openmcp-project/service-provider-landscaper/internal/shared/configmaps" "github.com/openmcp-project/service-provider-landscaper/internal/shared/identity" ) @@ -153,6 +155,25 @@ func (r *LandscaperReconciler) handleCreateUpdateOperation(ctx context.Context, return reconcile.Result{}, status, err } + if providerConfig.Spec.CABundleRef != nil { + if err := resources.CreateOrUpdateResource(ctx, conf.WorkloadCluster.Client(), resources.NewNamespaceMutator(conf.Instance.Namespace())); err != nil { + return reconcile.Result{}, status, err + } + caConfigMapSync := configmapsync.ConfigMapSync{ + PlatformCluster: r.PlatformCluster, + PlatformClusterNamespace: conf.PlatformClusterNamespace, + WorkloadCluster: conf.WorkloadCluster, + WorkloadClusterNamespace: conf.Instance.Namespace(), + } + + caConfigMap, err := caConfigMapSync.CreateOrUpdate(ctx, providerConfig.Spec.CABundleRef) + if err != nil { + return reconcile.Result{}, status, fmt.Errorf("failed to sync CA bundle configmap: %w", err) + } + + conf.CaConfigMap = caConfigMap + } + if err := instance.InstallLandscaperInstance(ctx, conf); err != nil { log.Error(err, "failed to install landscaper instance") status.setInstallFailed(err) @@ -265,6 +286,18 @@ func (r *LandscaperReconciler) handleDeleteOperation(ctx context.Context, ls *v1 return reconcile.Result{}, status, err } + if providerConfig.Spec.CABundleRef != nil { + caConfigMapSync := configmapsync.ConfigMapSync{ + WorkloadCluster: conf.WorkloadCluster, + WorkloadClusterNamespace: conf.Instance.Namespace(), + } + if err := caConfigMapSync.Delete(ctx, providerConfig.Spec.CABundleRef); err != nil { + if !apierrors.IsNotFound(err) { + return reconcile.Result{}, status, fmt.Errorf("failed to delete synced CA bundle configmap: %w", err) + } + } + } + if err = instance.UninstallLandscaperInstance(ctx, conf); err != nil { log.Error(err, "failed to uninstall landscaper instance") status.setUninstallFailed(err) diff --git a/internal/controller/testdata/test-03/ca-configmap.yaml b/internal/controller/testdata/test-03/ca-configmap.yaml new file mode 100644 index 0000000..20ea1f0 --- /dev/null +++ b/internal/controller/testdata/test-03/ca-configmap.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: ca-bundle + namespace: openmcp-system +data: + ca.crt: dummy-ca-cert diff --git a/internal/controller/testdata/test-03/providerconfig.yaml b/internal/controller/testdata/test-03/providerconfig.yaml index a33b54e..f14d817 100644 --- a/internal/controller/testdata/test-03/providerconfig.yaml +++ b/internal/controller/testdata/test-03/providerconfig.yaml @@ -5,6 +5,9 @@ metadata: landscaper.services.openmcp.cloud/providertype: default name: default spec: + caBundleRef: + name: ca-bundle + key: ca.crt deployment: repository: registry.test/components availableVersions: diff --git a/internal/installer/helmdeployer/deployment.go b/internal/installer/helmdeployer/deployment.go index ccdf96f..bdb6ece 100644 --- a/internal/installer/helmdeployer/deployment.go +++ b/internal/installer/helmdeployer/deployment.go @@ -11,6 +11,8 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/openmcp-project/controller-utils/pkg/resources" + + configmapsync "github.com/openmcp-project/service-provider-landscaper/internal/shared/configmaps" ) type deploymentMutator struct { @@ -138,6 +140,24 @@ func (d *deploymentMutator) volumes() []corev1.Volume { volumes = append(volumes, ociRegistryVolume) } + if d.values.CAConfigMap != nil { + caVolume := corev1.Volume{ + Name: configmapsync.CustomCaVolumeName, + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: d.values.CAConfigMap.LocalObjectReference, + Items: []corev1.KeyToPath{ + { + Key: d.values.CAConfigMap.Key, + Path: d.values.CAConfigMap.Key, + }, + }, + }, + }, + } + volumes = append(volumes, caVolume) + } + return volumes } @@ -160,6 +180,14 @@ func (d *deploymentMutator) volumeMounts() []corev1.VolumeMount { }) } + if d.values.CAConfigMap != nil { + volumeMounts = append(volumeMounts, corev1.VolumeMount{ + Name: configmapsync.CustomCaVolumeName, + MountPath: configmapsync.CustomCaPath, + ReadOnly: true, + }) + } + return volumeMounts } @@ -174,7 +202,7 @@ func (d *deploymentMutator) args() []string { } func (d *deploymentMutator) env() []corev1.EnvVar { - return []corev1.EnvVar{ + envVars := []corev1.EnvVar{ { Name: "KUBECONFIG", Value: fmt.Sprintf("/app/ls/%s/kubeconfig", d.mcpKubeconfigSecretName()), @@ -212,4 +240,14 @@ func (d *deploymentMutator) env() []corev1.EnvVar { Value: strconv.FormatInt(int64(d.values.MCPClientSettings.QPS), 10), }, } + + if d.values.CAConfigMap != nil { + caEnvVar := corev1.EnvVar{ + Name: "SSL_CERT_DIR", + Value: configmapsync.SSLCertDirEnvValue(), + } + envVars = append(envVars, caEnvVar) + } + + return envVars } diff --git a/internal/installer/helmdeployer/values.go b/internal/installer/helmdeployer/values.go index 8a1cb48..e003815 100644 --- a/internal/installer/helmdeployer/values.go +++ b/internal/installer/helmdeployer/values.go @@ -21,18 +21,19 @@ type Values struct { PlatformClusterNamespace string `json:"platformClusterNamespace,omitempty"` MCPCluster *clusters.Cluster WorkloadCluster *clusters.Cluster - VerbosityLevel string `json:"verbosityLevel,omitempty"` - MCPClusterKubeconfig string `json:"mcpClusterKubeconfig,omitempty"` - Image api.ImageConfiguration `json:"image,omitempty"` - ReplicaCount *int32 `json:"replicaCount,omitempty"` - Resources core.ResourceRequirements `json:"resources,omitempty"` // <<< - PodSecurityContext *core.PodSecurityContext `json:"podSecurityContext,omitempty"` - SecurityContext *core.SecurityContext `json:"securityContext,omitempty"` - Configuration v1alpha1.Configuration `json:"configuration,omitempty"` - WorkloadClientSettings *ClientSettings `json:"workloadClientSettings,omitempty"` - MCPClientSettings *ClientSettings `json:"mcpClientSettings,omitempty"` - HPA types.HPAValues `json:"hpa,omitempty"` - OCI *OCIValues `json:"oci,omitempty"` + VerbosityLevel string `json:"verbosityLevel,omitempty"` + MCPClusterKubeconfig string `json:"mcpClusterKubeconfig,omitempty"` + Image api.ImageConfiguration `json:"image,omitempty"` + ReplicaCount *int32 `json:"replicaCount,omitempty"` + Resources core.ResourceRequirements `json:"resources,omitempty"` // <<< + PodSecurityContext *core.PodSecurityContext `json:"podSecurityContext,omitempty"` + SecurityContext *core.SecurityContext `json:"securityContext,omitempty"` + Configuration v1alpha1.Configuration `json:"configuration,omitempty"` + WorkloadClientSettings *ClientSettings `json:"workloadClientSettings,omitempty"` + MCPClientSettings *ClientSettings `json:"mcpClientSettings,omitempty"` + HPA types.HPAValues `json:"hpa,omitempty"` + OCI *OCIValues `json:"oci,omitempty"` + CAConfigMap *core.ConfigMapKeySelector `json:"caConfigMap,omitempty"` } type ReleaseValues struct { diff --git a/internal/installer/instance/config.go b/internal/installer/instance/config.go index e358801..d3cacb7 100644 --- a/internal/installer/instance/config.go +++ b/internal/installer/instance/config.go @@ -19,6 +19,7 @@ type Configuration struct { MCPCluster *clusters.Cluster WorkloadCluster *clusters.Cluster WorkloadClusterDomain string + CaConfigMap *core.ConfigMapKeySelector Landscaper LandscaperConfig diff --git a/internal/installer/instance/values.go b/internal/installer/instance/values.go index 7e7ce02..be2034c 100644 --- a/internal/installer/instance/values.go +++ b/internal/installer/instance/values.go @@ -32,6 +32,7 @@ func manifestDeployerValues(c *Configuration, kubeconfigs *rbac.Kubeconfigs) *ma Resources: c.ManifestDeployer.Resources, HPA: c.ManifestDeployer.HPA, MCPClusterKubeconfig: string(kubeconfigs.MCPCluster), + CAConfigMap: c.CaConfigMap, } return v @@ -50,6 +51,7 @@ func helmDeployerValues(c *Configuration, kubeconfigs *rbac.Kubeconfigs) *helmde Resources: c.HelmDeployer.Resources, HPA: c.HelmDeployer.HPA, MCPClusterKubeconfig: string(kubeconfigs.MCPCluster), + CAConfigMap: c.CaConfigMap, } return v @@ -74,6 +76,7 @@ func landscaperValues(c *Configuration, kubeconfigs *rbac.Kubeconfigs, manifestE ResourcesMain: c.Landscaper.Controller.ResourcesMain, Metrics: nil, HPAMain: c.Landscaper.Controller.HPAMain, + CAConfigMap: c.CaConfigMap, }, WebhooksServer: landscaper.WebhooksServerValues{ DisableWebhooks: nil, @@ -84,9 +87,10 @@ func landscaperValues(c *Configuration, kubeconfigs *rbac.Kubeconfigs, manifestE Port: c.Landscaper.WebhooksServer.ServicePort, Name: c.Landscaper.WebhooksServer.ServiceName, }, - URL: c.WorkloadClusterDomain, - Resources: c.Landscaper.WebhooksServer.Resources, - HPA: c.Landscaper.WebhooksServer.HPA, + URL: c.WorkloadClusterDomain, + Resources: c.Landscaper.WebhooksServer.Resources, + HPA: c.Landscaper.WebhooksServer.HPA, + CAConfigMap: c.CaConfigMap, }, } diff --git a/internal/installer/landscaper/deployment_central.go b/internal/installer/landscaper/deployment_central.go index 3ba14ff..1a8588e 100644 --- a/internal/installer/landscaper/deployment_central.go +++ b/internal/installer/landscaper/deployment_central.go @@ -11,6 +11,8 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/openmcp-project/controller-utils/pkg/resources" + + configmapsync "github.com/openmcp-project/service-provider-landscaper/internal/shared/configmaps" ) type centralDeploymentMutator struct { @@ -129,6 +131,24 @@ func (m *centralDeploymentMutator) volumes() []corev1.Volume { }, } + if m.values.Controller.CAConfigMap != nil { + caVolume := corev1.Volume{ + Name: configmapsync.CustomCaVolumeName, + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: m.values.Controller.CAConfigMap.LocalObjectReference, + Items: []corev1.KeyToPath{ + { + Key: m.values.Controller.CAConfigMap.Key, + Path: m.values.Controller.CAConfigMap.Key, + }, + }, + }, + }, + } + volumes = append(volumes, caVolume) + } + return volumes } @@ -152,6 +172,14 @@ func (m *centralDeploymentMutator) volumeMounts() []corev1.VolumeMount { }, } + if m.values.Controller.CAConfigMap != nil { + volumeMounts = append(volumeMounts, corev1.VolumeMount{ + Name: configmapsync.CustomCaVolumeName, + MountPath: configmapsync.CustomCaPath, + ReadOnly: true, + }) + } + return volumeMounts } @@ -168,7 +196,7 @@ func (m *centralDeploymentMutator) args() []string { } func (m *centralDeploymentMutator) env() []corev1.EnvVar { - return []corev1.EnvVar{ + envVars := []corev1.EnvVar{ { Name: "KUBECONFIG", Value: fmt.Sprint("/app/ls/", m.controllerWorkloadKubeconfigSecretName(), "/kubeconfig"), @@ -210,6 +238,16 @@ func (m *centralDeploymentMutator) env() []corev1.EnvVar { Value: strconv.FormatInt(int64(m.values.Controller.MCPClientSettings.QPS), 10), }, } + + if m.values.Controller.CAConfigMap != nil { + caEnvVar := corev1.EnvVar{ + Name: "SSL_CERT_DIR", + Value: configmapsync.SSLCertDirEnvValue(), + } + envVars = append(envVars, caEnvVar) + } + + return envVars } func (m *centralDeploymentMutator) ports() []corev1.ContainerPort { diff --git a/internal/installer/landscaper/deployment_main.go b/internal/installer/landscaper/deployment_main.go index 9c00d3b..3a18eb7 100644 --- a/internal/installer/landscaper/deployment_main.go +++ b/internal/installer/landscaper/deployment_main.go @@ -11,6 +11,8 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/openmcp-project/controller-utils/pkg/resources" + + configmapsync "github.com/openmcp-project/service-provider-landscaper/internal/shared/configmaps" ) type mainDeploymentMutator struct { @@ -138,6 +140,24 @@ func (m *mainDeploymentMutator) volumes() []corev1.Volume { }, } + if m.values.Controller.CAConfigMap != nil { + caVolume := corev1.Volume{ + Name: configmapsync.CustomCaVolumeName, + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: m.values.Controller.CAConfigMap.LocalObjectReference, + Items: []corev1.KeyToPath{ + { + Key: m.values.Controller.CAConfigMap.Key, + Path: m.values.Controller.CAConfigMap.Key, + }, + }, + }, + }, + } + volumes = append(volumes, caVolume) + } + return volumes } @@ -161,6 +181,14 @@ func (m *mainDeploymentMutator) volumeMounts() []corev1.VolumeMount { }, } + if m.values.Controller.CAConfigMap != nil { + volumeMounts = append(volumeMounts, corev1.VolumeMount{ + Name: configmapsync.CustomCaVolumeName, + MountPath: configmapsync.CustomCaPath, + ReadOnly: true, + }) + } + return volumeMounts } @@ -176,7 +204,7 @@ func (m *mainDeploymentMutator) args() []string { } func (m *mainDeploymentMutator) env() []corev1.EnvVar { - return []corev1.EnvVar{ + envVars := []corev1.EnvVar{ { Name: "KUBECONFIG", Value: fmt.Sprint("/app/ls/", m.controllerWorkloadKubeconfigSecretName(), "/kubeconfig"), @@ -214,6 +242,16 @@ func (m *mainDeploymentMutator) env() []corev1.EnvVar { Value: strconv.FormatInt(int64(m.values.Controller.MCPClientSettings.QPS), 10), }, } + + if m.values.Controller.CAConfigMap != nil { + caEnvVar := corev1.EnvVar{ + Name: "SSL_CERT_DIR", + Value: configmapsync.SSLCertDirEnvValue(), + } + envVars = append(envVars, caEnvVar) + } + + return envVars } func (m *mainDeploymentMutator) ports() []corev1.ContainerPort { diff --git a/internal/installer/landscaper/deployment_webhooks.go b/internal/installer/landscaper/deployment_webhooks.go index d01a65a..5bad397 100644 --- a/internal/installer/landscaper/deployment_webhooks.go +++ b/internal/installer/landscaper/deployment_webhooks.go @@ -10,6 +10,8 @@ import ( appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + configmapsync "github.com/openmcp-project/service-provider-landscaper/internal/shared/configmaps" ) type webhooksDeploymentMutator struct { @@ -97,6 +99,24 @@ func (m *webhooksDeploymentMutator) volumes() []corev1.Volume { }, } + if m.values.Controller.CAConfigMap != nil { + caVolume := corev1.Volume{ + Name: configmapsync.CustomCaVolumeName, + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: m.values.Controller.CAConfigMap.LocalObjectReference, + Items: []corev1.KeyToPath{ + { + Key: m.values.Controller.CAConfigMap.Key, + Path: m.values.Controller.CAConfigMap.Key, + }, + }, + }, + }, + } + volumes = append(volumes, caVolume) + } + return volumes } @@ -108,6 +128,14 @@ func (m *webhooksDeploymentMutator) volumeMounts() []corev1.VolumeMount { }, } + if m.values.Controller.CAConfigMap != nil { + volumeMounts = append(volumeMounts, corev1.VolumeMount{ + Name: configmapsync.CustomCaVolumeName, + MountPath: configmapsync.CustomCaPath, + ReadOnly: true, + }) + } + return volumeMounts } @@ -136,10 +164,20 @@ func (m *webhooksDeploymentMutator) args() []string { } func (m *webhooksDeploymentMutator) env() []corev1.EnvVar { - return []corev1.EnvVar{ + envVars := []corev1.EnvVar{ { Name: "KUBECONFIG", Value: fmt.Sprint("/app/ls/", m.controllerMCPKubeconfigSecretName(), "/kubeconfig"), }, } + + if m.values.Controller.CAConfigMap != nil { + caEnvVar := corev1.EnvVar{ + Name: "SSL_CERT_DIR", + Value: configmapsync.SSLCertDirEnvValue(), + } + envVars = append(envVars, caEnvVar) + } + + return envVars } diff --git a/internal/installer/landscaper/values.go b/internal/installer/landscaper/values.go index c9f8590..00fa4a3 100644 --- a/internal/installer/landscaper/values.go +++ b/internal/installer/landscaper/values.go @@ -55,6 +55,7 @@ type ControllerValues struct { HPAMain types.HPAValues `json:"hpaMain,omitempty"` // optional, has default value DeployItemTimeouts *v1alpha1.DeployItemTimeouts `json:"deployItemTimeouts,omitempty"` // optional, has default value HealthChecks *v1alpha1.AdditionalDeployments `json:"healthChecks,omitempty"` // optional, has default value + CAConfigMap *core.ConfigMapKeySelector `json:"caConfigMap,omitempty"` } const ( @@ -67,14 +68,15 @@ const ( type WebhooksServerValues struct { DisableWebhooks []string `json:"disableWebhooks,omitempty"` // MCPKubeconfig contains the kubeconfig for the mcp cluster. - MCPKubeconfig string `json:"mcpKubeconfig,omitempty"` - Service *ServiceValues `json:"service,omitempty"` // optional, has default value - Image api.ImageConfiguration `json:"image,omitempty"` - URL string `json:"url,omitempty"` - ServicePort int32 `json:"servicePort,omitempty"` // required unless DisableWebhooks contains "all" - ReplicaCount *int32 `json:"replicaCount,omitempty"` // optional - has default value - Resources core.ResourceRequirements `json:"resources,omitempty"` // optional - has default value - HPA types.HPAValues `json:"hpa,omitempty"` // optional - has default value + MCPKubeconfig string `json:"mcpKubeconfig,omitempty"` + Service *ServiceValues `json:"service,omitempty"` // optional, has default value + Image api.ImageConfiguration `json:"image,omitempty"` + URL string `json:"url,omitempty"` + ServicePort int32 `json:"servicePort,omitempty"` // required unless DisableWebhooks contains "all" + ReplicaCount *int32 `json:"replicaCount,omitempty"` // optional - has default value + Resources core.ResourceRequirements `json:"resources,omitempty"` // optional - has default value + HPA types.HPAValues `json:"hpa,omitempty"` // optional - has default value + CAConfigMap *core.ConfigMapKeySelector `json:"caConfigMap,omitempty"` } type CommonControllerValues struct { diff --git a/internal/installer/manifestdeployer/deployment.go b/internal/installer/manifestdeployer/deployment.go index b15f2c6..4ce5cfd 100644 --- a/internal/installer/manifestdeployer/deployment.go +++ b/internal/installer/manifestdeployer/deployment.go @@ -11,6 +11,8 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/openmcp-project/controller-utils/pkg/resources" + + configmapsync "github.com/openmcp-project/service-provider-landscaper/internal/shared/configmaps" ) type deploymentMutator struct { @@ -123,6 +125,24 @@ func (d *deploymentMutator) volumes() []corev1.Volume { }, } + if d.values.CAConfigMap != nil { + caVolume := corev1.Volume{ + Name: configmapsync.CustomCaVolumeName, + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: d.values.CAConfigMap.LocalObjectReference, + Items: []corev1.KeyToPath{ + { + Key: d.values.CAConfigMap.Key, + Path: d.values.CAConfigMap.Key, + }, + }, + }, + }, + } + volumes = append(volumes, caVolume) + } + return volumes } @@ -138,6 +158,14 @@ func (d *deploymentMutator) volumeMounts() []corev1.VolumeMount { }, } + if d.values.CAConfigMap != nil { + volumeMounts = append(volumeMounts, corev1.VolumeMount{ + Name: configmapsync.CustomCaVolumeName, + MountPath: configmapsync.CustomCaPath, + ReadOnly: true, + }) + } + return volumeMounts } @@ -152,7 +180,7 @@ func (d *deploymentMutator) args() []string { } func (d *deploymentMutator) env() []corev1.EnvVar { - return []corev1.EnvVar{ + envVars := []corev1.EnvVar{ { Name: "KUBECONFIG", Value: fmt.Sprintf("/app/ls/%s/kubeconfig", d.mcpKubeconfigSecretName()), @@ -190,4 +218,14 @@ func (d *deploymentMutator) env() []corev1.EnvVar { Value: strconv.FormatInt(int64(d.values.MCPClientSettings.QPS), 10), }, } + + if d.values.CAConfigMap != nil { + caEnvVar := corev1.EnvVar{ + Name: "SSL_CERT_DIR", + Value: configmapsync.SSLCertDirEnvValue(), + } + envVars = append(envVars, caEnvVar) + } + + return envVars } diff --git a/internal/installer/manifestdeployer/values.go b/internal/installer/manifestdeployer/values.go index 542c9a1..194ad67 100644 --- a/internal/installer/manifestdeployer/values.go +++ b/internal/installer/manifestdeployer/values.go @@ -20,17 +20,18 @@ type Values struct { PlatformCluster *clusters.Cluster PlatformClusterNamespace string `json:"platformClusterNamespace,omitempty"` WorkloadCluster *clusters.Cluster - VerbosityLevel string `json:"verbosityLevel,omitempty"` - MCPClusterKubeconfig string `json:"mcpClusterKubeconfig,omitempty"` - Image api.ImageConfiguration `json:"image,omitempty"` - ReplicaCount *int32 `json:"replicaCount,omitempty"` - Resources core.ResourceRequirements `json:"resources,omitempty"` - PodSecurityContext *core.PodSecurityContext `json:"podSecurityContext,omitempty"` - SecurityContext *core.SecurityContext `json:"securityContext,omitempty"` - Configuration v1alpha2.Configuration `json:"configuration,omitempty"` - WorkloadClientSettings *ClientSettings `json:"workloadClientSettings,omitempty"` - MCPClientSettings *ClientSettings `json:"mcpClientSettings,omitempty"` - HPA types.HPAValues `json:"hpa,omitempty"` + VerbosityLevel string `json:"verbosityLevel,omitempty"` + MCPClusterKubeconfig string `json:"mcpClusterKubeconfig,omitempty"` + Image api.ImageConfiguration `json:"image,omitempty"` + ReplicaCount *int32 `json:"replicaCount,omitempty"` + Resources core.ResourceRequirements `json:"resources,omitempty"` + PodSecurityContext *core.PodSecurityContext `json:"podSecurityContext,omitempty"` + SecurityContext *core.SecurityContext `json:"securityContext,omitempty"` + Configuration v1alpha2.Configuration `json:"configuration,omitempty"` + WorkloadClientSettings *ClientSettings `json:"workloadClientSettings,omitempty"` + MCPClientSettings *ClientSettings `json:"mcpClientSettings,omitempty"` + HPA types.HPAValues `json:"hpa,omitempty"` + CAConfigMap *core.ConfigMapKeySelector `json:"caConfigMap,omitempty"` } type ReleaseValues struct { diff --git a/internal/shared/configmaps/configmap_sync.go b/internal/shared/configmaps/configmap_sync.go new file mode 100644 index 0000000..e21313d --- /dev/null +++ b/internal/shared/configmaps/configmap_sync.go @@ -0,0 +1,105 @@ +package configmapsync + +import ( + "context" + "errors" + "strings" + + "github.com/openmcp-project/controller-utils/pkg/clusters" + "github.com/openmcp-project/controller-utils/pkg/resources" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + CustomCaVolumeName = "custom-ca-bundle" + CustomCaPath = "/etc/open-control-plane/custom-ca" +) + +// from x509 go standard lib (https://github.com/golang/go/blob/015343854b5d9e2829481df30dbcae2ca6682d25/src/crypto/x509/root_linux.go) +var certDirectories = []string{ + "/etc/ssl/certs", + "/etc/pki/tls/certs", +} + +// SSLCertDirEnvValue builds the SSL_CERT_DIR value used by all Landscaper components. +func SSLCertDirEnvValue() string { + dirs := make([]string, 0, len(certDirectories)+1) + dirs = append(dirs, certDirectories...) + dirs = append(dirs, CustomCaPath) + return strings.Join(dirs, ":") +} + +var ErrNilSourceConfigMapRef = errors.New("caBundleRef must not be nil") + +// ConfigMapSync is a helper to sync configmaps from the platform cluster to the workload cluster. +// It copies a selected key from the platform cluster namespace to the workload cluster namespace and +// renames the copied configmap to avoid name clashes between components. +type ConfigMapSync struct { + PlatformCluster *clusters.Cluster + PlatformClusterNamespace string + WorkloadCluster *clusters.Cluster + WorkloadClusterNamespace string +} + +func (s *ConfigMapSync) CreateOrUpdate(ctx context.Context, caBundleRef *corev1.ConfigMapKeySelector) (*corev1.ConfigMapKeySelector, error) { + if caBundleRef == nil { + return nil, ErrNilSourceConfigMapRef + } + + sourceCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: caBundleRef.Name, + Namespace: s.PlatformClusterNamespace, + }, + } + + if err := s.PlatformCluster.Client().Get(ctx, client.ObjectKeyFromObject(sourceCM), sourceCM); err != nil { + return nil, err + } + + cmName := caBundleRef.Name + + if err := resources.CreateOrUpdateResource(ctx, s.WorkloadCluster.Client(), newCAConfigMapMutator(cmName, s.WorkloadClusterNamespace, sourceCM.Data)); err != nil { + return nil, err + } + + return &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: cmName, + }, + Key: caBundleRef.Key, + }, nil +} + +func (s *ConfigMapSync) Delete(ctx context.Context, caBundleRef *corev1.ConfigMapKeySelector) error { + if caBundleRef == nil { + return ErrNilSourceConfigMapRef + } + + sourceCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: caBundleRef.Name, + Namespace: s.WorkloadClusterNamespace, + }, + } + + if err := s.WorkloadCluster.Client().Get(ctx, client.ObjectKeyFromObject(sourceCM), sourceCM); err != nil { + return err + } + + if err := resources.DeleteResource(ctx, s.WorkloadCluster.Client(), newCAConfigMapMutator(caBundleRef.Name, s.WorkloadClusterNamespace, sourceCM.Data)); err != nil { + return err + } + return nil +} + +func newCAConfigMapMutator(name, namespace string, data map[string]string) resources.Mutator[*corev1.ConfigMap] { + m := resources.NewConfigMapMutator( + name, + namespace, + data, + ) + return m +}