Implement Custom CA Support
Use this guide when the domain service managed by your service provider needs to trust custom Certificate Authorities — for example, to connect to APIs of a private cloud platform, a database server, or any HTTPS endpoint whose certificate is signed by a non-public CA.
This guide covers the developer side. For the operator side (creating the CA bundle ConfigMaps and referencing them in a deployed ProviderConfig) see Configure Custom CAs.
Prerequisites
- A service provider created from the service-provider-template
- Familiarity with the ProviderConfig design guidelines
Step 1 — Add caBundleRef to your ProviderConfig API
Open your ProviderConfig type definition (typically api/v1alpha1/providerconfig_types.go) and add a CABundleRef field to the spec:
import corev1 "k8s.io/api/core/v1"
type ProviderConfigSpec struct {
// ... existing fields ...
// CABundleRef is a reference to a config map containing certificate bundle.
// It will be installed on the ManagedControlPlane and configured for the domain service.
// +kubebuilder:validation:Optional
CABundleRef *corev1.ConfigMapKeySelector `json:"caBundleRef,omitempty"`
}
corev1.ConfigMapKeySelector is the idiomatic Kubernetes type for a {name, key} reference to a ConfigMap entry. It produces the YAML structure that operators set in their ProviderConfig:
spec:
caBundleRef:
name: ca-bundles
key: ca-bundle.crt
After editing the type, run code generation to update the deepcopy and CRD manifest:
task generate
task manifests
Step 2 — Resolve the bundle in CreateOrUpdate
The CA bundle ConfigMap lives on the platform cluster in the openmcp-system namespace. You can read it at the start of your CreateOrUpdate method using the platform cluster client:
func (r *FooServiceReconciler) CreateOrUpdate(
ctx context.Context,
obj *apiv1alpha1.FooService,
pc *apiv1alpha1.ProviderConfig,
clusters spruntime.ClusterContext,
) (ctrl.Result, error) {
var caBundle []byte
if pc.Spec.CABundleRef != nil {
cm := &corev1.ConfigMap{}
key := client.ObjectKey{
Namespace: r.PodNamespace, // openmcp-system
Name: pc.Spec.CABundleRef.Name,
}
if err := r.PlatformCluster.Client().Get(ctx, key, cm); err != nil {
return ctrl.Result{}, fmt.Errorf("fetching CA bundle ConfigMap %q: %w", key.Name, err)
}
bundle, ok := cm.Data[pc.Spec.CABundleRef.Key]
if !ok {
return ctrl.Result{}, fmt.Errorf("CA bundle ConfigMap %q has no key %q", key.Name, pc.Spec.CABundleRef.Key)
}
caBundle = []byte(bundle)
}
// pass caBundle into your client/deployment logic below
_ = caBundle
return ctrl.Result{}, nil
}
r.PodNamespace is the namespace the service provider pod runs in — the same namespace where operators place the CA bundle ConfigMaps. Ensure this field is populated in your reconciler struct:
type FooServiceReconciler struct {
OnboardingCluster *clusters.Cluster
PlatformCluster *clusters.Cluster
PodNamespace string
}
And pass it when constructing the reconciler in main.go:
controller.FooServiceReconciler{
PodNamespace: os.Getenv("POD_NAMESPACE"),
// ...
}
Step 3 — Make use of the bundle
At this point, your reconciler can resolve the CA bundle from the platform cluster. The following sections show the two common integration points: outbound connections made by the controller itself, and workloads deployed into MCPs or workload clusters.
3a. Outbound connections from the controller process
If your controller makes direct HTTPS calls — for example, to a Helm repository, an OCI registry, or an external API — build a *x509.CertPool from the bundle and use it in your http.Client:
import (
"crypto/tls"
"crypto/x509"
"net/http"
)
func httpClientWithCA(caBundle []byte) *http.Client {
pool, err := x509.SystemCertPool()
if err != nil {
pool = x509.NewCertPool()
}
pool.AppendCertsFromPEM(caBundle)
return &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{RootCAs: pool},
},
}
}
Pass this client to any HTTP-based library that accepts a custom http.Client (for example, most Helm and OCI client constructors accept one).
3b. Workloads deployed into MCPs or workload clusters
If your service provider installs workloads (Deployments, Helm releases) that themselves need to trust the custom CA, the CA bundle must be available inside the target cluster. Copy the bundle as a ConfigMap into the workload namespace, then mount it into any Pod you manage.
Copy the ConfigMap at reconcile time:
func (r *FooServiceReconciler) syncCABundle(
ctx context.Context,
pc *apiv1alpha1.ProviderConfig,
caBundle []byte,
targetClient client.Client,
targetNamespace string,
) error {
if caBundle == nil {
return nil
}
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "ca-bundle",
Namespace: targetNamespace,
},
}
_, err := ctrl.CreateOrUpdate(ctx, targetClient, cm, func() error {
cm.Data = map[string]string{
pc.Spec.CABundleRef.Key: string(caBundle),
}
return nil
})
return err
}
Call this helper early in CreateOrUpdate, before reconciling any workloads:
workloadClient := clusters.WorkloadCluster.Client() // or clusters.MCPCluster.Client()
if err := r.syncCABundle(ctx, pc, caBundle, workloadClient, targetNamespace); err != nil {
return ctrl.Result{}, err
}
When caBundleRef is removed from the ProviderConfig, syncCABundle returns early (because caBundle == nil) and the previously copied ConfigMap will remain in the target namespace as an orphan. The orphan is harmless (it contains no secrets), but you may want to add a cleanup step that deletes the ca-bundle ConfigMap from the target namespace when pc.Spec.CABundleRef == nil.
Mount the ConfigMap and set EnvVar in any Pod spec you manage:
pod.Spec.Volumes = append(pod.Spec.Volumes, corev1.Volume{
Name: "ca-bundle",
VolumeSource: corev1.VolumeSource{
ConfigMap: &corev1.ConfigMapVolumeSource{
LocalObjectReference: corev1.LocalObjectReference{Name: "ca-bundle"},
},
},
})
pod.Spec.Containers[0].VolumeMounts = append(pod.Spec.Containers[0].VolumeMounts, corev1.VolumeMount{
Name: "ca-bundle",
MountPath: "/etc/open-control-plane/custom-ca",
ReadOnly: true,
})
var certDirectories = []string{
"/etc/ssl/certs",
"/etc/pki/tls/certs",
"/etc/open-control-plane/custom-ca",
}
pod.Spec.Containers[0].Env = append(pod.Spec.Containers[0].Env, corev1.EnvVar{
Name: "SSL_CERT_DIR",
Value: strings.Join(certDirectories, ":"), // appending the mount path of our custom CA cert to the usual directories
})
If you deploy via Helm, pass the bundle content as a chart value instead and let the chart handle the ConfigMap and volume mount.
Many HTTP and TLS client libraries can be configured to trust additional certificates through environment variables such as SSL_CERT_DIR but this is not universal behavior. Check how the specific library or runtime you use builds its trust store and whether it reads certificates from the system store, explicit file paths, or has other library-specific configuration.
Step 4 — Re-enqueue on ConfigMap changes
When the CA bundle ConfigMap is updated, your controller must reconcile all FooService objects so they pick up the new certificate. The runtime provides a ConfigMapWatcher interface for this.
4a. Implement ConfigMapWatcher on your reconciler
// IsReferencedConfigMap returns true if the given ConfigMap should trigger
// reconciliation. See serviceprovider.ConfigMapWatcher for details.
func (r *FooServiceReconciler) IsReferencedConfigMap(ctx context.Context, configMap *corev1.ConfigMap, pc *apiv1alpha1.ProviderConfig) bool {
if pc == nil || pc.Spec.CABundleRef == nil {
return false
}
return configMap.Name == pc.Spec.CABundleRef.Name
}
4b. Register the watch namespace on the builder
Tell the APIReconcilerBuilder which namespace to watch for ConfigMap changes. Pass the same namespace used to read the bundle (your pod's namespace):
spr := serviceprovider.NewAPIReconcilerBuilder[*fooservicesv1alpha1.FooService, *fooservicesv1alpha1.ProviderConfig]().
EmptyObjectProvider(func() *fooservicesv1alpha1.FooService { return &fooservicesv1alpha1.FooService{} }).
PlatformCluster(platformCluster).
OnboardingCluster(onboardingCluster).
ConfigMapNamespace(podNamespace).
Reconciler(&controller.FooServiceReconciler{...}).
// ...
With these two changes in place, the runtime will watch ConfigMaps in the given namespace on the platform cluster. Whenever a ConfigMap changes, IsReferencedConfigMap is called — if it returns true, all FooService objects are re-enqueued for reconciliation automatically.
Verification
- Create a
ProviderConfigwith acaBundleRefpointing to a ConfigMap that contains a self-signed CA certificate. - Verify your controller reads the ConfigMap without errors (check the controller logs).
- Confirm that outbound HTTPS connections to a service signed by that CA succeed.
- Update the ConfigMap content and confirm the controller reconciles all
FooServiceobjects automatically (e.g. watch controller logs for new reconcile entries) - Remove
caBundleReffrom theProviderConfigand confirm the controller falls back to default TLS behavior.