diff --git a/config/crd/bases/operator.kcp.io_frontproxies.yaml b/config/crd/bases/operator.kcp.io_frontproxies.yaml index 59640a97..d91f723d 100644 --- a/config/crd/bases/operator.kcp.io_frontproxies.yaml +++ b/config/crd/bases/operator.kcp.io_frontproxies.yaml @@ -421,6 +421,26 @@ spec: CertificateTemplates allows to customize the properties on the generated certificates for this front-proxy. type: object + clientCABundleRef: + description: |- + ClientCABundleRef references a v1.Secret object that contains an additional client CA bundle + that should be trusted by the front-proxy for client certificate authentication. + The secret must contain a key named `tls.crt` that holds the PEM encoded CA certificate(s). + This CA bundle will be merged with the root shard's client CA, allowing the front-proxy + to accept client certificates signed by either CA. This is useful for backwards compatibility + during upgrades when migrating from a separate front-proxy client CA to the shared root shard client CA. + properties: + 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 + type: object + x-kubernetes-map-type: atomic deploymentTemplate: description: 'Optional: DeploymentTemplate configures the Kubernetes Deployment created for this shard.' diff --git a/config/crd/bases/operator.kcp.io_rootshards.yaml b/config/crd/bases/operator.kcp.io_rootshards.yaml index 79250cd8..0b4da60d 100644 --- a/config/crd/bases/operator.kcp.io_rootshards.yaml +++ b/config/crd/bases/operator.kcp.io_rootshards.yaml @@ -587,6 +587,26 @@ spec: - name type: object type: object + clientCABundleRef: + description: |- + ClientCABundleRef references a v1.Secret containing an additional client CA bundle + for client certificate authentication. The secret must contain a key named `tls.crt`. + This CA bundle will be merged with the root shard's client CA. + If configured on a RootShard, this bundle is automatically inherited by all FrontProxies, + Shards, and VirtualWorkspaces connected to it. Each of those components can additionally + specify their own ClientCABundleRef, which will be merged on top. + properties: + 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 + type: object + x-kubernetes-map-type: atomic clusterDomain: description: ClusterDomain is the DNS domain for services in the cluster. Defaults to "cluster.local" if not set. diff --git a/config/crd/bases/operator.kcp.io_shards.yaml b/config/crd/bases/operator.kcp.io_shards.yaml index d7a4f054..891ee0f4 100644 --- a/config/crd/bases/operator.kcp.io_shards.yaml +++ b/config/crd/bases/operator.kcp.io_shards.yaml @@ -532,6 +532,26 @@ spec: CertificateTemplates allows to customize the properties on the generated certificates for this shard. type: object + clientCABundleRef: + description: |- + ClientCABundleRef references a v1.Secret containing an additional client CA bundle + for client certificate authentication. The secret must contain a key named `tls.crt`. + This CA bundle will be merged with the root shard's client CA. + If configured on a RootShard, this bundle is automatically inherited by all FrontProxies, + Shards, and VirtualWorkspaces connected to it. Each of those components can additionally + specify their own ClientCABundleRef, which will be merged on top. + properties: + 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 + type: object + x-kubernetes-map-type: atomic clusterDomain: description: ClusterDomain is the DNS domain for services in the cluster. Defaults to "cluster.local" if not set. diff --git a/config/crd/bases/operator.kcp.io_virtualworkspaces.yaml b/config/crd/bases/operator.kcp.io_virtualworkspaces.yaml index 583b0f67..ca84df26 100644 --- a/config/crd/bases/operator.kcp.io_virtualworkspaces.yaml +++ b/config/crd/bases/operator.kcp.io_virtualworkspaces.yaml @@ -276,6 +276,24 @@ spec: CertificateTemplates allows to customize the properties on the generated certificates for this server. type: object + clientCABundleRef: + description: |- + ClientCABundleRef references a v1.Secret containing an additional client CA bundle + for client certificate authentication. The secret must contain a key named `tls.crt`. + This CA bundle will be merged with the root shard's client CA and its ClientCABundleRef + (if configured). + properties: + 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 + type: object + x-kubernetes-map-type: atomic clusterDomain: description: ClusterDomain is the DNS domain for services in the cluster. Defaults to "cluster.local" if not set. diff --git a/docs/content/architecture/basics.md b/docs/content/architecture/basics.md index f841634b..a20bbdf3 100644 --- a/docs/content/architecture/basics.md +++ b/docs/content/architecture/basics.md @@ -40,7 +40,7 @@ For developing controllers against kcp, it is often necessary to access the shar Kubeconfigs allow the easy creation of credentials to access kcp. As a sharded system, kcp relies on client certificate authentication and the kcp-operator will ensure the correct certificates are generated and then neatly wrapped up in ready-to-use kubeconfig Secrets. -Kubeconfigs can be configured to point to a specific shard or to a front-proxy instance, which affects which client CA is used to generate the certificates. +Kubeconfigs can be configured to point to a specific shard or to a front-proxy instance. All kubeconfigs use the root shard's client CA for certificate generation, ensuring consistent authentication across the entire kcp installation. Additional client CAs can be configured via `clientCABundleRef` – see [Certificate Management](pki.md#client-ca-bundle) for details. ## Cross-Namespace/Cluster References diff --git a/docs/content/architecture/front-proxy.md b/docs/content/architecture/front-proxy.md index 1e8dd9f7..11de6196 100644 --- a/docs/content/architecture/front-proxy.md +++ b/docs/content/architecture/front-proxy.md @@ -22,6 +22,8 @@ The kcp front-proxy will build up a runtime map of all shards, workspaces and lo The proxy can optionally perform authentication, for example using OIDC, and pass authentication information (like username and groups) on to the shard (via HTTP headers). This can be configured for each front-proxy individually. If no authentication is performed, the requests will be passed unauthenticated to their target shards, where then authentication happens. +Additionally, front-proxies can be configured to accept client certificates signed by additional CAs via the `clientCABundleRef` field. This is useful when integrating with external identity systems. See [Certificate Management](pki.md#client-ca-bundle) for more details. + ## RootShard Proxy The kcp-operator will deploy an internal front-proxy for every `RootShard` (i.e. one for each kcp installation). This internal proxy is solely used by the operator itself to allow it to resolve workspace paths to logicalclusters and provision resources inside those workspaces. diff --git a/docs/content/architecture/pki.md b/docs/content/architecture/pki.md index 7091d857..a3aae465 100644 --- a/docs/content/architecture/pki.md +++ b/docs/content/architecture/pki.md @@ -14,7 +14,6 @@ graph TB C --> D(kcp-etcd-client-ca):::ca C --> E(kcp-etcd-peer-ca):::ca - C --> F($rootshard-front-proxy-client-ca):::ca C --> G($rootshard-server-ca):::ca C --> H($rootshard-requestheaer-client-ca):::ca C --> I($rootshard-client-ca):::ca @@ -22,7 +21,6 @@ graph TB D --> K([kcp-etcd-client-issuer]):::issuer E --> L([kcp-etcd-peer-issuer]):::issuer - F --> M([$rootshard-front-proxy-client-ca]):::issuer G --> N([$rootshard-server-ca]):::issuer H --> O([$rootshard-requestheader-client-ca]):::issuer I --> P([$rootshard-client-ca]):::issuer @@ -30,7 +28,6 @@ graph TB K --- K1(kcp-etcd):::cert --> K2(kcp-etcd-client):::cert L --> L1(kcp-etcd-peer):::cert - M --> M1($rootshard-$frontproxy-admin-kubeconfig):::cert N --- N1(kcp):::cert --- N2($rootshard-$frontproxy-server):::cert --> N3(kcp-virtual-workspaces):::cert O --- O1($rootshard-$frontproxy-requestheader):::cert --> O2("(kcp-front-proxy-vw-client)"):::cert P --- P1($rootshard-$frontproxy-kubeconfig):::cert --> P2(kcp-internal-admin-kubeconfig):::cert @@ -43,3 +40,71 @@ graph TB classDef ca color:#F77 classDef cert color:orange ``` + +## Client CA Bundle + +By default, all components in a kcp installation use the root shard's Client CA (`$rootshard-client-ca`) to authenticate client certificates. The kcp-operator generates this CA and uses it to sign all client certificates created via `Kubeconfig` objects. + +However, in some scenarios you may want to accept client certificates signed by additional CAs – for example, when integrating with external identity systems or when migrating from another certificate infrastructure. + +### Adding Additional Client CAs + +You can configure additional client CA bundles using the `clientCABundleRef` field on various resources. This field references a Secret containing additional CA certificates that should be trusted for client authentication. + +The inheritance model works as follows: + +| Component | Trusted Client CAs | +|-----------|-------------------| +| **RootShard** | Root Client CA + RootShard's `clientCABundleRef` | +| **Shard** | Root Client CA + RootShard's `clientCABundleRef` + Shard's `clientCABundleRef` | +| **FrontProxy** | Root Client CA + RootShard's `clientCABundleRef` + FrontProxy's `clientCABundleRef` | +| **VirtualWorkspace** | Root Client CA + RootShard's `clientCABundleRef` + VirtualWorkspace's `clientCABundleRef` | + +This means a `clientCABundleRef` configured on the `RootShard` automatically propagates to all shards, front-proxies, and virtual workspaces connected to it. Each of those components can additionally specify their own `clientCABundleRef` to trust even more CAs. + +### Example + +To add an additional client CA to your kcp installation: + +1. Create a Secret containing the additional CA certificate: + + ```yaml + apiVersion: v1 + kind: Secret + metadata: + name: external-client-ca + namespace: my-kcp + type: Opaque + data: + tls.crt: + ``` + +2. Reference it in your RootShard (to apply to all components): + + ```yaml + apiVersion: operator.kcp.io/v1alpha1 + kind: RootShard + metadata: + name: root + namespace: my-kcp + spec: + # ... other configuration ... + clientCABundleRef: + name: external-client-ca + ``` + + Or reference it on a specific component (e.g., a FrontProxy) to only trust it there: + + ```yaml + apiVersion: operator.kcp.io/v1alpha1 + kind: FrontProxy + metadata: + name: my-frontproxy + namespace: my-kcp + spec: + # ... other configuration ... + clientCABundleRef: + name: external-client-ca + ``` + +The Secret must contain a key named `tls.crt` with PEM-encoded CA certificate(s). Multiple certificates can be concatenated in a single PEM bundle. diff --git a/internal/controller/bundle/objects.go b/internal/controller/bundle/objects.go index 3f086848..6f07e306 100644 --- a/internal/controller/bundle/objects.go +++ b/internal/controller/bundle/objects.go @@ -70,7 +70,6 @@ func getBundleObjectsForShard(shard *operatorv1alpha1.Shard, rootShardName strin objects := []operatorv1alpha1.BundleObject{ // CA certificates from RootShard (shared) - {GVR: secretGVR, Name: fmt.Sprintf("%s-front-proxy-client-ca", rootShardName), Namespace: namespace}, {GVR: secretGVR, Name: fmt.Sprintf("%s-requestheader-client-ca", rootShardName), Namespace: namespace}, {GVR: secretGVR, Name: fmt.Sprintf("%s-server-ca", rootShardName), Namespace: namespace}, {GVR: secretGVR, Name: fmt.Sprintf("%s-ca", rootShardName), Namespace: namespace}, @@ -128,7 +127,6 @@ func getBundleObjectsForRootShard(rootShard *operatorv1alpha1.RootShard) []opera {GVR: secretGVR, Name: fmt.Sprintf("%s-requestheader-client-ca", rootShardName), Namespace: namespace}, {GVR: secretGVR, Name: fmt.Sprintf("%s-client-ca", rootShardName), Namespace: namespace}, {GVR: secretGVR, Name: fmt.Sprintf("%s-service-account-ca", rootShardName), Namespace: namespace}, - {GVR: secretGVR, Name: fmt.Sprintf("%s-front-proxy-client-ca", rootShardName), Namespace: namespace}, // RootShard certificates {GVR: secretGVR, Name: fmt.Sprintf("%s-server", rootShardName), Namespace: namespace}, @@ -181,7 +179,6 @@ func getBundleObjectsForFrontProxy(frontProxy *operatorv1alpha1.FrontProxy, root return []operatorv1alpha1.BundleObject{ // CA certificates from RootShard (shared) - {GVR: secretGVR, Name: fmt.Sprintf("%s-front-proxy-client-ca", rootShardName), Namespace: namespace}, {GVR: secretGVR, Name: fmt.Sprintf("%s-requestheader-client-ca", rootShardName), Namespace: namespace}, {GVR: secretGVR, Name: fmt.Sprintf("%s-server-ca", rootShardName), Namespace: namespace}, {GVR: secretGVR, Name: fmt.Sprintf("%s-ca", rootShardName), Namespace: namespace}, diff --git a/internal/controller/frontproxy/controller_test.go b/internal/controller/frontproxy/controller_test.go index ba1ce5ca..12c3aee9 100644 --- a/internal/controller/frontproxy/controller_test.go +++ b/internal/controller/frontproxy/controller_test.go @@ -117,31 +117,265 @@ func TestReconciling(t *testing.T) { for _, testcase := range testcases { t.Run(testcase.name, func(t *testing.T) { - // The merged client CA reconciler fetches FrontProxyClientCA and ClientCA. - frontProxyClientCASecret := &corev1.Secret{ + // The merged client CA reconciler fetches ClientCA. + clientCASecret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ - Name: testcase.rootShard.Name + "-front-proxy-client-ca", + Name: testcase.rootShard.Name + "-client-ca", Namespace: namespace, }, Data: map[string][]byte{ - "tls.crt": []byte("front-proxy-client-ca-cert"), + "tls.crt": []byte("client-ca-cert"), }, } + + client := ctrlruntimefakeclient. + NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(testcase.rootShard, testcase.frontProxy). + WithObjects(testcase.rootShard, testcase.frontProxy, clientCASecret). + Build() + + ctx := context.Background() + + controllerReconciler := &FrontProxyReconciler{ + Client: client, + Scheme: client.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: ctrlruntimeclient.ObjectKeyFromObject(testcase.frontProxy), + }) + require.NoError(t, err) + }) + } +} + +func TestClientCABundleMerging(t *testing.T) { + const namespace = "frontproxy-ca-tests" + + testcases := []struct { + name string + rootShard *operatorv1alpha1.RootShard + frontProxy *operatorv1alpha1.FrontProxy + extraSecrets []*corev1.Secret + expectedMergedContents []string + }{ + { + name: "without any clientCABundleRef merged secret contains only ClientCA", + rootShard: &operatorv1alpha1.RootShard{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rooty-no-bundle", + Namespace: namespace, + }, + Spec: operatorv1alpha1.RootShardSpec{ + External: operatorv1alpha1.ExternalConfig{ + Hostname: "example.kcp.io", + Port: 6443, + }, + CommonShardSpec: operatorv1alpha1.CommonShardSpec{ + Etcd: operatorv1alpha1.EtcdConfig{ + Endpoints: []string{"https://localhost:2379"}, + }, + }, + }, + }, + frontProxy: &operatorv1alpha1.FrontProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "fronty-no-bundle", + Namespace: namespace, + }, + Spec: operatorv1alpha1.FrontProxySpec{ + RootShard: operatorv1alpha1.RootShardConfig{ + Reference: &corev1.LocalObjectReference{ + Name: "rooty-no-bundle", + }, + }, + }, + }, + expectedMergedContents: []string{"RootClientCA"}, + }, + { + name: "with rootShard clientCABundleRef only, merged secret contains ClientCA and RootShard bundle", + rootShard: &operatorv1alpha1.RootShard{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rooty-with-bundle", + Namespace: namespace, + }, + Spec: operatorv1alpha1.RootShardSpec{ + External: operatorv1alpha1.ExternalConfig{ + Hostname: "example.kcp.io", + Port: 6443, + }, + CommonShardSpec: operatorv1alpha1.CommonShardSpec{ + Etcd: operatorv1alpha1.EtcdConfig{ + Endpoints: []string{"https://localhost:2379"}, + }, + ClientCABundleRef: &corev1.LocalObjectReference{ + Name: "rootshard-extra-ca", + }, + }, + }, + }, + frontProxy: &operatorv1alpha1.FrontProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "fronty-inherits", + Namespace: namespace, + }, + Spec: operatorv1alpha1.FrontProxySpec{ + RootShard: operatorv1alpha1.RootShardConfig{ + Reference: &corev1.LocalObjectReference{ + Name: "rooty-with-bundle", + }, + }, + }, + }, + extraSecrets: []*corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "rootshard-extra-ca", + Namespace: namespace, + }, + Data: map[string][]byte{ + "tls.crt": []byte("-----BEGIN CERTIFICATE-----\nRootShardExtraCA\n-----END CERTIFICATE-----"), + }, + }, + }, + expectedMergedContents: []string{"RootClientCA", "RootShardExtraCA"}, + }, + { + name: "with frontProxy clientCABundleRef only, merged secret contains ClientCA and FrontProxy bundle", + rootShard: &operatorv1alpha1.RootShard{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rooty-plain", + Namespace: namespace, + }, + Spec: operatorv1alpha1.RootShardSpec{ + External: operatorv1alpha1.ExternalConfig{ + Hostname: "example.kcp.io", + Port: 6443, + }, + CommonShardSpec: operatorv1alpha1.CommonShardSpec{ + Etcd: operatorv1alpha1.EtcdConfig{ + Endpoints: []string{"https://localhost:2379"}, + }, + }, + }, + }, + frontProxy: &operatorv1alpha1.FrontProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "fronty-own-bundle", + Namespace: namespace, + }, + Spec: operatorv1alpha1.FrontProxySpec{ + RootShard: operatorv1alpha1.RootShardConfig{ + Reference: &corev1.LocalObjectReference{ + Name: "rooty-plain", + }, + }, + ClientCABundleRef: &corev1.LocalObjectReference{ + Name: "frontproxy-extra-ca", + }, + }, + }, + extraSecrets: []*corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "frontproxy-extra-ca", + Namespace: namespace, + }, + Data: map[string][]byte{ + "tls.crt": []byte("-----BEGIN CERTIFICATE-----\nFrontProxyExtraCA\n-----END CERTIFICATE-----"), + }, + }, + }, + expectedMergedContents: []string{"RootClientCA", "FrontProxyExtraCA"}, + }, + { + name: "with both rootShard and frontProxy clientCABundleRef, merged secret contains all three CAs", + rootShard: &operatorv1alpha1.RootShard{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rooty-both", + Namespace: namespace, + }, + Spec: operatorv1alpha1.RootShardSpec{ + External: operatorv1alpha1.ExternalConfig{ + Hostname: "example.kcp.io", + Port: 6443, + }, + CommonShardSpec: operatorv1alpha1.CommonShardSpec{ + Etcd: operatorv1alpha1.EtcdConfig{ + Endpoints: []string{"https://localhost:2379"}, + }, + ClientCABundleRef: &corev1.LocalObjectReference{ + Name: "rootshard-ca-both", + }, + }, + }, + }, + frontProxy: &operatorv1alpha1.FrontProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "fronty-both", + Namespace: namespace, + }, + Spec: operatorv1alpha1.FrontProxySpec{ + RootShard: operatorv1alpha1.RootShardConfig{ + Reference: &corev1.LocalObjectReference{ + Name: "rooty-both", + }, + }, + ClientCABundleRef: &corev1.LocalObjectReference{ + Name: "frontproxy-ca-both", + }, + }, + }, + extraSecrets: []*corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "rootshard-ca-both", + Namespace: namespace, + }, + Data: map[string][]byte{ + "tls.crt": []byte("-----BEGIN CERTIFICATE-----\nRootShardExtraCA\n-----END CERTIFICATE-----"), + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "frontproxy-ca-both", + Namespace: namespace, + }, + Data: map[string][]byte{ + "tls.crt": []byte("-----BEGIN CERTIFICATE-----\nFrontProxyExtraCA\n-----END CERTIFICATE-----"), + }, + }, + }, + expectedMergedContents: []string{"RootClientCA", "RootShardExtraCA", "FrontProxyExtraCA"}, + }, + } + + scheme := util.GetTestScheme() + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { clientCASecret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ - Name: testcase.rootShard.Name + "-client-ca", + Name: tc.rootShard.Name + "-client-ca", Namespace: namespace, }, Data: map[string][]byte{ - "tls.crt": []byte("client-ca-cert"), + "tls.crt": []byte("-----BEGIN CERTIFICATE-----\nRootClientCA\n-----END CERTIFICATE-----"), }, } + objects := []ctrlruntimeclient.Object{tc.rootShard, tc.frontProxy, clientCASecret} + for _, s := range tc.extraSecrets { + objects = append(objects, s) + } + client := ctrlruntimefakeclient. NewClientBuilder(). WithScheme(scheme). - WithStatusSubresource(testcase.rootShard, testcase.frontProxy). - WithObjects(testcase.rootShard, testcase.frontProxy, frontProxyClientCASecret, clientCASecret). + WithStatusSubresource(tc.rootShard, tc.frontProxy). + WithObjects(objects...). Build() ctx := context.Background() @@ -152,9 +386,25 @@ func TestReconciling(t *testing.T) { } _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ - NamespacedName: ctrlruntimeclient.ObjectKeyFromObject(testcase.frontProxy), + NamespacedName: ctrlruntimeclient.ObjectKeyFromObject(tc.frontProxy), }) require.NoError(t, err) + + // The merged client CA secret is always created for FrontProxy + mergedSecret := &corev1.Secret{} + mergedSecretName := tc.frontProxy.Name + "-merged-client-ca" + err = client.Get(ctx, ctrlruntimeclient.ObjectKey{ + Name: mergedSecretName, + Namespace: namespace, + }, mergedSecret) + require.NoError(t, err, "merged client CA secret should exist") + require.NotNil(t, mergedSecret.Data["tls.crt"], "merged secret should contain tls.crt") + + mergedData := string(mergedSecret.Data["tls.crt"]) + for _, expected := range tc.expectedMergedContents { + require.Contains(t, mergedData, expected, + "merged CA should contain %s", expected) + } }) } } diff --git a/internal/controller/kubeconfig/controller.go b/internal/controller/kubeconfig/controller.go index 3eb37fc9..ab745783 100644 --- a/internal/controller/kubeconfig/controller.go +++ b/internal/controller/kubeconfig/controller.go @@ -226,7 +226,7 @@ func (r *KubeconfigReconciler) reconcile(ctx context.Context, kc *operatorv1alph return conditions, err } - clientCertIssuer = resources.GetRootShardCAName(rootShard, operatorv1alpha1.FrontProxyClientCA) + clientCertIssuer = resources.GetRootShardCAName(rootShard, operatorv1alpha1.ClientCA) serverCA = resources.GetRootShardCAName(rootShard, operatorv1alpha1.ServerCA) if frontProxy.Spec.CABundleSecretRef != nil { diff --git a/internal/controller/rootshard/controller.go b/internal/controller/rootshard/controller.go index 9efce18d..a97bf86a 100644 --- a/internal/controller/rootshard/controller.go +++ b/internal/controller/rootshard/controller.go @@ -194,7 +194,6 @@ func (r *RootShardReconciler) reconcile(ctx context.Context, rootShard *operator operatorv1alpha1.RequestHeaderClientCA, operatorv1alpha1.ClientCA, operatorv1alpha1.ServiceAccountCA, - operatorv1alpha1.FrontProxyClientCA, } for _, ca := range intermediateCAs { @@ -221,6 +220,14 @@ func (r *RootShardReconciler) reconcile(ctx context.Context, rootShard *operator } } + if rootShard.Spec.ClientCABundleRef != nil { + if err := k8creconciling.ReconcileSecrets(ctx, []k8creconciling.NamedSecretReconcilerFactory{ + rootshard.MergedClientCABundleSecretReconciler(ctx, rootShard, r.Client), + }, rootShard.Namespace, r.Client, ownerRefWrapper); err != nil { + errs = append(errs, err) + } + } + if err := k8creconciling.ReconcileSecrets(ctx, []k8creconciling.NamedSecretReconcilerFactory{ rootshard.LogicalClusterAdminKubeconfigReconciler(rootShard), rootshard.ExternalLogicalClusterAdminKubeconfigReconciler(rootShard), diff --git a/internal/controller/rootshard/controller_test.go b/internal/controller/rootshard/controller_test.go index 1f704ae5..27222b5d 100644 --- a/internal/controller/rootshard/controller_test.go +++ b/internal/controller/rootshard/controller_test.go @@ -90,31 +90,132 @@ func TestReconciling(t *testing.T) { for _, testcase := range testcases { t.Run(testcase.name, func(t *testing.T) { - // The merged client CA reconciler fetches FrontProxyClientCA and ClientCA. - frontProxyClientCASecret := &corev1.Secret{ + // The merged client CA reconciler fetches ClientCA. + clientCASecret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ - Name: testcase.rootShard.Name + "-front-proxy-client-ca", + Name: testcase.rootShard.Name + "-client-ca", Namespace: namespace, }, Data: map[string][]byte{ - "tls.crt": []byte("front-proxy-client-ca-cert"), + "tls.crt": []byte("client-ca-cert"), }, } + + client := ctrlruntimefakeclient. + NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(testcase.rootShard). + WithObjects(testcase.rootShard, clientCASecret). + Build() + + ctx := context.Background() + + controllerReconciler := &RootShardReconciler{ + Client: client, + Scheme: client.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: ctrlruntimeclient.ObjectKeyFromObject(testcase.rootShard), + }) + require.NoError(t, err) + }) + } +} + +func TestClientCABundleMerging(t *testing.T) { + const namespace = "rootshard-ca-tests" + + testcases := []struct { + name string + rootShard *operatorv1alpha1.RootShard + extraSecrets []*corev1.Secret + expectMergedSecret bool + expectedMergedContents []string + }{ + { + name: "without clientCABundleRef no merged secret is created", + rootShard: &operatorv1alpha1.RootShard{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rooty-no-bundle", + Namespace: namespace, + }, + Spec: operatorv1alpha1.RootShardSpec{ + External: operatorv1alpha1.ExternalConfig{ + Hostname: "example.kcp.io", + Port: 6443, + }, + CommonShardSpec: operatorv1alpha1.CommonShardSpec{ + Etcd: operatorv1alpha1.EtcdConfig{ + Endpoints: []string{"https://localhost:2379"}, + }, + }, + }, + }, + expectMergedSecret: false, + }, + { + name: "with clientCABundleRef merged secret contains both CAs", + rootShard: &operatorv1alpha1.RootShard{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rooty-with-bundle", + Namespace: namespace, + }, + Spec: operatorv1alpha1.RootShardSpec{ + External: operatorv1alpha1.ExternalConfig{ + Hostname: "example.kcp.io", + Port: 6443, + }, + CommonShardSpec: operatorv1alpha1.CommonShardSpec{ + Etcd: operatorv1alpha1.EtcdConfig{ + Endpoints: []string{"https://localhost:2379"}, + }, + ClientCABundleRef: &corev1.LocalObjectReference{ + Name: "extra-client-ca", + }, + }, + }, + }, + extraSecrets: []*corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "extra-client-ca", + Namespace: namespace, + }, + Data: map[string][]byte{ + "tls.crt": []byte("-----BEGIN CERTIFICATE-----\nExtraClientCA\n-----END CERTIFICATE-----"), + }, + }, + }, + expectMergedSecret: true, + expectedMergedContents: []string{"RootClientCA", "ExtraClientCA"}, + }, + } + + scheme := util.GetTestScheme() + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { clientCASecret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ - Name: testcase.rootShard.Name + "-client-ca", + Name: tc.rootShard.Name + "-client-ca", Namespace: namespace, }, Data: map[string][]byte{ - "tls.crt": []byte("client-ca-cert"), + "tls.crt": []byte("-----BEGIN CERTIFICATE-----\nRootClientCA\n-----END CERTIFICATE-----"), }, } + objects := []ctrlruntimeclient.Object{tc.rootShard, clientCASecret} + for _, s := range tc.extraSecrets { + objects = append(objects, s) + } + client := ctrlruntimefakeclient. NewClientBuilder(). WithScheme(scheme). - WithStatusSubresource(testcase.rootShard). - WithObjects(testcase.rootShard, frontProxyClientCASecret, clientCASecret). + WithStatusSubresource(tc.rootShard). + WithObjects(objects...). Build() ctx := context.Background() @@ -125,9 +226,30 @@ func TestReconciling(t *testing.T) { } _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ - NamespacedName: ctrlruntimeclient.ObjectKeyFromObject(testcase.rootShard), + NamespacedName: ctrlruntimeclient.ObjectKeyFromObject(tc.rootShard), }) require.NoError(t, err) + + // Check if merged secret exists + mergedSecret := &corev1.Secret{} + mergedSecretName := tc.rootShard.Name + "-merged-client-ca" + err = client.Get(ctx, ctrlruntimeclient.ObjectKey{ + Name: mergedSecretName, + Namespace: namespace, + }, mergedSecret) + + if tc.expectMergedSecret { + require.NoError(t, err, "merged client CA secret should exist") + require.NotNil(t, mergedSecret.Data["tls.crt"], "merged secret should contain tls.crt") + + mergedData := string(mergedSecret.Data["tls.crt"]) + for _, expected := range tc.expectedMergedContents { + require.Contains(t, mergedData, expected, + "merged CA should contain %s", expected) + } + } else { + require.Error(t, err, "merged client CA secret should not exist when clientCABundleRef is not set") + } }) } } diff --git a/internal/controller/shard/controller.go b/internal/controller/shard/controller.go index 2dd0d642..2e68ad4d 100644 --- a/internal/controller/shard/controller.go +++ b/internal/controller/shard/controller.go @@ -213,6 +213,14 @@ func (r *ShardReconciler) reconcile(ctx context.Context, s *operatorv1alpha1.Sha } } + if rootShard.Spec.ClientCABundleRef != nil || s.Spec.ClientCABundleRef != nil { + if err := k8creconciling.ReconcileSecrets(ctx, []k8creconciling.NamedSecretReconcilerFactory{ + shard.MergedClientCABundleSecretReconciler(ctx, s, rootShard, r.Client), + }, s.Namespace, r.Client, ownerRefWrapper); err != nil { + errs = append(errs, err) + } + } + // to correctly configure the cache settings, we need to find the (optional) external // kcp virtual workspace var kcpVW *operatorv1alpha1.VirtualWorkspace diff --git a/internal/controller/shard/controller_test.go b/internal/controller/shard/controller_test.go index 0f6063a0..9f1955e6 100644 --- a/internal/controller/shard/controller_test.go +++ b/internal/controller/shard/controller_test.go @@ -287,3 +287,302 @@ func TestReconciling(t *testing.T) { }) } } + +func TestClientCABundleMerging(t *testing.T) { + const namespace = "shard-ca-tests" + + testcases := []struct { + name string + rootShard *operatorv1alpha1.RootShard + shard *operatorv1alpha1.Shard + extraSecrets []*corev1.Secret + expectMergedSecret bool + expectedMergedContents []string + }{ + { + name: "without any clientCABundleRef no merged secret is created", + rootShard: &operatorv1alpha1.RootShard{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rooty-no-bundle", + Namespace: namespace, + }, + Spec: operatorv1alpha1.RootShardSpec{ + External: operatorv1alpha1.ExternalConfig{ + Hostname: "example.kcp.io", + Port: 6443, + }, + CommonShardSpec: operatorv1alpha1.CommonShardSpec{ + Etcd: operatorv1alpha1.EtcdConfig{ + Endpoints: []string{"https://localhost:2379"}, + }, + }, + }, + }, + shard: &operatorv1alpha1.Shard{ + ObjectMeta: metav1.ObjectMeta{ + Name: "shardy-no-bundle", + Namespace: namespace, + }, + Spec: operatorv1alpha1.ShardSpec{ + CommonShardSpec: operatorv1alpha1.CommonShardSpec{ + Etcd: operatorv1alpha1.EtcdConfig{ + Endpoints: []string{"https://localhost:2379"}, + }, + }, + RootShard: operatorv1alpha1.RootShardConfig{ + Reference: &corev1.LocalObjectReference{ + Name: "rooty-no-bundle", + }, + }, + }, + }, + expectMergedSecret: false, + }, + { + name: "with rootShard clientCABundleRef only, merged secret contains ClientCA and RootShard bundle", + rootShard: &operatorv1alpha1.RootShard{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rooty-with-bundle", + Namespace: namespace, + }, + Spec: operatorv1alpha1.RootShardSpec{ + External: operatorv1alpha1.ExternalConfig{ + Hostname: "example.kcp.io", + Port: 6443, + }, + CommonShardSpec: operatorv1alpha1.CommonShardSpec{ + Etcd: operatorv1alpha1.EtcdConfig{ + Endpoints: []string{"https://localhost:2379"}, + }, + ClientCABundleRef: &corev1.LocalObjectReference{ + Name: "rootshard-extra-ca", + }, + }, + }, + }, + shard: &operatorv1alpha1.Shard{ + ObjectMeta: metav1.ObjectMeta{ + Name: "shardy-inherits", + Namespace: namespace, + }, + Spec: operatorv1alpha1.ShardSpec{ + CommonShardSpec: operatorv1alpha1.CommonShardSpec{ + Etcd: operatorv1alpha1.EtcdConfig{ + Endpoints: []string{"https://localhost:2379"}, + }, + }, + RootShard: operatorv1alpha1.RootShardConfig{ + Reference: &corev1.LocalObjectReference{ + Name: "rooty-with-bundle", + }, + }, + }, + }, + extraSecrets: []*corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "rootshard-extra-ca", + Namespace: namespace, + }, + Data: map[string][]byte{ + "tls.crt": []byte("-----BEGIN CERTIFICATE-----\nRootShardExtraCA\n-----END CERTIFICATE-----"), + }, + }, + }, + expectMergedSecret: true, + expectedMergedContents: []string{"RootClientCA", "RootShardExtraCA"}, + }, + { + name: "with shard clientCABundleRef only, merged secret contains ClientCA and Shard bundle", + rootShard: &operatorv1alpha1.RootShard{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rooty-plain", + Namespace: namespace, + }, + Spec: operatorv1alpha1.RootShardSpec{ + External: operatorv1alpha1.ExternalConfig{ + Hostname: "example.kcp.io", + Port: 6443, + }, + CommonShardSpec: operatorv1alpha1.CommonShardSpec{ + Etcd: operatorv1alpha1.EtcdConfig{ + Endpoints: []string{"https://localhost:2379"}, + }, + }, + }, + }, + shard: &operatorv1alpha1.Shard{ + ObjectMeta: metav1.ObjectMeta{ + Name: "shardy-own-bundle", + Namespace: namespace, + }, + Spec: operatorv1alpha1.ShardSpec{ + CommonShardSpec: operatorv1alpha1.CommonShardSpec{ + Etcd: operatorv1alpha1.EtcdConfig{ + Endpoints: []string{"https://localhost:2379"}, + }, + ClientCABundleRef: &corev1.LocalObjectReference{ + Name: "shard-extra-ca", + }, + }, + RootShard: operatorv1alpha1.RootShardConfig{ + Reference: &corev1.LocalObjectReference{ + Name: "rooty-plain", + }, + }, + }, + }, + extraSecrets: []*corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "shard-extra-ca", + Namespace: namespace, + }, + Data: map[string][]byte{ + "tls.crt": []byte("-----BEGIN CERTIFICATE-----\nShardExtraCA\n-----END CERTIFICATE-----"), + }, + }, + }, + expectMergedSecret: true, + expectedMergedContents: []string{"RootClientCA", "ShardExtraCA"}, + }, + { + name: "with both rootShard and shard clientCABundleRef, merged secret contains all three CAs", + rootShard: &operatorv1alpha1.RootShard{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rooty-both", + Namespace: namespace, + }, + Spec: operatorv1alpha1.RootShardSpec{ + External: operatorv1alpha1.ExternalConfig{ + Hostname: "example.kcp.io", + Port: 6443, + }, + CommonShardSpec: operatorv1alpha1.CommonShardSpec{ + Etcd: operatorv1alpha1.EtcdConfig{ + Endpoints: []string{"https://localhost:2379"}, + }, + ClientCABundleRef: &corev1.LocalObjectReference{ + Name: "rootshard-ca-both", + }, + }, + }, + }, + shard: &operatorv1alpha1.Shard{ + ObjectMeta: metav1.ObjectMeta{ + Name: "shardy-both", + Namespace: namespace, + }, + Spec: operatorv1alpha1.ShardSpec{ + CommonShardSpec: operatorv1alpha1.CommonShardSpec{ + Etcd: operatorv1alpha1.EtcdConfig{ + Endpoints: []string{"https://localhost:2379"}, + }, + ClientCABundleRef: &corev1.LocalObjectReference{ + Name: "shard-ca-both", + }, + }, + RootShard: operatorv1alpha1.RootShardConfig{ + Reference: &corev1.LocalObjectReference{ + Name: "rooty-both", + }, + }, + }, + }, + extraSecrets: []*corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "rootshard-ca-both", + Namespace: namespace, + }, + Data: map[string][]byte{ + "tls.crt": []byte("-----BEGIN CERTIFICATE-----\nRootShardExtraCA\n-----END CERTIFICATE-----"), + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "shard-ca-both", + Namespace: namespace, + }, + Data: map[string][]byte{ + "tls.crt": []byte("-----BEGIN CERTIFICATE-----\nShardExtraCA\n-----END CERTIFICATE-----"), + }, + }, + }, + expectMergedSecret: true, + expectedMergedContents: []string{"RootClientCA", "RootShardExtraCA", "ShardExtraCA"}, + }, + } + + scheme := runtime.NewScheme() + require.Nil(t, corev1.AddToScheme(scheme)) + require.Nil(t, appsv1.AddToScheme(scheme)) + require.Nil(t, operatorv1alpha1.AddToScheme(scheme)) + require.Nil(t, certmanagerv1.AddToScheme(scheme)) + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + clientCASecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: tc.rootShard.Name + "-client-ca", + Namespace: namespace, + }, + Data: map[string][]byte{ + "tls.crt": []byte("-----BEGIN CERTIFICATE-----\nRootClientCA\n-----END CERTIFICATE-----"), + }, + } + + objects := []ctrlruntimeclient.Object{tc.rootShard, tc.shard, clientCASecret} + for _, s := range tc.extraSecrets { + objects = append(objects, s) + } + + client := ctrlruntimefakeclient. + NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(tc.rootShard, tc.shard). + WithObjects(objects...). + Build() + + ctx := context.Background() + + controllerReconciler := &ShardReconciler{ + Client: client, + Scheme: client.Scheme(), + } + + // First reconcile adds finalizer + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: ctrlruntimeclient.ObjectKeyFromObject(tc.shard), + }) + require.NoError(t, err) + + // Second reconcile performs actual reconciliation + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: ctrlruntimeclient.ObjectKeyFromObject(tc.shard), + }) + require.NoError(t, err) + + // Check if merged secret exists + mergedSecret := &corev1.Secret{} + mergedSecretName := tc.shard.Name + "-merged-client-ca" + err = client.Get(ctx, ctrlruntimeclient.ObjectKey{ + Name: mergedSecretName, + Namespace: namespace, + }, mergedSecret) + + if tc.expectMergedSecret { + require.NoError(t, err, "merged client CA secret should exist") + require.NotNil(t, mergedSecret.Data["tls.crt"], "merged secret should contain tls.crt") + + mergedData := string(mergedSecret.Data["tls.crt"]) + for _, expected := range tc.expectedMergedContents { + require.Contains(t, mergedData, expected, + "merged CA should contain %s", expected) + } + } else { + require.Error(t, err, "merged client CA secret should not exist when no clientCABundleRef is set") + } + }) + } +} diff --git a/internal/controller/virtualworkspace/controller.go b/internal/controller/virtualworkspace/controller.go index 2e26402a..42b24242 100644 --- a/internal/controller/virtualworkspace/controller.go +++ b/internal/controller/virtualworkspace/controller.go @@ -212,6 +212,14 @@ func (r *Reconciler) reconcile(ctx context.Context, vw *operatorv1alpha1.Virtual return conditions, err } + if rootShard.Spec.ClientCABundleRef != nil || vw.Spec.ClientCABundleRef != nil { + if err := k8creconciling.ReconcileSecrets(ctx, []k8creconciling.NamedSecretReconcilerFactory{ + virtualworkspace.MergedClientCABundleSecretReconciler(ctx, vw, rootShard, r.Client), + }, vw.Namespace, r.Client, ownerRefWrapper); err != nil { + return conditions, err + } + } + if err := k8creconciling.ReconcileDeployments(ctx, []k8creconciling.NamedDeploymentReconcilerFactory{ virtualworkspace.DeploymentReconciler(vw, rootShard, shard), }, vw.Namespace, r.Client, ownerRefWrapper, revisionLabels); err != nil { diff --git a/internal/controller/virtualworkspace/controller_test.go b/internal/controller/virtualworkspace/controller_test.go index 796a2c76..3e92abdc 100644 --- a/internal/controller/virtualworkspace/controller_test.go +++ b/internal/controller/virtualworkspace/controller_test.go @@ -105,3 +105,287 @@ func TestReconciling(t *testing.T) { }) } } + +func TestClientCABundleMerging(t *testing.T) { + const namespace = "vw-ca-tests" + + testcases := []struct { + name string + rootShard *operatorv1alpha1.RootShard + virtualWorkspace *operatorv1alpha1.VirtualWorkspace + extraSecrets []*corev1.Secret + expectMergedSecret bool + expectedMergedContents []string + }{ + { + name: "without any clientCABundleRef no merged secret is created", + rootShard: &operatorv1alpha1.RootShard{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rooty-no-bundle", + Namespace: namespace, + }, + Spec: operatorv1alpha1.RootShardSpec{ + External: operatorv1alpha1.ExternalConfig{ + Hostname: "example.kcp.io", + Port: 6443, + }, + CommonShardSpec: operatorv1alpha1.CommonShardSpec{ + Etcd: operatorv1alpha1.EtcdConfig{ + Endpoints: []string{"https://localhost:2379"}, + }, + }, + }, + }, + virtualWorkspace: &operatorv1alpha1.VirtualWorkspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "vw-no-bundle", + Namespace: namespace, + }, + Spec: operatorv1alpha1.VirtualWorkspaceSpec{ + External: operatorv1alpha1.ExternalConfig{ + Hostname: "vw.example.com", + Port: 6443, + }, + Target: operatorv1alpha1.VirtualWorkspaceTarget{ + RootShardRef: &corev1.LocalObjectReference{ + Name: "rooty-no-bundle", + }, + }, + }, + }, + expectMergedSecret: false, + }, + { + name: "with rootShard clientCABundleRef only, merged secret contains ClientCA and RootShard bundle", + rootShard: &operatorv1alpha1.RootShard{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rooty-with-bundle", + Namespace: namespace, + }, + Spec: operatorv1alpha1.RootShardSpec{ + External: operatorv1alpha1.ExternalConfig{ + Hostname: "example.kcp.io", + Port: 6443, + }, + CommonShardSpec: operatorv1alpha1.CommonShardSpec{ + Etcd: operatorv1alpha1.EtcdConfig{ + Endpoints: []string{"https://localhost:2379"}, + }, + ClientCABundleRef: &corev1.LocalObjectReference{ + Name: "rootshard-extra-ca", + }, + }, + }, + }, + virtualWorkspace: &operatorv1alpha1.VirtualWorkspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "vw-inherits", + Namespace: namespace, + }, + Spec: operatorv1alpha1.VirtualWorkspaceSpec{ + External: operatorv1alpha1.ExternalConfig{ + Hostname: "vw.example.com", + Port: 6443, + }, + Target: operatorv1alpha1.VirtualWorkspaceTarget{ + RootShardRef: &corev1.LocalObjectReference{ + Name: "rooty-with-bundle", + }, + }, + }, + }, + extraSecrets: []*corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "rootshard-extra-ca", + Namespace: namespace, + }, + Data: map[string][]byte{ + "tls.crt": []byte("-----BEGIN CERTIFICATE-----\nRootShardExtraCA\n-----END CERTIFICATE-----"), + }, + }, + }, + expectMergedSecret: true, + expectedMergedContents: []string{"RootClientCA", "RootShardExtraCA"}, + }, + { + name: "with virtualWorkspace clientCABundleRef only, merged secret contains ClientCA and VW bundle", + rootShard: &operatorv1alpha1.RootShard{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rooty-plain", + Namespace: namespace, + }, + Spec: operatorv1alpha1.RootShardSpec{ + External: operatorv1alpha1.ExternalConfig{ + Hostname: "example.kcp.io", + Port: 6443, + }, + CommonShardSpec: operatorv1alpha1.CommonShardSpec{ + Etcd: operatorv1alpha1.EtcdConfig{ + Endpoints: []string{"https://localhost:2379"}, + }, + }, + }, + }, + virtualWorkspace: &operatorv1alpha1.VirtualWorkspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "vw-own-bundle", + Namespace: namespace, + }, + Spec: operatorv1alpha1.VirtualWorkspaceSpec{ + External: operatorv1alpha1.ExternalConfig{ + Hostname: "vw.example.com", + Port: 6443, + }, + Target: operatorv1alpha1.VirtualWorkspaceTarget{ + RootShardRef: &corev1.LocalObjectReference{ + Name: "rooty-plain", + }, + }, + ClientCABundleRef: &corev1.LocalObjectReference{ + Name: "vw-extra-ca", + }, + }, + }, + extraSecrets: []*corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "vw-extra-ca", + Namespace: namespace, + }, + Data: map[string][]byte{ + "tls.crt": []byte("-----BEGIN CERTIFICATE-----\nVWExtraCA\n-----END CERTIFICATE-----"), + }, + }, + }, + expectMergedSecret: true, + expectedMergedContents: []string{"RootClientCA", "VWExtraCA"}, + }, + { + name: "with both rootShard and virtualWorkspace clientCABundleRef, merged secret contains all three CAs", + rootShard: &operatorv1alpha1.RootShard{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rooty-both", + Namespace: namespace, + }, + Spec: operatorv1alpha1.RootShardSpec{ + External: operatorv1alpha1.ExternalConfig{ + Hostname: "example.kcp.io", + Port: 6443, + }, + CommonShardSpec: operatorv1alpha1.CommonShardSpec{ + Etcd: operatorv1alpha1.EtcdConfig{ + Endpoints: []string{"https://localhost:2379"}, + }, + ClientCABundleRef: &corev1.LocalObjectReference{ + Name: "rootshard-ca-both", + }, + }, + }, + }, + virtualWorkspace: &operatorv1alpha1.VirtualWorkspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "vw-both", + Namespace: namespace, + }, + Spec: operatorv1alpha1.VirtualWorkspaceSpec{ + External: operatorv1alpha1.ExternalConfig{ + Hostname: "vw.example.com", + Port: 6443, + }, + Target: operatorv1alpha1.VirtualWorkspaceTarget{ + RootShardRef: &corev1.LocalObjectReference{ + Name: "rooty-both", + }, + }, + ClientCABundleRef: &corev1.LocalObjectReference{ + Name: "vw-ca-both", + }, + }, + }, + extraSecrets: []*corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "rootshard-ca-both", + Namespace: namespace, + }, + Data: map[string][]byte{ + "tls.crt": []byte("-----BEGIN CERTIFICATE-----\nRootShardExtraCA\n-----END CERTIFICATE-----"), + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "vw-ca-both", + Namespace: namespace, + }, + Data: map[string][]byte{ + "tls.crt": []byte("-----BEGIN CERTIFICATE-----\nVWExtraCA\n-----END CERTIFICATE-----"), + }, + }, + }, + expectMergedSecret: true, + expectedMergedContents: []string{"RootClientCA", "RootShardExtraCA", "VWExtraCA"}, + }, + } + + scheme := util.GetTestScheme() + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + clientCASecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: tc.rootShard.Name + "-client-ca", + Namespace: namespace, + }, + Data: map[string][]byte{ + "tls.crt": []byte("-----BEGIN CERTIFICATE-----\nRootClientCA\n-----END CERTIFICATE-----"), + }, + } + + objects := []ctrlruntimeclient.Object{tc.rootShard, tc.virtualWorkspace, clientCASecret} + for _, s := range tc.extraSecrets { + objects = append(objects, s) + } + + client := ctrlruntimefakeclient. + NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(tc.rootShard, tc.virtualWorkspace). + WithObjects(objects...). + Build() + + ctx := context.Background() + + controllerReconciler := &Reconciler{ + Client: client, + Scheme: client.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: ctrlruntimeclient.ObjectKeyFromObject(tc.virtualWorkspace), + }) + require.NoError(t, err) + + // Check if merged secret exists + mergedSecret := &corev1.Secret{} + mergedSecretName := tc.virtualWorkspace.Name + "-merged-client-ca" + err = client.Get(ctx, ctrlruntimeclient.ObjectKey{ + Name: mergedSecretName, + Namespace: namespace, + }, mergedSecret) + + if tc.expectMergedSecret { + require.NoError(t, err, "merged client CA secret should exist") + require.NotNil(t, mergedSecret.Data["tls.crt"], "merged secret should contain tls.crt") + + mergedData := string(mergedSecret.Data["tls.crt"]) + for _, expected := range tc.expectedMergedContents { + require.Contains(t, mergedData, expected, + "merged CA should contain %s", expected) + } + } else { + require.Error(t, err, "merged client CA secret should not exist when no clientCABundleRef is set") + } + }) + } +} diff --git a/internal/resources/frontproxy/ca_bundle.go b/internal/resources/frontproxy/ca_bundle.go index ea9f907f..142ee833 100644 --- a/internal/resources/frontproxy/ca_bundle.go +++ b/internal/resources/frontproxy/ca_bundle.go @@ -17,65 +17,34 @@ limitations under the License. package frontproxy import ( - "bytes" - "context" "fmt" k8creconciling "k8c.io/reconciler/pkg/reconciling" corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/types" - ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client" "github.com/kcp-dev/kcp-operator/internal/resources" - operatorv1alpha1 "github.com/kcp-dev/kcp-operator/sdk/apis/operator/v1alpha1" + "github.com/kcp-dev/kcp-operator/internal/resources/utils" ) -func (r *reconciler) mergedClientCASecretName() string { +func (r *reconciler) clientCABundleSecretName() string { if r.frontProxy != nil { return fmt.Sprintf("%s-merged-client-ca", r.frontProxy.Name) } return fmt.Sprintf("%s-proxy-merged-client-ca", r.rootShard.Name) } -// mergedClientCASecretReconciler creates a single secret with the -// FrontProxyClientCA and shard ClientCA concatenated so that the front -// proxy accepts clients signed by either CA. -func (r *reconciler) mergedClientCASecretReconciler(ctx context.Context, kubeClient ctrlruntimeclient.Client) k8creconciling.NamedSecretReconcilerFactory { - getCA := func(caType operatorv1alpha1.CA) ([]byte, error) { - caSecret := &corev1.Secret{} - caSecretName := resources.GetRootShardCAName(r.rootShard, caType) - if err := kubeClient.Get(ctx, types.NamespacedName{ - Namespace: r.rootShard.Namespace, - Name: caSecretName, - }, caSecret); err != nil { - return nil, fmt.Errorf("failed to get %s secret %s: %w", caType, caSecretName, err) - } - - cert, ok := caSecret.Data["tls.crt"] - if !ok { - return nil, fmt.Errorf("%s secret %s missing tls.crt", caType, caSecretName) - } - return cert, nil - } - +// clientCABundleSecretReconciler creates a single secret with the +// shard ClientCA and an optional additional client CA bundle concatenated +// so that the front proxy accepts clients signed by either CA. +func (r *reconciler) clientCABundleSecretReconciler(clientCAs ...[]byte) k8creconciling.NamedSecretReconcilerFactory { return func() (string, k8creconciling.SecretReconciler) { - return r.mergedClientCASecretName(), func(secret *corev1.Secret) (*corev1.Secret, error) { + return r.clientCABundleSecretName(), func(secret *corev1.Secret) (*corev1.Secret, error) { if secret.Data == nil { secret.Data = make(map[string][]byte) } - fpClientCA, err := getCA(operatorv1alpha1.FrontProxyClientCA) - if err != nil { - return nil, fmt.Errorf("error getting front proxy client ca: %w", err) - } - - clientCA, err := getCA(operatorv1alpha1.ClientCA) - if err != nil { - return nil, fmt.Errorf("error getting client ca: %w", err) - } - - secret.Data["tls.crt"] = bytes.Join([][]byte{fpClientCA, clientCA}, []byte{'\n'}) + secret.Data["tls.crt"] = utils.MergeCertificates(clientCAs...) if secret.Labels == nil { secret.Labels = make(map[string]string) @@ -91,7 +60,7 @@ func (r *reconciler) mergedClientCASecretReconciler(ctx context.Context, kubeCli } } -func (r *reconciler) mergedCABundleSecretName() string { +func (r *reconciler) backendCABundleSecretName() string { // Validate whether called for frontProxy or rootShardFrontProxy if r.frontProxy != nil { return fmt.Sprintf("%s-merged-ca-bundle", r.frontProxy.Name) @@ -99,63 +68,14 @@ func (r *reconciler) mergedCABundleSecretName() string { return fmt.Sprintf("%s-proxy-merged-ca-bundle", r.rootShard.Name) } -func (r *reconciler) mergedCABundleSecretReconciler(ctx context.Context, kubeClient ctrlruntimeclient.Client) k8creconciling.NamedSecretReconcilerFactory { +func (r *reconciler) backendCABundleSecretReconciler(serverCACert, userCABundle []byte) k8creconciling.NamedSecretReconcilerFactory { return func() (string, k8creconciling.SecretReconciler) { - return r.mergedCABundleSecretName(), func(secret *corev1.Secret) (*corev1.Secret, error) { + return r.backendCABundleSecretName(), func(secret *corev1.Secret) (*corev1.Secret, error) { if secret.Data == nil { secret.Data = make(map[string][]byte) } - // Get ServerCA certificate from the rootshard - serverCASecret := &corev1.Secret{} - serverCASecretName := resources.GetRootShardCAName(r.rootShard, operatorv1alpha1.ServerCA) - err := kubeClient.Get(ctx, types.NamespacedName{ - Name: serverCASecretName, - Namespace: r.rootShard.Namespace, - }, serverCASecret) - if err != nil { - return nil, fmt.Errorf("failed to get ServerCA secret %s: %w", serverCASecretName, err) - } - - serverCACert, exists := serverCASecret.Data["tls.crt"] - if !exists { - return nil, fmt.Errorf("ServerCA secret %s missing tls.crt", serverCASecretName) - } - - // Get user-provided CA bundle if specified - var userCABundle []byte - caBundleRef := r.getCABundleSecretRef() - if caBundleRef != nil { - userCABundleSecret := &corev1.Secret{} - namespace := r.rootShard.Namespace - if r.frontProxy != nil { - namespace = r.frontProxy.Namespace - } - err := kubeClient.Get(ctx, types.NamespacedName{ - Name: caBundleRef.Name, - Namespace: namespace, - }, userCABundleSecret) - if err != nil { - return nil, fmt.Errorf("failed to get user CA bundle secret %s: %w", caBundleRef.Name, err) - } - - var exists bool - userCABundle, exists = userCABundleSecret.Data["tls.crt"] - if !exists { - return nil, fmt.Errorf("user CA bundle secret %s missing tls.crt", caBundleRef.Name) - } - } - - // Merge certificates: ServerCA + user CA bundle - var mergedCA []byte - if len(userCABundle) > 0 { - mergedCA = append(serverCACert, '\n') - mergedCA = append(mergedCA, userCABundle...) - } else { - mergedCA = serverCACert - } - - secret.Data["tls.crt"] = mergedCA + secret.Data["tls.crt"] = utils.MergeCertificates(serverCACert, userCABundle) // Set labels to identify this as a merged CA bundle if secret.Labels == nil { diff --git a/internal/resources/frontproxy/ca_bundle_test.go b/internal/resources/frontproxy/ca_bundle_test.go new file mode 100644 index 00000000..a4dd22ef --- /dev/null +++ b/internal/resources/frontproxy/ca_bundle_test.go @@ -0,0 +1,262 @@ +/* +Copyright 2026 The KCP Authors. + +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 frontproxy + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client" + ctrlruntimefakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake" + + operatorv1alpha1 "github.com/kcp-dev/kcp-operator/sdk/apis/operator/v1alpha1" +) + +func TestMergedClientCASecretReconciler(t *testing.T) { + tests := []struct { + name string + frontProxy *operatorv1alpha1.FrontProxy + rootShard *operatorv1alpha1.RootShard + clientCASecret *corev1.Secret + additionalClientCASecret *corev1.Secret + expectAdditionalCAInMerge bool + }{ + { + name: "without clientCABundleRef merges only ClientCA", + frontProxy: &operatorv1alpha1.FrontProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-front-proxy", + Namespace: "test-namespace", + }, + Spec: operatorv1alpha1.FrontProxySpec{ + RootShard: operatorv1alpha1.RootShardConfig{ + Reference: &corev1.LocalObjectReference{Name: "test-root-shard"}, + }, + }, + }, + rootShard: &operatorv1alpha1.RootShard{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-root-shard", + Namespace: "test-namespace", + }, + Spec: operatorv1alpha1.RootShardSpec{ + External: operatorv1alpha1.ExternalConfig{ + Hostname: "kcp.example.com", + Port: 6443, + }, + }, + }, + clientCASecret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-root-shard-client-ca", + Namespace: "test-namespace", + }, + Data: map[string][]byte{ + "tls.crt": []byte("-----BEGIN CERTIFICATE-----\nClientCA\n-----END CERTIFICATE-----"), + }, + }, + expectAdditionalCAInMerge: false, + }, + { + name: "with clientCABundleRef merges ClientCA and additional CA", + frontProxy: &operatorv1alpha1.FrontProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-front-proxy", + Namespace: "test-namespace", + }, + Spec: operatorv1alpha1.FrontProxySpec{ + RootShard: operatorv1alpha1.RootShardConfig{ + Reference: &corev1.LocalObjectReference{Name: "test-root-shard"}, + }, + ClientCABundleRef: &corev1.LocalObjectReference{ + Name: "additional-client-ca", + }, + }, + }, + rootShard: &operatorv1alpha1.RootShard{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-root-shard", + Namespace: "test-namespace", + }, + Spec: operatorv1alpha1.RootShardSpec{ + External: operatorv1alpha1.ExternalConfig{ + Hostname: "kcp.example.com", + Port: 6443, + }, + }, + }, + clientCASecret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-root-shard-client-ca", + Namespace: "test-namespace", + }, + Data: map[string][]byte{ + "tls.crt": []byte("-----BEGIN CERTIFICATE-----\nClientCA\n-----END CERTIFICATE-----"), + }, + }, + additionalClientCASecret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "additional-client-ca", + Namespace: "test-namespace", + }, + Data: map[string][]byte{ + "tls.crt": []byte("-----BEGIN CERTIFICATE-----\nAdditionalClientCA\n-----END CERTIFICATE-----"), + }, + }, + expectAdditionalCAInMerge: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, corev1.AddToScheme(scheme)) + require.NoError(t, operatorv1alpha1.AddToScheme(scheme)) + + objects := []ctrlruntimeclient.Object{ + tt.frontProxy, + tt.rootShard, + tt.clientCASecret, + } + if tt.additionalClientCASecret != nil { + objects = append(objects, tt.additionalClientCASecret) + } + + _ = ctrlruntimefakeclient.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + Build() + + rec := NewFrontProxy(tt.frontProxy, tt.rootShard) + + // Fetch the ClientCA data + clientCACert := tt.clientCASecret.Data["tls.crt"] + + // Fetch additional CA bundle data if configured + var additionalClientCABundle []byte + if tt.additionalClientCASecret != nil { + additionalClientCABundle = tt.additionalClientCASecret.Data["tls.crt"] + } + + reconcilerFactory := rec.clientCABundleSecretReconciler(clientCACert, additionalClientCABundle) + + secretName, reconciler := reconcilerFactory() + require.Equal(t, "test-front-proxy-merged-client-ca", secretName) + + mergedSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: "test-namespace", + }, + } + + result, err := reconciler(mergedSecret) + require.NoError(t, err) + require.NotNil(t, result) + + mergedCA := result.Data["tls.crt"] + require.NotEmpty(t, mergedCA) + + // Verify ClientCA is always present + require.Contains(t, string(mergedCA), "ClientCA", "Merged CA should contain ClientCA") + + // Verify additional CA is present only when configured + if tt.expectAdditionalCAInMerge { + require.Contains(t, string(mergedCA), "AdditionalClientCA", + "Merged CA should contain AdditionalClientCA when clientCABundleRef is set") + + // Verify both CAs are separated by newline + require.True(t, bytes.Contains(mergedCA, []byte("ClientCA\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nAdditionalClientCA")), + "CAs should be separated by newline") + } else { + require.NotContains(t, string(mergedCA), "AdditionalClientCA", + "Merged CA should not contain AdditionalClientCA when clientCABundleRef is not set") + } + + // Verify labels are set correctly + require.Equal(t, tt.frontProxy.Name, result.Labels["operator.kcp.io/front-proxy"]) + }) + } +} + +func TestMergedClientCASecretReconciler_RootShardProxy(t *testing.T) { + // Test the root shard internal proxy (without a FrontProxy object) + rootShard := &operatorv1alpha1.RootShard{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-root-shard", + Namespace: "test-namespace", + }, + Spec: operatorv1alpha1.RootShardSpec{ + External: operatorv1alpha1.ExternalConfig{ + Hostname: "kcp.example.com", + Port: 6443, + }, + }, + } + + clientCASecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-root-shard-client-ca", + Namespace: "test-namespace", + }, + Data: map[string][]byte{ + "tls.crt": []byte("-----BEGIN CERTIFICATE-----\nClientCA\n-----END CERTIFICATE-----"), + }, + } + + scheme := runtime.NewScheme() + require.NoError(t, corev1.AddToScheme(scheme)) + require.NoError(t, operatorv1alpha1.AddToScheme(scheme)) + + rec := NewRootShardProxy(rootShard) + + // Fetch the ClientCA data + clientCACert := clientCASecret.Data["tls.crt"] + + // Root shard proxy doesn't support additional CA bundle + reconcilerFactory := rec.clientCABundleSecretReconciler(clientCACert, nil) + + secretName, reconciler := reconcilerFactory() + require.Equal(t, "test-root-shard-proxy-merged-client-ca", secretName) + + mergedSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: "test-namespace", + }, + } + + result, err := reconciler(mergedSecret) + require.NoError(t, err) + require.NotNil(t, result) + + mergedCA := result.Data["tls.crt"] + require.NotEmpty(t, mergedCA) + require.Contains(t, string(mergedCA), "ClientCA") + + // For root shard proxy, no additional CA should be added (clientCABundleRef is only for FrontProxy) + require.Equal(t, clientCASecret.Data["tls.crt"], mergedCA, + "Root shard proxy should only contain ClientCA without any additional bundle") + + // Verify labels are set correctly + require.Equal(t, rootShard.Name, result.Labels["operator.kcp.io/rootshard"]) +} diff --git a/internal/resources/frontproxy/certificates.go b/internal/resources/frontproxy/certificates.go index e3e35374..17f6b963 100644 --- a/internal/resources/frontproxy/certificates.go +++ b/internal/resources/frontproxy/certificates.go @@ -132,51 +132,6 @@ func (r *reconciler) serverCertificateReconciler() reconciling.NamedCertificateR } } -// adminKubeconfigCertificateReconciler is only reconciled for true front-proxies. -func (r *reconciler) adminKubeconfigCertificateReconciler() reconciling.NamedCertificateReconcilerFactory { - const certKind = operatorv1alpha1.AdminKubeconfigClientCertificate - - name := r.certName(certKind) - template := r.certTemplateMap().CertificateTemplate(certKind) - - return func() (string, reconciling.CertificateReconciler) { - return name, func(cert *certmanagerv1.Certificate) (*certmanagerv1.Certificate, error) { - cert.SetLabels(r.resourceLabels) - cert.Spec = certmanagerv1.CertificateSpec{ - SecretName: name, - SecretTemplate: &certmanagerv1.CertificateSecretTemplate{ - Labels: r.certSecretLabels(), - }, - Duration: &operatorv1alpha1.DefaultCertificateDuration, - RenewBefore: &operatorv1alpha1.DefaultCertificateRenewal, - - PrivateKey: &certmanagerv1.CertificatePrivateKey{ - Algorithm: certmanagerv1.RSAKeyAlgorithm, - Size: 4096, - }, - - CommonName: "external-logical-cluster-admin", - - Usages: []certmanagerv1.KeyUsage{ - certmanagerv1.UsageClientAuth, - }, - - Subject: &certmanagerv1.X509Subject{ - Organizations: []string{"system:kcp:external-logical-cluster-admin"}, - }, - - IssuerRef: certmanagermetav1.IssuerReference{ - Name: resources.GetRootShardCAName(r.rootShard, operatorv1alpha1.FrontProxyClientCA), - Kind: "Issuer", - Group: "cert-manager.io", - }, - } - - return utils.ApplyCertificateTemplate(cert, &template), nil - } - } -} - func (r *reconciler) kubeconfigCertificateReconciler() reconciling.NamedCertificateReconcilerFactory { const certKind = operatorv1alpha1.KubeconfigCertificate diff --git a/internal/resources/frontproxy/deployment.go b/internal/resources/frontproxy/deployment.go index 24668d49..e4a03410 100644 --- a/internal/resources/frontproxy/deployment.go +++ b/internal/resources/frontproxy/deployment.go @@ -173,12 +173,12 @@ func (r *reconciler) deploymentReconciler() reconciling.NamedDeploymentReconcile // If caBundleSecretRef is specified, mount the merged CA bundle secret. // This secret contains both kcp root CA and user-provided CA bundle merged together. if r.getCABundleSecretRef() != nil { - mountSecret(r.mergedCABundleSecretName(), getCAMountPath(operatorv1alpha1.CABundleCA), true) + mountSecret(r.backendCABundleSecretName(), getCAMountPath(operatorv1alpha1.CABundleCA), true) } - // Mount the merged client CA (FrontProxyClientCA + ClientCA) so + // Mount the merged client CA (ClientCA + optional ClientCABundleRef) so // that clients signed by either CA are accepted. - mountSecret(r.mergedClientCASecretName(), frontProxyBasepath+"/client-ca", true) + mountSecret(r.clientCABundleSecretName(), frontProxyBasepath+"/client-ca", true) // front-proxy config { diff --git a/internal/resources/frontproxy/reconciler.go b/internal/resources/frontproxy/reconciler.go index 10dc0c44..e37ad0d2 100644 --- a/internal/resources/frontproxy/reconciler.go +++ b/internal/resources/frontproxy/reconciler.go @@ -19,11 +19,13 @@ package frontproxy import ( "context" "errors" + "fmt" k8creconciling "k8c.io/reconciler/pkg/reconciling" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" kerrors "k8s.io/apimachinery/pkg/util/errors" ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client" @@ -66,6 +68,15 @@ func (r *reconciler) getCABundleSecretRef() *corev1.LocalObjectReference { return r.rootShard.Spec.CABundleSecretRef } +// getClientCABundleSecretRef returns the ClientCABundleRef from the FrontProxy spec. +// This is only used for FrontProxy resources, not for the RootShard internal proxy. +func (r *reconciler) getClientCABundleSecretRef() *corev1.LocalObjectReference { + if r.frontProxy != nil { + return r.frontProxy.Spec.ClientCABundleRef + } + return nil +} + // +kubebuilder:rbac:groups=core,resources=configmaps;secrets;services,verbs=get;update;patch // +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;update;patch // +kubebuilder:rbac:groups=cert-manager.io,resources=certificates,verbs=get;update;patch @@ -82,17 +93,28 @@ func (r *reconciler) Reconcile(ctx context.Context, client ctrlruntimeclient.Cli ownerRefWrapper := k8creconciling.OwnerRefWrapper(*ref) revisionLabels := modifier.RelatedRevisionsLabels(ctx, client) + // Fetch client CA certificates + clientCACerts, err := r.fetchClientCACerts(ctx, client) + if err != nil { + return err + } + configMapReconcilers := []k8creconciling.NamedConfigMapReconcilerFactory{ r.pathMappingConfigMapReconciler(), } secretReconcilers := []k8creconciling.NamedSecretReconcilerFactory{ r.dynamicKubeconfigSecretReconciler(), - r.mergedClientCASecretReconciler(ctx, client), + r.clientCABundleSecretReconciler(clientCACerts...), } + // Fetch server CA bundle if needed if r.getCABundleSecretRef() != nil { - secretReconcilers = append(secretReconcilers, r.mergedCABundleSecretReconciler(ctx, client)) + serverCACert, userCABundle, err := r.fetchBackendCAs(ctx, client) + if err != nil { + return err + } + secretReconcilers = append(secretReconcilers, r.backendCABundleSecretReconciler(serverCACert, userCABundle)) } certReconcilers := []reconciling.NamedCertificateReconcilerFactory{ @@ -101,10 +123,6 @@ func (r *reconciler) Reconcile(ctx context.Context, client ctrlruntimeclient.Cli r.requestHeaderCertificateReconciler(), } - if r.frontProxy != nil { - certReconcilers = append(certReconcilers, r.adminKubeconfigCertificateReconciler()) - } - deploymentReconcilers := []k8creconciling.NamedDeploymentReconcilerFactory{ r.deploymentReconciler(), } @@ -139,3 +157,71 @@ func (r *reconciler) Reconcile(ctx context.Context, client ctrlruntimeclient.Cli return kerrors.NewAggregate(errs) } + +// fetchClientCACerts fetches the ClientCA certificate and optionally the additional +// client CA bundles (from RootShard and/or FrontProxy if configured). +// Returns the certificates in order: ClientCA, RootShard.ClientCABundleRef, FrontProxy.ClientCABundleRef +func (r *reconciler) fetchClientCACerts(ctx context.Context, client ctrlruntimeclient.Client) ([][]byte, error) { + certs := [][]byte{} + + // fetch the shared, global client CA + clientCA, err := r.fetchTLSCert(ctx, client, resources.GetRootShardCAName(r.rootShard, operatorv1alpha1.ClientCA)) + if err != nil { + return nil, fmt.Errorf("failed to fetch ClientCA certificate: %w", err) + } + certs = append(certs, clientCA) + + // fetch RootShard's optional client CA bundle (inherited by all components) + if r.rootShard.Spec.ClientCABundleRef != nil { + rootShardCABundle, err := r.fetchTLSCert(ctx, client, r.rootShard.Spec.ClientCABundleRef.Name) + if err != nil { + return nil, fmt.Errorf("failed to fetch RootShard client CA bundle: %w", err) + } + certs = append(certs, rootShardCABundle) + } + + // fetch optional additional client CA bundle if specified on FrontProxy + // (if this a root proxy, getClientCABundleSecretRef returns nil) + if ref := r.getClientCABundleSecretRef(); ref != nil { + additionalCABundle, err := r.fetchTLSCert(ctx, client, ref.Name) + if err != nil { + return nil, fmt.Errorf("failed to fetch additional client CA bundle: %w", err) + } + certs = append(certs, additionalCABundle) + } + + return certs, nil +} + +// fetchBackendCAs fetches the ServerCA certificate and the user-provided CA bundle. +func (r *reconciler) fetchBackendCAs(ctx context.Context, client ctrlruntimeclient.Client) (serverCA, userCABundle []byte, err error) { + // fetch ServerCA + serverCA, err = r.fetchTLSCert(ctx, client, resources.GetRootShardCAName(r.rootShard, operatorv1alpha1.ServerCA)) + if err != nil { + return nil, nil, fmt.Errorf("failed to fetch ServerCA certificate: %w", err) + } + + // fetch user-provided CA bundle + if ref := r.getCABundleSecretRef(); ref != nil { + userCABundle, err = r.fetchTLSCert(ctx, client, ref.Name) + if err != nil { + return nil, nil, fmt.Errorf("failed to fetch user CA bundle: %w", err) + } + } + + return serverCA, userCABundle, nil +} + +func (r *reconciler) fetchTLSCert(ctx context.Context, client ctrlruntimeclient.Client, secretName string) ([]byte, error) { + secret := &corev1.Secret{} + if err := client.Get(ctx, types.NamespacedName{Name: secretName, Namespace: r.rootShard.Namespace}, secret); err != nil { + return nil, err + } + + data, exists := secret.Data["tls.crt"] + if !exists { + return nil, fmt.Errorf("the Secret %s contains no tls.crt", secretName) + } + + return data, nil +} diff --git a/internal/resources/kubeconfig/secret.go b/internal/resources/kubeconfig/secret.go index 1455bfb5..42db34a6 100644 --- a/internal/resources/kubeconfig/secret.go +++ b/internal/resources/kubeconfig/secret.go @@ -51,7 +51,7 @@ func KubeconfigSecretReconciler( return nil, fmt.Errorf("the CA bundle secret %s/%s does not contain a `tls.crt` key", caBundle.Namespace, caBundle.Name) } - caData, err := utils.MergeCABundles(caSecret, caBundle) + caData, err := utils.MergeCertificateSecrets(caSecret, caBundle) if err != nil { return nil, fmt.Errorf("failed to merge CA bundles: %w", err) } diff --git a/internal/resources/rootshard/ca_bundle.go b/internal/resources/rootshard/ca_bundle.go index 18278fae..7f3978ae 100644 --- a/internal/resources/rootshard/ca_bundle.go +++ b/internal/resources/rootshard/ca_bundle.go @@ -27,6 +27,7 @@ import ( ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client" "github.com/kcp-dev/kcp-operator/internal/resources" + "github.com/kcp-dev/kcp-operator/internal/resources/utils" operatorv1alpha1 "github.com/kcp-dev/kcp-operator/sdk/apis/operator/v1alpha1" ) @@ -39,52 +40,59 @@ func MergedCABundleSecretReconciler(ctx context.Context, rootShard *operatorv1al } // Get ServerCA certificate - serverCASecret := &corev1.Secret{} - serverCASecretName := resources.GetRootShardCAName(rootShard, operatorv1alpha1.ServerCA) - err := kubeClient.Get(ctx, types.NamespacedName{ - Name: serverCASecretName, - Namespace: rootShard.Namespace, - }, serverCASecret) + serverCACert, err := fetchTLSCert(ctx, kubeClient, rootShard.Namespace, resources.GetRootShardCAName(rootShard, operatorv1alpha1.ServerCA)) if err != nil { - return nil, fmt.Errorf("failed to get ServerCA secret %s: %w", serverCASecretName, err) - } - - serverCACert, exists := serverCASecret.Data["tls.crt"] - if !exists { - return nil, fmt.Errorf("ServerCA secret %s missing tls.crt", serverCASecretName) + return nil, fmt.Errorf("failed to get ServerCA: %w", err) } // Get user-provided CA bundle if specified var userCABundle []byte if rootShard.Spec.CABundleSecretRef != nil { - userCABundleSecret := &corev1.Secret{} - err := kubeClient.Get(ctx, types.NamespacedName{ - Name: rootShard.Spec.CABundleSecretRef.Name, - Namespace: rootShard.Namespace, - }, userCABundleSecret) + userCABundle, err = fetchTLSCert(ctx, kubeClient, rootShard.Namespace, rootShard.Spec.CABundleSecretRef.Name) if err != nil { - return nil, fmt.Errorf("failed to get user CA bundle secret %s: %w", rootShard.Spec.CABundleSecretRef.Name, err) + return nil, fmt.Errorf("failed to get user CA bundle: %w", err) } + } - var exists bool - userCABundle, exists = userCABundleSecret.Data["tls.crt"] - if !exists { - return nil, fmt.Errorf("user CA bundle secret %s missing tls.crt", rootShard.Spec.CABundleSecretRef.Name) - } + secret.Data["tls.crt"] = utils.MergeCertificates(serverCACert, userCABundle) + + // Set labels to identify this as a merged CA bundle + if secret.Labels == nil { + secret.Labels = make(map[string]string) + } + secret.Labels[resources.RootShardLabel] = rootShard.Name + + return secret, nil + } + } +} + +func MergedClientCABundleSecretReconciler(ctx context.Context, rootShard *operatorv1alpha1.RootShard, kubeClient ctrlruntimeclient.Client) k8creconciling.NamedSecretReconcilerFactory { + return func() (string, k8creconciling.SecretReconciler) { + secretName := fmt.Sprintf("%s-merged-client-ca", rootShard.Name) + return secretName, func(secret *corev1.Secret) (*corev1.Secret, error) { + if secret.Data == nil { + secret.Data = make(map[string][]byte) } - // Merge certificates: ServerCA + user CA bundle - var mergedCA []byte - if len(userCABundle) > 0 { - mergedCA = append(serverCACert, '\n') - mergedCA = append(mergedCA, userCABundle...) - } else { - mergedCA = serverCACert + // Get ClientCA certificate + clientCACert, err := fetchTLSCert(ctx, kubeClient, rootShard.Namespace, resources.GetRootShardCAName(rootShard, operatorv1alpha1.ClientCA)) + if err != nil { + return nil, fmt.Errorf("failed to get ClientCA: %w", err) + } + + // Get user-provided client CA bundle if specified + var userClientCABundle []byte + if rootShard.Spec.ClientCABundleRef != nil { + userClientCABundle, err = fetchTLSCert(ctx, kubeClient, rootShard.Namespace, rootShard.Spec.ClientCABundleRef.Name) + if err != nil { + return nil, fmt.Errorf("failed to get user client CA bundle: %w", err) + } } - secret.Data["tls.crt"] = mergedCA + secret.Data["tls.crt"] = utils.MergeCertificates(clientCACert, userClientCABundle) - // Set labels to identify this as a merged CA bundle + // Set labels to identify this as a merged client CA bundle if secret.Labels == nil { secret.Labels = make(map[string]string) } @@ -94,3 +102,17 @@ func MergedCABundleSecretReconciler(ctx context.Context, rootShard *operatorv1al } } } + +func fetchTLSCert(ctx context.Context, client ctrlruntimeclient.Client, namespace, secretName string) ([]byte, error) { + secret := &corev1.Secret{} + if err := client.Get(ctx, types.NamespacedName{Name: secretName, Namespace: namespace}, secret); err != nil { + return nil, fmt.Errorf("failed to get secret %s: %w", secretName, err) + } + + data, exists := secret.Data["tls.crt"] + if !exists { + return nil, fmt.Errorf("secret %s missing tls.crt", secretName) + } + + return data, nil +} diff --git a/internal/resources/rootshard/certificates.go b/internal/resources/rootshard/certificates.go index 8cdb8c08..e95ffcf4 100644 --- a/internal/resources/rootshard/certificates.go +++ b/internal/resources/rootshard/certificates.go @@ -237,7 +237,7 @@ func ExternalLogicalClusterAdminCertificateReconciler(rootShard *operatorv1alpha }, IssuerRef: certmanagermetav1.IssuerReference{ - Name: resources.GetRootShardCAName(rootShard, operatorv1alpha1.FrontProxyClientCA), + Name: resources.GetRootShardCAName(rootShard, operatorv1alpha1.ClientCA), Kind: "Issuer", Group: "cert-manager.io", }, diff --git a/internal/resources/rootshard/deployment.go b/internal/resources/rootshard/deployment.go index 0f279c79..e49a6c14 100644 --- a/internal/resources/rootshard/deployment.go +++ b/internal/resources/rootshard/deployment.go @@ -109,7 +109,6 @@ func DeploymentReconciler(rootShard *operatorv1alpha1.RootShard, kcpVW *operator } for _, ca := range []operatorv1alpha1.CA{ - operatorv1alpha1.ClientCA, operatorv1alpha1.ServerCA, operatorv1alpha1.ServiceAccountCA, operatorv1alpha1.RequestHeaderClientCA, @@ -121,6 +120,21 @@ func DeploymentReconciler(rootShard *operatorv1alpha1.RootShard, kcpVW *operator }) } + // ClientCA: use merged secret if ClientCABundleRef is set, otherwise use direct ClientCA + if rootShard.Spec.ClientCABundleRef != nil { + secretMounts = append(secretMounts, utils.SecretMount{ + VolumeName: fmt.Sprintf("%s-ca", operatorv1alpha1.ClientCA), + SecretName: fmt.Sprintf("%s-merged-client-ca", rootShard.Name), + MountPath: getCAMountPath(operatorv1alpha1.ClientCA), + }) + } else { + secretMounts = append(secretMounts, utils.SecretMount{ + VolumeName: fmt.Sprintf("%s-ca", operatorv1alpha1.ClientCA), + SecretName: resources.GetRootShardCAName(rootShard, operatorv1alpha1.ClientCA), + MountPath: getCAMountPath(operatorv1alpha1.ClientCA), + }) + } + for _, cert := range []operatorv1alpha1.Certificate{ operatorv1alpha1.ServerCertificate, operatorv1alpha1.ServiceAccountCertificate, diff --git a/internal/resources/rootshard/issuers.go b/internal/resources/rootshard/issuers.go index 26fc5f5c..57528078 100644 --- a/internal/resources/rootshard/issuers.go +++ b/internal/resources/rootshard/issuers.go @@ -86,23 +86,3 @@ func ClientCAIssuerReconciler(rootshard *operatorv1alpha1.RootShard) reconciling } } } - -func FrontProxyClientCAIssuerReconciler(rootshard *operatorv1alpha1.RootShard) reconciling.NamedIssuerReconcilerFactory { - name := resources.GetRootShardCAName(rootshard, operatorv1alpha1.FrontProxyClientCA) - secretName := name - - return func() (string, reconciling.IssuerReconciler) { - return name, func(issuer *certmanagerv1.Issuer) (*certmanagerv1.Issuer, error) { - issuer.SetLabels(resources.GetRootShardResourceLabels(rootshard)) - issuer.Spec = certmanagerv1.IssuerSpec{ - IssuerConfig: certmanagerv1.IssuerConfig{ - CA: &certmanagerv1.CAIssuer{ - SecretName: secretName, - }, - }, - } - - return issuer, nil - } - } -} diff --git a/internal/resources/shard/ca_bundle.go b/internal/resources/shard/ca_bundle.go index f6bc9df2..b494d5bc 100644 --- a/internal/resources/shard/ca_bundle.go +++ b/internal/resources/shard/ca_bundle.go @@ -27,6 +27,7 @@ import ( ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client" "github.com/kcp-dev/kcp-operator/internal/resources" + "github.com/kcp-dev/kcp-operator/internal/resources/utils" operatorv1alpha1 "github.com/kcp-dev/kcp-operator/sdk/apis/operator/v1alpha1" ) @@ -39,52 +40,70 @@ func MergedCABundleSecretReconciler(ctx context.Context, shard *operatorv1alpha1 } // Get ServerCA certificate - serverCASecret := &corev1.Secret{} - serverCASecretName := resources.GetShardCertificateName(shard, operatorv1alpha1.ServerCertificate) - err := kubeClient.Get(ctx, types.NamespacedName{ - Name: serverCASecretName, - Namespace: shard.Namespace, - }, serverCASecret) + serverCACert, err := fetchTLSCert(ctx, kubeClient, shard.Namespace, resources.GetShardCertificateName(shard, operatorv1alpha1.ServerCertificate)) if err != nil { - return nil, fmt.Errorf("failed to get ServerCA secret %s: %w", serverCASecretName, err) - } - - serverCACert, exists := serverCASecret.Data["tls.crt"] - if !exists { - return nil, fmt.Errorf("ServerCA secret %s missing tls.crt", serverCASecretName) + return nil, fmt.Errorf("failed to get ServerCA: %w", err) } // Get user-provided CA bundle if specified var userCABundle []byte if shard.Spec.CABundleSecretRef != nil { - userCABundleSecret := &corev1.Secret{} - err := kubeClient.Get(ctx, types.NamespacedName{ - Name: shard.Spec.CABundleSecretRef.Name, - Namespace: shard.Namespace, - }, userCABundleSecret) + userCABundle, err = fetchTLSCert(ctx, kubeClient, shard.Namespace, shard.Spec.CABundleSecretRef.Name) if err != nil { - return nil, fmt.Errorf("failed to get user CA bundle secret %s: %w", shard.Spec.CABundleSecretRef.Name, err) + return nil, fmt.Errorf("failed to get user CA bundle: %w", err) } + } + + secret.Data["tls.crt"] = utils.MergeCertificates(serverCACert, userCABundle) + + // Set labels to identify this as a merged CA bundle + if secret.Labels == nil { + secret.Labels = make(map[string]string) + } + secret.Labels[resources.ShardLabel] = shard.Name - var exists bool - userCABundle, exists = userCABundleSecret.Data["tls.crt"] - if !exists { - return nil, fmt.Errorf("user CA bundle secret %s missing tls.crt", shard.Spec.CABundleSecretRef.Name) + return secret, nil + } + } +} + +func MergedClientCABundleSecretReconciler(ctx context.Context, shard *operatorv1alpha1.Shard, rootShard *operatorv1alpha1.RootShard, kubeClient ctrlruntimeclient.Client) k8creconciling.NamedSecretReconcilerFactory { + return func() (string, k8creconciling.SecretReconciler) { + secretName := fmt.Sprintf("%s-merged-client-ca", shard.Name) + return secretName, func(secret *corev1.Secret) (*corev1.Secret, error) { + if secret.Data == nil { + secret.Data = make(map[string][]byte) + } + + // Get ClientCA certificate from RootShard + clientCACert, err := fetchTLSCert(ctx, kubeClient, rootShard.Namespace, resources.GetRootShardCAName(rootShard, operatorv1alpha1.ClientCA)) + if err != nil { + return nil, fmt.Errorf("failed to get ClientCA: %w", err) + } + + certs := [][]byte{clientCACert} + + // Get RootShard's client CA bundle if specified (inherited) + if rootShard.Spec.ClientCABundleRef != nil { + rootShardCABundle, err := fetchTLSCert(ctx, kubeClient, rootShard.Namespace, rootShard.Spec.ClientCABundleRef.Name) + if err != nil { + return nil, fmt.Errorf("failed to get RootShard client CA bundle: %w", err) } + certs = append(certs, rootShardCABundle) } - // Merge certificates: ServerCA + user CA bundle - var mergedCA []byte - if len(userCABundle) > 0 { - mergedCA = append(serverCACert, '\n') - mergedCA = append(mergedCA, userCABundle...) - } else { - mergedCA = serverCACert + // Get Shard's own client CA bundle if specified + if shard.Spec.ClientCABundleRef != nil { + shardCABundle, err := fetchTLSCert(ctx, kubeClient, shard.Namespace, shard.Spec.ClientCABundleRef.Name) + if err != nil { + return nil, fmt.Errorf("failed to get Shard client CA bundle: %w", err) + } + certs = append(certs, shardCABundle) } - secret.Data["tls.crt"] = mergedCA + secret.Data["tls.crt"] = utils.MergeCertificates(certs...) - // Set labels to identify this as a merged CA bundle + // Set labels to identify this as a merged client CA bundle if secret.Labels == nil { secret.Labels = make(map[string]string) } @@ -94,3 +113,17 @@ func MergedCABundleSecretReconciler(ctx context.Context, shard *operatorv1alpha1 } } } + +func fetchTLSCert(ctx context.Context, client ctrlruntimeclient.Client, namespace, secretName string) ([]byte, error) { + secret := &corev1.Secret{} + if err := client.Get(ctx, types.NamespacedName{Name: secretName, Namespace: namespace}, secret); err != nil { + return nil, fmt.Errorf("failed to get secret %s: %w", secretName, err) + } + + data, exists := secret.Data["tls.crt"] + if !exists { + return nil, fmt.Errorf("secret %s missing tls.crt", secretName) + } + + return data, nil +} diff --git a/internal/resources/shard/certificates.go b/internal/resources/shard/certificates.go index 87093bec..cd7435d9 100644 --- a/internal/resources/shard/certificates.go +++ b/internal/resources/shard/certificates.go @@ -296,7 +296,7 @@ func ExternalLogicalClusterAdminCertificateReconciler(shard *operatorv1alpha1.Sh }, IssuerRef: certmanagermetav1.IssuerReference{ - Name: resources.GetRootShardCAName(rootShard, operatorv1alpha1.FrontProxyClientCA), + Name: resources.GetRootShardCAName(rootShard, operatorv1alpha1.ClientCA), Kind: "Issuer", Group: "cert-manager.io", }, diff --git a/internal/resources/shard/deployment.go b/internal/resources/shard/deployment.go index 1ce48d4b..7b353a3e 100644 --- a/internal/resources/shard/deployment.go +++ b/internal/resources/shard/deployment.go @@ -122,7 +122,6 @@ func DeploymentReconciler(shard *operatorv1alpha1.Shard, rootShard *operatorv1al // All of these CAs are shared between rootshard and regular shards. for _, ca := range []operatorv1alpha1.CA{ - operatorv1alpha1.ClientCA, operatorv1alpha1.ServerCA, operatorv1alpha1.ServiceAccountCA, operatorv1alpha1.RequestHeaderClientCA, @@ -134,6 +133,21 @@ func DeploymentReconciler(shard *operatorv1alpha1.Shard, rootShard *operatorv1al }) } + // ClientCA: use merged secret if any ClientCABundleRef is set (inherited from RootShard or Shard's own) + if rootShard.Spec.ClientCABundleRef != nil || shard.Spec.ClientCABundleRef != nil { + secretMounts = append(secretMounts, utils.SecretMount{ + VolumeName: fmt.Sprintf("%s-ca", operatorv1alpha1.ClientCA), + SecretName: fmt.Sprintf("%s-merged-client-ca", shard.Name), + MountPath: getCAMountPath(operatorv1alpha1.ClientCA), + }) + } else { + secretMounts = append(secretMounts, utils.SecretMount{ + VolumeName: fmt.Sprintf("%s-ca", operatorv1alpha1.ClientCA), + SecretName: resources.GetRootShardCAName(rootShard, operatorv1alpha1.ClientCA), + MountPath: getCAMountPath(operatorv1alpha1.ClientCA), + }) + } + for _, cert := range []operatorv1alpha1.Certificate{ operatorv1alpha1.ServerCertificate, operatorv1alpha1.ServiceAccountCertificate, diff --git a/internal/resources/utils/certificates.go b/internal/resources/utils/certificates.go index 5824a8c9..b62163e5 100644 --- a/internal/resources/utils/certificates.go +++ b/internal/resources/utils/certificates.go @@ -17,12 +17,11 @@ limitations under the License. package utils import ( + "bytes" "crypto/x509" "encoding/pem" "fmt" "maps" - "net/url" - "os" certmanagerv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" @@ -33,7 +32,7 @@ import ( operatorv1alpha1 "github.com/kcp-dev/kcp-operator/sdk/apis/operator/v1alpha1" ) -func addNewKeys(existing, toAdd map[string]string) map[string]string { +func mergeMaps(existing, toAdd map[string]string) map[string]string { if len(toAdd) == 0 { return existing } @@ -58,8 +57,8 @@ func ApplyCertificateTemplate(cert *certmanagerv1.Certificate, tpl *operatorv1al } if metadata := tpl.Metadata; metadata != nil { - cert.Annotations = addNewKeys(cert.Annotations, metadata.Annotations) - cert.Labels = addNewKeys(cert.Labels, metadata.Labels) + cert.Annotations = mergeMaps(cert.Annotations, metadata.Annotations) + cert.Labels = mergeMaps(cert.Labels, metadata.Labels) } applyCertificateSpecTemplate(cert, tpl.Spec) @@ -97,8 +96,8 @@ func applyCertificateSpecTemplate(cert *certmanagerv1.Certificate, tpl *operator cert.Spec.SecretTemplate = &certmanagerv1.CertificateSecretTemplate{} } - cert.Spec.SecretTemplate.Annotations = addNewKeys(cert.Spec.SecretTemplate.Annotations, secretTpl.Annotations) - cert.Spec.SecretTemplate.Labels = addNewKeys(cert.Spec.SecretTemplate.Labels, secretTpl.Labels) + cert.Spec.SecretTemplate.Annotations = mergeMaps(cert.Spec.SecretTemplate.Annotations, secretTpl.Annotations) + cert.Spec.SecretTemplate.Labels = mergeMaps(cert.Spec.SecretTemplate.Labels, secretTpl.Labels) } if tpl.IssuerRef != nil { cert.Spec.IssuerRef = cmmeta.IssuerReference{ @@ -168,8 +167,8 @@ func applyCertificateSubjectTemplate(subj *certmanagerv1.X509Subject, tpl *opera return subj } -// ValidatePEMCertificate validates that the given data contains valid PEM-encoded certificates. -func ValidatePEMCertificate(data []byte) error { +// validatePEMCertificate validates that the given data contains valid PEM-encoded certificates. +func validatePEMCertificate(data []byte) error { if len(data) == 0 { return nil } @@ -194,69 +193,49 @@ func ValidatePEMCertificate(data []byte) error { return nil } -// MergeCABundles merges the CA certificate data from two secrets. -func MergeCABundles(caSecret, caBundle *corev1.Secret) ([]byte, error) { - var merged []byte - - if caSecret != nil && caSecret.Data != nil { - if caCrt, exists := caSecret.Data["tls.crt"]; exists { - if err := ValidatePEMCertificate(caCrt); err != nil { - return nil, fmt.Errorf("invalid certificate in caSecret: %w", err) - } - merged = append(merged, caCrt...) - } - } - - if caBundle != nil && caBundle.Data != nil { - if caCrt, exists := caBundle.Data["tls.crt"]; exists { - if err := ValidatePEMCertificate(caCrt); err != nil { - return nil, fmt.Errorf("invalid certificate in caBundle: %w", err) - } - merged = append(merged, caCrt...) +// MergeCertificates concatenates multiple PEM certificate bundles with newlines. +// Empty or nil certificates are skipped. +func MergeCertificates(certs ...[]byte) []byte { + merged := [][]byte{} + for _, cert := range certs { + if len(cert) > 0 { + merged = append(merged, cert) } } - - return merged, nil + return bytes.Join(merged, []byte{'\n'}) } -// MergeCABundlesFiles merges the CA certificate data from two files. -func MergeCABundlesFiles(caFile1, caFile2 string) ([]byte, error) { - var merged []byte +// MergeCertificateSecrets merges the CA certificate data from multiple secrets. +func MergeCertificateSecrets(secrets ...*corev1.Secret) ([]byte, error) { + certs := [][]byte{} - // Read and validate the first CA file - caFile1Content, err := os.ReadFile(caFile1) - if err != nil { - return nil, fmt.Errorf("failed to read CA file 1: %w", err) - } - if err := ValidatePEMCertificate(caFile1Content); err != nil { - return nil, fmt.Errorf("invalid certificate in caFile1: %w", err) - } - merged = append(merged, caFile1Content...) + for _, secret := range secrets { + cert, err := getCertFromSecret(secret) + if err != nil { + return nil, fmt.Errorf("error getting certificate from Secret %s: %w", secret.Name, err) + } - // Read and validate the second CA file - caFile2Content, err := os.ReadFile(caFile2) - if err != nil { - return nil, fmt.Errorf("failed to read CA file 2: %w", err) - } - if err := ValidatePEMCertificate(caFile2Content); err != nil { - return nil, fmt.Errorf("invalid certificate in caFile2: %w", err) + if cert != nil { + certs = append(certs, cert) + } } - merged = append(merged, caFile2Content...) - return merged, nil + return MergeCertificates(certs...), nil } -// ExtractHostnameFromURL extracts the hostname from a URL string. -// Returns empty string if the URL is invalid or empty. -func ExtractHostnameFromURL(rawURL string) string { - if rawURL == "" { - return "" +func getCertFromSecret(secret *corev1.Secret) ([]byte, error) { + if secret == nil { + return nil, nil + } + + cert, exists := secret.Data["tls.crt"] + if !exists { + return nil, nil } - parsed, err := url.Parse(rawURL) - if err != nil { - return "" + if err := validatePEMCertificate(cert); err != nil { + return nil, err } - return parsed.Hostname() + return cert, nil } diff --git a/internal/resources/utils/certificates_test.go b/internal/resources/utils/certificates_test.go index 852b24ba..a70dc0e5 100644 --- a/internal/resources/utils/certificates_test.go +++ b/internal/resources/utils/certificates_test.go @@ -109,7 +109,7 @@ YWJjZGVmZ2hpams= for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := ValidatePEMCertificate(tt.data) + err := validatePEMCertificate(tt.data) if tt.wantErr { assert.Error(t, err) @@ -122,64 +122,3 @@ YWJjZGVmZ2hpams= }) } } - -func TestExtractHostnameFromURL(t *testing.T) { - tests := []struct { - name string - url string - expected string - }{ - { - name: "empty URL", - url: "", - expected: "", - }, - { - name: "valid URL with https", - url: "https://api.example.com", - expected: "api.example.com", - }, - { - name: "valid URL with port", - url: "https://api.example.com:6443", - expected: "api.example.com", - }, - { - name: "valid URL with http", - url: "http://localhost:8080", - expected: "localhost", - }, - { - name: "URL with path", - url: "https://api.example.com:6443/path/to/resource", - expected: "api.example.com", - }, - { - name: "subdomain URL", - url: "https://root.shard.kcp.example.com:6443", - expected: "root.shard.kcp.example.com", - }, - { - name: "invalid URL", - url: "not-a-valid-url", - expected: "", - }, - { - name: "URL with IPv4 address", - url: "https://192.168.1.1:6443", - expected: "192.168.1.1", - }, - { - name: "URL with IPv6 address", - url: "https://[2001:db8::1]:6443", - expected: "2001:db8::1", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := ExtractHostnameFromURL(tt.url) - assert.Equal(t, tt.expected, result) - }) - } -} diff --git a/internal/resources/utils/deployments.go b/internal/resources/utils/deployments.go index 9b12aad4..a2a5ea69 100644 --- a/internal/resources/utils/deployments.go +++ b/internal/resources/utils/deployments.go @@ -31,8 +31,8 @@ func ApplyDeploymentTemplate(dep *appsv1.Deployment, tpl *operatorv1alpha1.Deplo } if metadata := tpl.Metadata; metadata != nil { - dep.Annotations = addNewKeys(dep.Annotations, metadata.Annotations) - dep.Labels = addNewKeys(dep.Labels, metadata.Labels) + dep.Annotations = mergeMaps(dep.Annotations, metadata.Annotations) + dep.Labels = mergeMaps(dep.Labels, metadata.Labels) } applyDeploymentSpecTemplate(&dep.Spec, tpl.Spec) @@ -54,8 +54,8 @@ func applyPodTemplateSpec(templateSpec *corev1.PodTemplateSpec, tpl *operatorv1a } if metadata := tpl.Metadata; metadata != nil { - templateSpec.Annotations = addNewKeys(templateSpec.Annotations, metadata.Annotations) - templateSpec.Labels = addNewKeys(templateSpec.Labels, metadata.Labels) + templateSpec.Annotations = mergeMaps(templateSpec.Annotations, metadata.Annotations) + templateSpec.Labels = mergeMaps(templateSpec.Labels, metadata.Labels) } applyPodSpecTemplate(&templateSpec.Spec, tpl.Spec) diff --git a/internal/resources/utils/services.go b/internal/resources/utils/services.go index c331bf8b..6c973e54 100644 --- a/internal/resources/utils/services.go +++ b/internal/resources/utils/services.go @@ -28,8 +28,8 @@ func ApplyServiceTemplate(svc *corev1.Service, tpl *operatorv1alpha1.ServiceTemp } if metadata := tpl.Metadata; metadata != nil { - svc.Annotations = addNewKeys(svc.Annotations, metadata.Annotations) - svc.Labels = addNewKeys(svc.Labels, metadata.Labels) + svc.Annotations = mergeMaps(svc.Annotations, metadata.Annotations) + svc.Labels = mergeMaps(svc.Labels, metadata.Labels) } if spec := tpl.Spec; spec != nil { diff --git a/internal/resources/utils/url.go b/internal/resources/utils/url.go new file mode 100644 index 00000000..6b1fdbcd --- /dev/null +++ b/internal/resources/utils/url.go @@ -0,0 +1,36 @@ +/* +Copyright 2026 The kcp Authors. + +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 utils + +import ( + "net/url" +) + +// ExtractHostnameFromURL extracts the hostname from a URL string. +// Returns empty string if the URL is invalid or empty. +func ExtractHostnameFromURL(rawURL string) string { + if rawURL == "" { + return "" + } + + parsed, err := url.Parse(rawURL) + if err != nil { + return "" + } + + return parsed.Hostname() +} diff --git a/internal/resources/utils/url_test.go b/internal/resources/utils/url_test.go new file mode 100644 index 00000000..940f5519 --- /dev/null +++ b/internal/resources/utils/url_test.go @@ -0,0 +1,84 @@ +/* +Copyright 2026 The kcp Authors. + +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 utils + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExtractHostnameFromURL(t *testing.T) { + tests := []struct { + name string + url string + expected string + }{ + { + name: "empty URL", + url: "", + expected: "", + }, + { + name: "valid URL with https", + url: "https://api.example.com", + expected: "api.example.com", + }, + { + name: "valid URL with port", + url: "https://api.example.com:6443", + expected: "api.example.com", + }, + { + name: "valid URL with http", + url: "http://localhost:8080", + expected: "localhost", + }, + { + name: "URL with path", + url: "https://api.example.com:6443/path/to/resource", + expected: "api.example.com", + }, + { + name: "subdomain URL", + url: "https://root.shard.kcp.example.com:6443", + expected: "root.shard.kcp.example.com", + }, + { + name: "invalid URL", + url: "not-a-valid-url", + expected: "", + }, + { + name: "URL with IPv4 address", + url: "https://192.168.1.1:6443", + expected: "192.168.1.1", + }, + { + name: "URL with IPv6 address", + url: "https://[2001:db8::1]:6443", + expected: "2001:db8::1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ExtractHostnameFromURL(tt.url) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/internal/resources/virtualworkspace/ca_bundle.go b/internal/resources/virtualworkspace/ca_bundle.go new file mode 100644 index 00000000..2d2f71cb --- /dev/null +++ b/internal/resources/virtualworkspace/ca_bundle.go @@ -0,0 +1,93 @@ +/* +Copyright 2026 The KCP Authors. + +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 virtualworkspace + +import ( + "context" + "fmt" + + k8creconciling "k8c.io/reconciler/pkg/reconciling" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/kcp-dev/kcp-operator/internal/resources" + "github.com/kcp-dev/kcp-operator/internal/resources/utils" + operatorv1alpha1 "github.com/kcp-dev/kcp-operator/sdk/apis/operator/v1alpha1" +) + +func MergedClientCABundleSecretReconciler(ctx context.Context, vw *operatorv1alpha1.VirtualWorkspace, rootShard *operatorv1alpha1.RootShard, kubeClient ctrlruntimeclient.Client) k8creconciling.NamedSecretReconcilerFactory { + return func() (string, k8creconciling.SecretReconciler) { + secretName := fmt.Sprintf("%s-merged-client-ca", vw.Name) + return secretName, func(secret *corev1.Secret) (*corev1.Secret, error) { + if secret.Data == nil { + secret.Data = make(map[string][]byte) + } + + // Get ClientCA certificate from RootShard + clientCACert, err := fetchTLSCert(ctx, kubeClient, rootShard.Namespace, resources.GetRootShardCAName(rootShard, operatorv1alpha1.ClientCA)) + if err != nil { + return nil, fmt.Errorf("failed to get ClientCA: %w", err) + } + + certs := [][]byte{clientCACert} + + // Get RootShard's client CA bundle if specified (inherited) + if rootShard.Spec.ClientCABundleRef != nil { + rootShardCABundle, err := fetchTLSCert(ctx, kubeClient, rootShard.Namespace, rootShard.Spec.ClientCABundleRef.Name) + if err != nil { + return nil, fmt.Errorf("failed to get RootShard client CA bundle: %w", err) + } + certs = append(certs, rootShardCABundle) + } + + // Get VirtualWorkspace's own client CA bundle if specified + if vw.Spec.ClientCABundleRef != nil { + vwCABundle, err := fetchTLSCert(ctx, kubeClient, vw.Namespace, vw.Spec.ClientCABundleRef.Name) + if err != nil { + return nil, fmt.Errorf("failed to get VirtualWorkspace client CA bundle: %w", err) + } + certs = append(certs, vwCABundle) + } + + secret.Data["tls.crt"] = utils.MergeCertificates(certs...) + + // Set labels to identify this as a merged client CA bundle + if secret.Labels == nil { + secret.Labels = make(map[string]string) + } + secret.Labels[resources.VirtualWorkspaceLabel] = vw.Name + + return secret, nil + } + } +} + +func fetchTLSCert(ctx context.Context, client ctrlruntimeclient.Client, namespace, secretName string) ([]byte, error) { + secret := &corev1.Secret{} + if err := client.Get(ctx, types.NamespacedName{Name: secretName, Namespace: namespace}, secret); err != nil { + return nil, fmt.Errorf("failed to get secret %s: %w", secretName, err) + } + + data, exists := secret.Data["tls.crt"] + if !exists { + return nil, fmt.Errorf("secret %s missing tls.crt", secretName) + } + + return data, nil +} diff --git a/internal/resources/virtualworkspace/deployment.go b/internal/resources/virtualworkspace/deployment.go index 8293ec42..49bed4d6 100644 --- a/internal/resources/virtualworkspace/deployment.go +++ b/internal/resources/virtualworkspace/deployment.go @@ -115,11 +115,9 @@ func DeploymentReconciler(vw *operatorv1alpha1.VirtualWorkspace, rootShard *oper args := getArgs(vw, rootShard, shard) - // All of these CAs are shared between rootshard and regular shards. + // CAs shared between rootshard and regular shards (excluding ClientCA which may need merging) for _, ca := range []operatorv1alpha1.CA{ - operatorv1alpha1.ClientCA, operatorv1alpha1.ServerCA, - // operatorv1alpha1.ServiceAccountCA, operatorv1alpha1.RequestHeaderClientCA, } { secretMounts = append(secretMounts, utils.SecretMount{ @@ -129,6 +127,21 @@ func DeploymentReconciler(vw *operatorv1alpha1.VirtualWorkspace, rootShard *oper }) } + // ClientCA: use merged secret if any ClientCABundleRef is set (inherited from RootShard or VW's own) + if rootShard.Spec.ClientCABundleRef != nil || vw.Spec.ClientCABundleRef != nil { + secretMounts = append(secretMounts, utils.SecretMount{ + VolumeName: fmt.Sprintf("%s-ca", operatorv1alpha1.ClientCA), + SecretName: fmt.Sprintf("%s-merged-client-ca", vw.Name), + MountPath: getCAMountPath(operatorv1alpha1.ClientCA), + }) + } else { + secretMounts = append(secretMounts, utils.SecretMount{ + VolumeName: fmt.Sprintf("%s-ca", operatorv1alpha1.ClientCA), + SecretName: resources.GetRootShardCAName(rootShard, operatorv1alpha1.ClientCA), + MountPath: getCAMountPath(operatorv1alpha1.ClientCA), + }) + } + secretMounts = append(secretMounts, utils.SecretMount{ VolumeName: fmt.Sprintf("%s-cert", operatorv1alpha1.ServerCertificate), SecretName: resources.GetVirtualWorkspaceCertificateName(vw, operatorv1alpha1.ServerCertificate), diff --git a/sdk/apis/operator/v1alpha1/common.go b/sdk/apis/operator/v1alpha1/common.go index 748323d1..bdfdbb23 100644 --- a/sdk/apis/operator/v1alpha1/common.go +++ b/sdk/apis/operator/v1alpha1/common.go @@ -108,9 +108,13 @@ const ( ServerCA CA = "server" ServiceAccountCA CA = "service-account" ClientCA CA = "client" - FrontProxyClientCA CA = "front-proxy-client" RequestHeaderClientCA CA = "requestheader-client" + // Deprecated: FrontProxyClientCA is no longer used. The front-proxy now uses the ClientCA + // for client certificate authentication. For backwards compatibility during upgrades, + // use FrontProxy.Spec.ClientCABundleRef to reference the old front-proxy client CA. + FrontProxyClientCA CA = "front-proxy-client" + // CABundleCA is the CA used to validate the API server's TLS certificate. CABundleCA CA = "ca-bundle" ) diff --git a/sdk/apis/operator/v1alpha1/frontproxy_types.go b/sdk/apis/operator/v1alpha1/frontproxy_types.go index 14a3bf34..b9092486 100644 --- a/sdk/apis/operator/v1alpha1/frontproxy_types.go +++ b/sdk/apis/operator/v1alpha1/frontproxy_types.go @@ -65,6 +65,15 @@ type FrontProxySpec struct { // +optional CABundleSecretRef *corev1.LocalObjectReference `json:"caBundleSecretRef,omitempty"` + // ClientCABundleRef references a v1.Secret object that contains an additional client CA bundle + // that should be trusted by the front-proxy for client certificate authentication. + // The secret must contain a key named `tls.crt` that holds the PEM encoded CA certificate(s). + // This CA bundle will be merged with the root shard's client CA, allowing the front-proxy + // to accept client certificates signed by either CA. This is useful for backwards compatibility + // during upgrades when migrating from a separate front-proxy client CA to the shared root shard client CA. + // +optional + ClientCABundleRef *corev1.LocalObjectReference `json:"clientCABundleRef,omitempty"` + // Optional: ExtraArgs defines additional command line arguments to pass to the front-proxy container. ExtraArgs []string `json:"extraArgs,omitempty"` diff --git a/sdk/apis/operator/v1alpha1/shard_types.go b/sdk/apis/operator/v1alpha1/shard_types.go index 19e41644..24cd2e43 100644 --- a/sdk/apis/operator/v1alpha1/shard_types.go +++ b/sdk/apis/operator/v1alpha1/shard_types.go @@ -91,6 +91,16 @@ type CommonShardSpec struct { // +optional CABundleSecretRef *corev1.LocalObjectReference `json:"caBundleSecretRef,omitempty"` + // ClientCABundleRef references a v1.Secret containing an additional client CA bundle + // for client certificate authentication. The secret must contain a key named `tls.crt`. + // This CA bundle will be merged with the root shard's client CA. + // If configured on a RootShard, this bundle is automatically inherited by all FrontProxies, + // Shards, and VirtualWorkspaces connected to it. Each of those components can additionally + // specify their own ClientCABundleRef, which will be merged on top. + // + // +optional + ClientCABundleRef *corev1.LocalObjectReference `json:"clientCABundleRef,omitempty"` + // Optional: ExtraArgs defines additional command line arguments to pass to the shard container. ExtraArgs []string `json:"extraArgs,omitempty"` diff --git a/sdk/apis/operator/v1alpha1/virtualworkspace_types.go b/sdk/apis/operator/v1alpha1/virtualworkspace_types.go index 1c7c757a..a3d71d7f 100644 --- a/sdk/apis/operator/v1alpha1/virtualworkspace_types.go +++ b/sdk/apis/operator/v1alpha1/virtualworkspace_types.go @@ -78,6 +78,14 @@ type VirtualWorkspaceSpec struct { // +optional CABundleSecretRef *corev1.LocalObjectReference `json:"caBundleSecretRef,omitempty"` + // ClientCABundleRef references a v1.Secret containing an additional client CA bundle + // for client certificate authentication. The secret must contain a key named `tls.crt`. + // This CA bundle will be merged with the root shard's client CA and its ClientCABundleRef + // (if configured). + // + // +optional + ClientCABundleRef *corev1.LocalObjectReference `json:"clientCABundleRef,omitempty"` + // Optional: ExtraArgs defines additional command line arguments to pass to the server container. ExtraArgs []string `json:"extraArgs,omitempty"` diff --git a/sdk/apis/operator/v1alpha1/zz_generated.deepcopy.go b/sdk/apis/operator/v1alpha1/zz_generated.deepcopy.go index 3c7b0080..aa60fb24 100644 --- a/sdk/apis/operator/v1alpha1/zz_generated.deepcopy.go +++ b/sdk/apis/operator/v1alpha1/zz_generated.deepcopy.go @@ -740,6 +740,11 @@ func (in *CommonShardSpec) DeepCopyInto(out *CommonShardSpec) { *out = new(v1.LocalObjectReference) **out = **in } + if in.ClientCABundleRef != nil { + in, out := &in.ClientCABundleRef, &out.ClientCABundleRef + *out = new(v1.LocalObjectReference) + **out = **in + } if in.ExtraArgs != nil { in, out := &in.ExtraArgs, &out.ExtraArgs *out = make([]string, len(*in)) @@ -1023,6 +1028,11 @@ func (in *FrontProxySpec) DeepCopyInto(out *FrontProxySpec) { *out = new(v1.LocalObjectReference) **out = **in } + if in.ClientCABundleRef != nil { + in, out := &in.ClientCABundleRef, &out.ClientCABundleRef + *out = new(v1.LocalObjectReference) + **out = **in + } if in.ExtraArgs != nil { in, out := &in.ExtraArgs, &out.ExtraArgs *out = make([]string, len(*in)) @@ -2024,6 +2034,11 @@ func (in *VirtualWorkspaceSpec) DeepCopyInto(out *VirtualWorkspaceSpec) { *out = new(v1.LocalObjectReference) **out = **in } + if in.ClientCABundleRef != nil { + in, out := &in.ClientCABundleRef, &out.ClientCABundleRef + *out = new(v1.LocalObjectReference) + **out = **in + } if in.ExtraArgs != nil { in, out := &in.ExtraArgs, &out.ExtraArgs *out = make([]string, len(*in)) diff --git a/sdk/applyconfiguration/operator/v1alpha1/commonshardspec.go b/sdk/applyconfiguration/operator/v1alpha1/commonshardspec.go index e75d3766..5fa66f7a 100644 --- a/sdk/applyconfiguration/operator/v1alpha1/commonshardspec.go +++ b/sdk/applyconfiguration/operator/v1alpha1/commonshardspec.go @@ -41,6 +41,7 @@ type CommonShardSpecApplyConfiguration struct { ServiceTemplate *ServiceTemplateApplyConfiguration `json:"serviceTemplate,omitempty"` DeploymentTemplate *DeploymentTemplateApplyConfiguration `json:"deploymentTemplate,omitempty"` CABundleSecretRef *v1.LocalObjectReference `json:"caBundleSecretRef,omitempty"` + ClientCABundleRef *v1.LocalObjectReference `json:"clientCABundleRef,omitempty"` ExtraArgs []string `json:"extraArgs,omitempty"` Logging *LoggingSpecApplyConfiguration `json:"logging,omitempty"` } @@ -163,6 +164,14 @@ func (b *CommonShardSpecApplyConfiguration) WithCABundleSecretRef(value v1.Local return b } +// WithClientCABundleRef sets the ClientCABundleRef field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ClientCABundleRef field is set to the value of the last call. +func (b *CommonShardSpecApplyConfiguration) WithClientCABundleRef(value v1.LocalObjectReference) *CommonShardSpecApplyConfiguration { + b.ClientCABundleRef = &value + return b +} + // WithExtraArgs adds the given value to the ExtraArgs field in the declarative configuration // and returns the receiver, so that objects can be build by chaining "With" function invocations. // If called multiple times, values provided by each call will be appended to the ExtraArgs field. diff --git a/sdk/applyconfiguration/operator/v1alpha1/frontproxyspec.go b/sdk/applyconfiguration/operator/v1alpha1/frontproxyspec.go index 32e0d5f8..bdc877ba 100644 --- a/sdk/applyconfiguration/operator/v1alpha1/frontproxyspec.go +++ b/sdk/applyconfiguration/operator/v1alpha1/frontproxyspec.go @@ -39,6 +39,7 @@ type FrontProxySpecApplyConfiguration struct { DeploymentTemplate *DeploymentTemplateApplyConfiguration `json:"deploymentTemplate,omitempty"` CertificateTemplates *operatorv1alpha1.CertificateTemplateMap `json:"certificateTemplates,omitempty"` CABundleSecretRef *v1.LocalObjectReference `json:"caBundleSecretRef,omitempty"` + ClientCABundleRef *v1.LocalObjectReference `json:"clientCABundleRef,omitempty"` ExtraArgs []string `json:"extraArgs,omitempty"` Logging *LoggingSpecApplyConfiguration `json:"logging,omitempty"` } @@ -150,6 +151,14 @@ func (b *FrontProxySpecApplyConfiguration) WithCABundleSecretRef(value v1.LocalO return b } +// WithClientCABundleRef sets the ClientCABundleRef field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ClientCABundleRef field is set to the value of the last call. +func (b *FrontProxySpecApplyConfiguration) WithClientCABundleRef(value v1.LocalObjectReference) *FrontProxySpecApplyConfiguration { + b.ClientCABundleRef = &value + return b +} + // WithExtraArgs adds the given value to the ExtraArgs field in the declarative configuration // and returns the receiver, so that objects can be build by chaining "With" function invocations. // If called multiple times, values provided by each call will be appended to the ExtraArgs field. diff --git a/sdk/applyconfiguration/operator/v1alpha1/rootshardspec.go b/sdk/applyconfiguration/operator/v1alpha1/rootshardspec.go index 631a3ca5..70728659 100644 --- a/sdk/applyconfiguration/operator/v1alpha1/rootshardspec.go +++ b/sdk/applyconfiguration/operator/v1alpha1/rootshardspec.go @@ -152,6 +152,14 @@ func (b *RootShardSpecApplyConfiguration) WithCABundleSecretRef(value v1.LocalOb return b } +// WithClientCABundleRef sets the ClientCABundleRef field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ClientCABundleRef field is set to the value of the last call. +func (b *RootShardSpecApplyConfiguration) WithClientCABundleRef(value v1.LocalObjectReference) *RootShardSpecApplyConfiguration { + b.CommonShardSpecApplyConfiguration.ClientCABundleRef = &value + return b +} + // WithExtraArgs adds the given value to the ExtraArgs field in the declarative configuration // and returns the receiver, so that objects can be build by chaining "With" function invocations. // If called multiple times, values provided by each call will be appended to the ExtraArgs field. diff --git a/sdk/applyconfiguration/operator/v1alpha1/shardspec.go b/sdk/applyconfiguration/operator/v1alpha1/shardspec.go index 0aebbd69..79735781 100644 --- a/sdk/applyconfiguration/operator/v1alpha1/shardspec.go +++ b/sdk/applyconfiguration/operator/v1alpha1/shardspec.go @@ -150,6 +150,14 @@ func (b *ShardSpecApplyConfiguration) WithCABundleSecretRef(value v1.LocalObject return b } +// WithClientCABundleRef sets the ClientCABundleRef field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ClientCABundleRef field is set to the value of the last call. +func (b *ShardSpecApplyConfiguration) WithClientCABundleRef(value v1.LocalObjectReference) *ShardSpecApplyConfiguration { + b.CommonShardSpecApplyConfiguration.ClientCABundleRef = &value + return b +} + // WithExtraArgs adds the given value to the ExtraArgs field in the declarative configuration // and returns the receiver, so that objects can be build by chaining "With" function invocations. // If called multiple times, values provided by each call will be appended to the ExtraArgs field. diff --git a/sdk/applyconfiguration/operator/v1alpha1/virtualworkspacespec.go b/sdk/applyconfiguration/operator/v1alpha1/virtualworkspacespec.go index f88a073a..50645cee 100644 --- a/sdk/applyconfiguration/operator/v1alpha1/virtualworkspacespec.go +++ b/sdk/applyconfiguration/operator/v1alpha1/virtualworkspacespec.go @@ -36,6 +36,7 @@ type VirtualWorkspaceSpecApplyConfiguration struct { ServiceTemplate *ServiceTemplateApplyConfiguration `json:"serviceTemplate,omitempty"` DeploymentTemplate *DeploymentTemplateApplyConfiguration `json:"deploymentTemplate,omitempty"` CABundleSecretRef *v1.LocalObjectReference `json:"caBundleSecretRef,omitempty"` + ClientCABundleRef *v1.LocalObjectReference `json:"clientCABundleRef,omitempty"` ExtraArgs []string `json:"extraArgs,omitempty"` Logging *LoggingSpecApplyConfiguration `json:"logging,omitempty"` ClusterDomain *string `json:"clusterDomain,omitempty"` @@ -119,6 +120,14 @@ func (b *VirtualWorkspaceSpecApplyConfiguration) WithCABundleSecretRef(value v1. return b } +// WithClientCABundleRef sets the ClientCABundleRef field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ClientCABundleRef field is set to the value of the last call. +func (b *VirtualWorkspaceSpecApplyConfiguration) WithClientCABundleRef(value v1.LocalObjectReference) *VirtualWorkspaceSpecApplyConfiguration { + b.ClientCABundleRef = &value + return b +} + // WithExtraArgs adds the given value to the ExtraArgs field in the declarative configuration // and returns the receiver, so that objects can be build by chaining "With" function invocations. // If called multiple times, values provided by each call will be appended to the ExtraArgs field. diff --git a/test/e2e/shards/shards_test.go b/test/e2e/shards/shards_test.go index 4977be6b..b7b73e6b 100644 --- a/test/e2e/shards/shards_test.go +++ b/test/e2e/shards/shards_test.go @@ -212,8 +212,8 @@ func TestShardBundleAnnotation(t *testing.T) { t.Log("Successfully verified Bundle was created with correct target") // wait for bundle to become ready and have all objects - // Note: Shard without CABundleSecretRef has 17 objects (no merged CA bundle) - expectedObjects := 17 + // Note: Shard without CABundleSecretRef has 16 objects (no merged CA bundle) + expectedObjects := 16 t.Logf("Waiting for Bundle to become Ready with all %d objects...", expectedObjects) timeout := time.After(3 * time.Minute) ticker := time.NewTicker(5 * time.Second) @@ -276,8 +276,7 @@ bundleReady: // verify specific expected objects exist expectedObjectsList := []string{ - // CA certificates from RootShard (6 objects) - fmt.Sprintf("secrets.core.v1:%s/%s-front-proxy-client-ca", namespace.Name, rootShard.Name), + // CA certificates from RootShard (5 objects) fmt.Sprintf("secrets.core.v1:%s/%s-requestheader-client-ca", namespace.Name, rootShard.Name), fmt.Sprintf("secrets.core.v1:%s/%s-server-ca", namespace.Name, rootShard.Name), fmt.Sprintf("secrets.core.v1:%s/%s-ca", namespace.Name, rootShard.Name),