From 9b182b913b4cf56341493f657adc073d47df3d3a Mon Sep 17 00:00:00 2001 From: grokspawn Date: Thu, 12 Mar 2026 13:08:42 -0500 Subject: [PATCH] initial Signed-off-by: grokspawn --- README.md | 9 +- docs/examples/hypershift-deployment.yaml | 179 ++++++++++++++++ docs/hypershift.md | 182 ++++++++++++++++ pkg/clients/clients.go | 34 ++- pkg/controller/builder.go | 108 +++++++++- pkg/controller/builder_test.go | 253 +++++++++++++++++++++++ 6 files changed, 757 insertions(+), 8 deletions(-) create mode 100644 docs/examples/hypershift-deployment.yaml create mode 100644 docs/hypershift.md diff --git a/README.md b/README.md index 1595ee2ad..8a3b41a41 100644 --- a/README.md +++ b/README.md @@ -8,5 +8,12 @@ It exists as a way for us to facilitate two things: 1. To turn off and on the feature flags for olm v1 so we could ship it in the openshift payload without it being turned on by default 2. To handle the `clusterstatus` resource for the v1 components - + Because OCP has an API that's part of the `cluster-version-operator`, that isn't in plain kubernetes, that tracks the state of all the OCP components, and if you're in the payload you are required to write status to it + +## Features + +- **Standalone Mode**: Manages OLMv1 components (catalogd, operator-controller) in standard OpenShift clusters +- **HyperShift Mode**: Supports HyperShift hosted clusters where OLMv1 components run in the management cluster but watch hosted cluster API servers + +For more information on HyperShift support, see [docs/hypershift.md](docs/hypershift.md). diff --git a/docs/examples/hypershift-deployment.yaml b/docs/examples/hypershift-deployment.yaml new file mode 100644 index 000000000..11d7d46d2 --- /dev/null +++ b/docs/examples/hypershift-deployment.yaml @@ -0,0 +1,179 @@ +--- +# Example HyperShift deployment for cluster-olm-operator +# This shows how to configure cluster-olm-operator to manage OLMv1 components +# for a hosted cluster. +# +# In this example: +# - Management cluster runs in namespace: clusters-customer1 +# - Hosted cluster name: customer1 +# - Admin kubeconfig secret: admin-kubeconfig + +apiVersion: apps/v1 +kind: Deployment +metadata: + name: cluster-olm-operator + namespace: clusters-customer1 + labels: + app: cluster-olm-operator + hypershift.openshift.io/control-plane-component: cluster-olm-operator +spec: + replicas: 1 + selector: + matchLabels: + app: cluster-olm-operator + template: + metadata: + labels: + app: cluster-olm-operator + hypershift.openshift.io/control-plane-component: cluster-olm-operator + spec: + serviceAccountName: cluster-olm-operator + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + initContainers: + - name: copy-catalogd-manifests + image: quay.io/openshift/origin-olm-catalogd:latest + imagePullPolicy: IfNotPresent + command: + - /bin/sh + args: + - -c + - /cp-manifests /operand-assets + volumeMounts: + - mountPath: /operand-assets + name: operand-assets + securityContext: + readOnlyRootFilesystem: true + terminationMessagePolicy: FallbackToLogsOnError + - name: copy-operator-controller-manifests + image: quay.io/openshift/origin-olm-operator-controller:latest + imagePullPolicy: IfNotPresent + command: + - /bin/sh + args: + - -c + - /cp-manifests /operand-assets + volumeMounts: + - mountPath: /operand-assets + name: operand-assets + securityContext: + readOnlyRootFilesystem: true + terminationMessagePolicy: FallbackToLogsOnError + containers: + - name: cluster-olm-operator + image: quay.io/openshift/origin-cluster-olm-operator:latest + terminationMessagePolicy: FallbackToLogsOnError + command: + - /cluster-olm-operator + args: + - start + imagePullPolicy: IfNotPresent + env: + # Standard environment variables + - name: OPERATOR_NAME + value: cluster-olm-operator + - name: OPERATOR_IMAGE_VERSION + value: 4.16.0 + - name: KUBE_RBAC_PROXY_IMAGE + value: quay.io/openshift/origin-kube-rbac-proxy:latest + - name: CATALOGD_IMAGE + value: quay.io/openshift/origin-olm-catalogd:latest + - name: OPERATOR_CONTROLLER_IMAGE + value: quay.io/openshift/origin-olm-operator-controller:latest + + # HyperShift mode configuration + # Setting these enables HyperShift mode + - name: HOSTED_KUBECONFIG_SECRET + value: admin-kubeconfig + - name: HOSTED_NAMESPACE + value: clusters-customer1 + + resources: + requests: + cpu: 10m + memory: 20Mi + securityContext: + readOnlyRootFilesystem: true + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + volumeMounts: + - mountPath: /operand-assets + name: operand-assets + - mountPath: /tmp + name: tmp + volumes: + - name: operand-assets + emptyDir: {} + - name: tmp + emptyDir: {} + +--- +# ServiceAccount for cluster-olm-operator +apiVersion: v1 +kind: ServiceAccount +metadata: + name: cluster-olm-operator + namespace: clusters-customer1 + +--- +# RBAC for cluster-olm-operator in management cluster +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: cluster-olm-operator-management +rules: +# Management cluster permissions +- apiGroups: ["apps"] + resources: ["deployments"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +- apiGroups: [""] + resources: ["services", "serviceaccounts", "configmaps"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +- apiGroups: ["rbac.authorization.k8s.io"] + resources: ["roles", "rolebindings", "clusterroles", "clusterrolebindings"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +- apiGroups: ["config.openshift.io"] + resources: ["proxies"] + verbs: ["get", "list", "watch"] + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: cluster-olm-operator-management +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-olm-operator-management +subjects: +- kind: ServiceAccount + name: cluster-olm-operator + namespace: clusters-customer1 + +--- +# Example: admin-kubeconfig secret +# This secret contains the kubeconfig for the hosted cluster's API server +# In a real HyperShift deployment, this is created automatically by HyperShift +apiVersion: v1 +kind: Secret +metadata: + name: admin-kubeconfig + namespace: clusters-customer1 +type: Opaque +data: + # Base64-encoded kubeconfig for the hosted cluster + # This would be generated by HyperShift control-plane-operator + kubeconfig: | + YXBpVmVyc2lvbjogdjEKY2x1c3RlcnM6Ci0gY2x1c3RlcjoKICAgIGNlcnRpZmljYXRl + LWF1dGhvcml0eS1kYXRhOiA8YmFzZTY0LWNhLWNlcnQ+CiAgICBzZXJ2ZXI6IGh0dHBz + Oi8vYXBpLmN1c3RvbWVyMS5leGFtcGxlLmNvbTo2NDQzCiAgbmFtZTogY3VzdG9tZXIx + CmNvbnRleHRzOgotIGNvbnRleHQ6CiAgICBjbHVzdGVyOiBjdXN0b21lcjEKICAgIHVz + ZXI6IGFkbWluCiAgbmFtZTogYWRtaW5AY3VzdG9tZXIxCmN1cnJlbnQtY29udGV4dDog + YWRtaW5AY3VzdG9tZXIxCmtpbmQ6IENvbmZpZwpwcmVmZXJlbmNlczoge30KdXNlcnM6 + Ci0gbmFtZTogYWRtaW4KICB1c2VyOgogICAgY2xpZW50LWNlcnRpZmljYXRlLWRhdGE6 + IDxiYXNlNjQtY2xpZW50LWNlcnQ+CiAgICBjbGllbnQta2V5LWRhdGE6IDxiYXNlNjQt + Y2xpZW50LWtleT4K diff --git a/docs/hypershift.md b/docs/hypershift.md new file mode 100644 index 000000000..86f99aa12 --- /dev/null +++ b/docs/hypershift.md @@ -0,0 +1,182 @@ +# HyperShift Support + +cluster-olm-operator supports running in HyperShift mode, where it manages OLMv1 components (catalogd and operator-controller) for hosted clusters. + +## Overview + +In HyperShift deployments, cluster-olm-operator can run in the management cluster and manage OLMv1 components that watch hosted cluster API servers. This enables: + +- catalogd to serve catalogs from the management cluster while watching ClusterCatalog resources in the hosted cluster's API server +- operator-controller to install operators into hosted cluster worker nodes while watching ClusterExtension resources in the hosted cluster's API server + +This corresponds to **Approach 1: Control Plane Placement** as described in the [HyperShift OLMv1 design document](https://github.com/openshift/enhancements/blob/master/enhancements/olm/hypershift-olmv1.md). + +## Architecture + +### Standalone Mode (Default) + +In standalone OpenShift clusters: +- cluster-olm-operator runs in `openshift-cluster-olm-operator` namespace +- catalogd and operator-controller watch the local cluster's API server using in-cluster config +- Components run in `olmv1-system` namespace + +### HyperShift Mode + +In HyperShift deployments: +- cluster-olm-operator runs in the management cluster (in the hosted control plane namespace, e.g., `clusters-customer1`) +- catalogd and operator-controller watch the **hosted cluster's** API server using a mounted kubeconfig +- Components are configured with `--kubeconfig` and `--system-namespace` flags +- The `admin-kubeconfig` secret provides connectivity to the hosted cluster's API server + +## Configuration + +HyperShift mode is enabled by setting environment variables on the cluster-olm-operator deployment: + +### Required Environment Variables + +| Variable | Description | Example | +|----------|-------------|---------| +| `HOSTED_KUBECONFIG_SECRET` | Name of the secret containing the hosted cluster's kubeconfig | `admin-kubeconfig` | +| `HOSTED_NAMESPACE` | The hosted control plane namespace in the management cluster | `clusters-customer1` | + +### Example Deployment Configuration + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: cluster-olm-operator + namespace: clusters-customer1 # Hosted control plane namespace +spec: + template: + spec: + containers: + - name: cluster-olm-operator + image: quay.io/openshift/origin-cluster-olm-operator:latest + env: + - name: HOSTED_KUBECONFIG_SECRET + value: admin-kubeconfig + - name: HOSTED_NAMESPACE + value: clusters-customer1 + # ... other environment variables ... +``` + +## How It Works + +When HyperShift mode is detected (via `HOSTED_KUBECONFIG_SECRET` environment variable): + +1. **Kubeconfig Injection Hook**: The `InjectHostedClusterKubeconfigHook` deployment hook is automatically applied to catalogd and operator-controller deployments + +2. **Volume Mounting**: The hook adds a volume referencing the kubeconfig secret: + ```yaml + volumes: + - name: hosted-kubeconfig + secret: + secretName: admin-kubeconfig # Value from HOSTED_KUBECONFIG_SECRET + ``` + +3. **Volume Mounts**: The kubeconfig is mounted into all containers: + ```yaml + volumeMounts: + - name: hosted-kubeconfig + mountPath: /var/run/secrets/kubeconfig + readOnly: true + ``` + +4. **Command-line Flags**: Additional arguments are added to containers: + ```yaml + args: + - --kubeconfig=/var/run/secrets/kubeconfig/kubeconfig + - --system-namespace=clusters-customer1 # Value from HOSTED_NAMESPACE + ``` + +## Components Affected + +The HyperShift configuration is automatically applied to: + +- **catalogd**: Watches ClusterCatalog resources in the hosted cluster's API server +- **operator-controller**: Watches ClusterExtension resources in the hosted cluster's API server and installs operators into hosted cluster worker nodes + +Both components continue to serve their control plane functions from the management cluster while interacting with hosted cluster API resources. + +## Upstream Requirements + +For HyperShift mode to work, the upstream components must support: + +- **catalogd**: `--kubeconfig` flag support ([catalogd PR #xyz](https://github.com/operator-framework/catalogd/pull/xyz)) +- **operator-controller**: `--kubeconfig` flag support ([operator-controller PR #xyz](https://github.com/operator-framework/operator-controller/pull/xyz)) +- Both components: `--system-namespace` flag to specify the namespace context + +## Detection and Logging + +When cluster-olm-operator starts in HyperShift mode: + +``` +I0312 10:15:23.123456 1 builder.go:150] HyperShift mode detected, injecting kubeconfig configuration deployment="catalogd" kubeconfigSecret="admin-kubeconfig" hostedNamespace="clusters-customer1" +I0312 10:15:23.234567 1 builder.go:150] HyperShift mode detected, injecting kubeconfig configuration deployment="operator-controller" kubeconfigSecret="admin-kubeconfig" hostedNamespace="clusters-customer1" +``` + +Individual deployment hooks also log their actions: + +``` +I0312 10:15:23.345678 1 builder.go:354] Injecting hosted cluster kubeconfig configuration deployment="catalogd" kubeconfigSecret="admin-kubeconfig" hostedNamespace="clusters-customer1" +I0312 10:15:23.456789 1 builder.go:380] Configured container container="catalogd" kubeconfigPath="/var/run/secrets/kubeconfig/kubeconfig" systemNamespace="clusters-customer1" +``` + +## Verification + +To verify cluster-olm-operator is running in HyperShift mode: + +1. Check environment variables: + ```bash + kubectl get deployment cluster-olm-operator -n clusters-customer1 -o yaml | grep -A2 HOSTED_ + ``` + +2. Check catalogd/operator-controller deployments for kubeconfig configuration: + ```bash + kubectl get deployment catalogd -n clusters-customer1 -o yaml | grep -A5 "hosted-kubeconfig" + kubectl get deployment operator-controller -n clusters-customer1 -o yaml | grep "kubeconfig" + ``` + +3. Verify components are watching the hosted cluster API: + ```bash + # Check catalogd logs + kubectl logs -n clusters-customer1 deployment/catalogd | grep "kubeconfig" + + # Check operator-controller logs + kubectl logs -n clusters-customer1 deployment/operator-controller | grep "kubeconfig" + ``` + +## Troubleshooting + +### Components not connecting to hosted cluster + +**Symptoms**: catalogd or operator-controller cannot list resources, API connection errors + +**Checks**: +1. Verify the `admin-kubeconfig` secret exists and is properly mounted +2. Check the secret contains a valid kubeconfig +3. Verify network connectivity from management cluster to hosted cluster API server +4. Check RBAC permissions in the kubeconfig + +### Missing environment variables + +**Symptoms**: Components use in-cluster config instead of hosted cluster kubeconfig + +**Solution**: Ensure both `HOSTED_KUBECONFIG_SECRET` and `HOSTED_NAMESPACE` environment variables are set on the cluster-olm-operator deployment + +### Hook not applied + +**Symptoms**: Deployments don't have kubeconfig volumes or --kubeconfig flags + +**Checks**: +1. Verify environment variables are set before cluster-olm-operator starts +2. Check cluster-olm-operator logs for "HyperShift mode detected" messages +3. Verify the deployment controller is processing deployments correctly + +## References + +- [HyperShift OLMv1 Design Proposal](https://github.com/openshift/enhancements/blob/master/enhancements/olm/hypershift-olmv1.md) +- [catalogd Documentation](https://github.com/operator-framework/catalogd) +- [operator-controller Documentation](https://github.com/operator-framework/operator-controller) +- [HyperShift Documentation](https://hypershift-docs.netlify.app/) diff --git a/pkg/clients/clients.go b/pkg/clients/clients.go index c65620811..c12fc84ce 100644 --- a/pkg/clients/clients.go +++ b/pkg/clients/clients.go @@ -4,12 +4,14 @@ import ( "context" "encoding/json" "fmt" + "os" "slices" "strings" "time" "k8s.io/apimachinery/pkg/api/equality" apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/client-go/tools/clientcmd" "k8s.io/utils/clock" configv1 "github.com/openshift/api/config/v1" @@ -72,31 +74,50 @@ type Clients struct { } func New(cc *controllercmd.ControllerContext) (*Clients, error) { + // Dual-API support for HyperShift: detect HYPERSHIFT_MODE and load hosted cluster config + mgmtConfig := cc.KubeConfig + hostedConfig := mgmtConfig + + if hypershiftMode := os.Getenv("HYPERSHIFT_MODE"); hypershiftMode == "true" { + if hostedKubeconfigPath := os.Getenv("HOSTED_KUBECONFIG"); hostedKubeconfigPath != "" { + var err error + hostedConfig, err = clientcmd.BuildConfigFromFlags("", hostedKubeconfigPath) + if err != nil { + return nil, fmt.Errorf("failed to load hosted kubeconfig from %s: %w", hostedKubeconfigPath, err) + } + } else { + return nil, fmt.Errorf("HYPERSHIFT_MODE is true but HOSTED_KUBECONFIG is not set") + } + } + kubeClient, err := kubernetes.NewForConfig(cc.ProtoKubeConfig) if err != nil { return nil, err } - apiExtensionsClient, err := apiextensionsclient.NewForConfig(cc.KubeConfig) + apiExtensionsClient, err := apiextensionsclient.NewForConfig(mgmtConfig) if err != nil { return nil, err } - dynClient, err := dynamic.NewForConfig(cc.KubeConfig) + // DynamicClient uses hostedConfig for ClusterCatalog/ClusterExtension operations + dynClient, err := dynamic.NewForConfig(hostedConfig) if err != nil { return nil, err } - httpClient, err := rest.HTTPClientFor(cc.KubeConfig) + // RESTMapper uses hostedConfig for resource discovery + httpClient, err := rest.HTTPClientFor(hostedConfig) if err != nil { return nil, err } - rm, err := apiutil.NewDynamicRESTMapper(cc.KubeConfig, httpClient) + rm, err := apiutil.NewDynamicRESTMapper(hostedConfig, httpClient) if err != nil { return nil, err } - operatorClientset, err := operatorclient.NewForConfig(cc.KubeConfig) + // OperatorClientset uses mgmtConfig for ClusterOperator status reporting + operatorClientset, err := operatorclient.NewForConfig(mgmtConfig) if err != nil { return nil, err } @@ -109,7 +130,8 @@ func New(cc *controllercmd.ControllerContext) (*Clients, error) { clock: clock.RealClock{}, } - configClient, err := configclient.NewForConfig(cc.KubeConfig) + // ConfigClient uses mgmtConfig for cluster configuration + configClient, err := configclient.NewForConfig(mgmtConfig) if err != nil { return nil, err } diff --git a/pkg/controller/builder.go b/pkg/controller/builder.go index 5d707271d..0e73a0b78 100644 --- a/pkg/controller/builder.go +++ b/pkg/controller/builder.go @@ -39,6 +39,16 @@ import ( catalogdv1 "github.com/operator-framework/catalogd/api/v1" ) +const ( + // HyperShift environment variables + HostedKubeconfigSecretEnv = "HOSTED_KUBECONFIG_SECRET" + HostedNamespaceEnv = "HOSTED_NAMESPACE" + + // Kubeconfig mount paths + kubeconfigMountPath = "/var/run/secrets/kubeconfig" + kubeconfigFilePath = "/var/run/secrets/kubeconfig/kubeconfig" +) + type Builder struct { Assets string Clients *clients.Clients @@ -47,6 +57,24 @@ type Builder struct { FeatureGate configv1.FeatureGate } +// IsHyperShiftMode returns true if the operator is running in HyperShift mode. +// HyperShift mode is detected by the presence of the HOSTED_KUBECONFIG_SECRET environment variable. +func (b *Builder) IsHyperShiftMode() bool { + return os.Getenv(HostedKubeconfigSecretEnv) != "" +} + +// GetHostedKubeconfigSecret returns the name of the secret containing the hosted cluster's kubeconfig. +// Returns empty string if not in HyperShift mode. +func (b *Builder) GetHostedKubeconfigSecret() string { + return os.Getenv(HostedKubeconfigSecretEnv) +} + +// GetHostedNamespace returns the hosted control plane namespace. +// Returns empty string if not in HyperShift mode. +func (b *Builder) GetHostedNamespace() string { + return os.Getenv(HostedNamespaceEnv) +} + func (b *Builder) BuildControllers(subDirectories ...string) (map[string]factory.Controller, map[string]factory.Controller, map[string]factory.Controller, []configv1.ObjectReference, error) { var ( staticResourceControllers = map[string]factory.Controller{} @@ -117,6 +145,25 @@ func (b *Builder) BuildControllers(subDirectories ...string) (map[string]factory if manifestGVK.Kind == "Deployment" && manifestGVK.Group == "apps" { controllerName := controllerNameForObject(namePrefix, &manifest) + + // Build deployment hooks based on configuration + deploymentHooks := []deploymentcontroller.DeploymentHookFunc{ + UpdateDeploymentProxyHook(b.Clients.ProxyClient), + } + + // Add HyperShift kubeconfig injection if in HyperShift mode + if b.IsHyperShiftMode() { + kubeconfigSecret := b.GetHostedKubeconfigSecret() + hostedNamespace := b.GetHostedNamespace() + log.Info("HyperShift mode detected, injecting kubeconfig configuration", + "deployment", manifest.GetName(), + "kubeconfigSecret", kubeconfigSecret, + "hostedNamespace", hostedNamespace) + deploymentHooks = append(deploymentHooks, + InjectHostedClusterKubeconfigHook(kubeconfigSecret, hostedNamespace), + ) + } + deploymentControllers[controllerName] = deploymentcontroller.NewDeploymentController( controllerName, manifestData, @@ -130,7 +177,7 @@ func (b *Builder) BuildControllers(subDirectories ...string) (map[string]factory []deploymentcontroller.ManifestHookFunc{ replaceVerbosityHook("${LOG_VERBOSITY}"), }, - UpdateDeploymentProxyHook(b.Clients.ProxyClient), + deploymentHooks..., ) return nil } @@ -317,3 +364,62 @@ func UpdateDeploymentProxyHook(pc clients.ProxyClientInterface) deploymentcontro return nil } } + +// InjectHostedClusterKubeconfigHook adds the necessary volume, volume mount, and command-line +// arguments to enable an OLMv1 component (catalogd or operator-controller) to watch the +// hosted cluster's API server instead of the management cluster. +// +// This hook is used when cluster-olm-operator runs in HyperShift mode (Approach 1: Control Plane Placement). +// It enables catalogd and operator-controller to connect to the hosted cluster's API server by: +// 1. Mounting the admin-kubeconfig secret as a volume +// 2. Adding volume mounts to all containers +// 3. Adding --kubeconfig and --system-namespace flags to all containers +func InjectHostedClusterKubeconfigHook(kubeconfigSecret, hostedNamespace string) deploymentcontroller.DeploymentHookFunc { + return func(_ *operatorv1.OperatorSpec, deployment *appsv1.Deployment) error { + log := klog.FromContext(context.Background()).WithName("builder").WithValues("deployment", deployment.Name) + log.V(1).Info("Injecting hosted cluster kubeconfig configuration", + "kubeconfigSecret", kubeconfigSecret, + "hostedNamespace", hostedNamespace) + + // Add kubeconfig volume from the specified secret + kubeconfigVolume := corev1.Volume{ + Name: "hosted-kubeconfig", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: kubeconfigSecret, + }, + }, + } + deployment.Spec.Template.Spec.Volumes = append( + deployment.Spec.Template.Spec.Volumes, + kubeconfigVolume, + ) + + // Add volume mount and command-line flags to all containers + for i := range deployment.Spec.Template.Spec.Containers { + container := &deployment.Spec.Template.Spec.Containers[i] + + // Mount kubeconfig + container.VolumeMounts = append(container.VolumeMounts, + corev1.VolumeMount{ + Name: "hosted-kubeconfig", + MountPath: kubeconfigMountPath, + ReadOnly: true, + }, + ) + + // Add command-line flags for kubeconfig and system namespace + container.Args = append(container.Args, + "--kubeconfig="+kubeconfigFilePath, + "--system-namespace="+hostedNamespace, + ) + + log.V(2).Info("Configured container", + "container", container.Name, + "kubeconfigPath", kubeconfigFilePath, + "systemNamespace", hostedNamespace) + } + + return nil + } +} diff --git a/pkg/controller/builder_test.go b/pkg/controller/builder_test.go index 63e90cd9f..27ec849a4 100644 --- a/pkg/controller/builder_test.go +++ b/pkg/controller/builder_test.go @@ -138,3 +138,256 @@ func validateEnvVarsOrFail(t *testing.T, in, expected []corev1.EnvVar) { } } } + +func TestIsHyperShiftMode(t *testing.T) { + tests := []struct { + name string + envValue string + expected bool + }{ + { + name: "HyperShift mode enabled", + envValue: "admin-kubeconfig", + expected: true, + }, + { + name: "HyperShift mode disabled - empty string", + envValue: "", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set environment variable + if tt.envValue != "" { + t.Setenv(HostedKubeconfigSecretEnv, tt.envValue) + } + + builder := &Builder{} + got := builder.IsHyperShiftMode() + if got != tt.expected { + t.Errorf("IsHyperShiftMode() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestGetHostedKubeconfigSecret(t *testing.T) { + tests := []struct { + name string + envValue string + expected string + }{ + { + name: "returns secret name", + envValue: "admin-kubeconfig", + expected: "admin-kubeconfig", + }, + { + name: "returns empty when not set", + envValue: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envValue != "" { + t.Setenv(HostedKubeconfigSecretEnv, tt.envValue) + } + + builder := &Builder{} + got := builder.GetHostedKubeconfigSecret() + if got != tt.expected { + t.Errorf("GetHostedKubeconfigSecret() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestGetHostedNamespace(t *testing.T) { + tests := []struct { + name string + envValue string + expected string + }{ + { + name: "returns namespace", + envValue: "clusters-customer1", + expected: "clusters-customer1", + }, + { + name: "returns empty when not set", + envValue: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envValue != "" { + t.Setenv(HostedNamespaceEnv, tt.envValue) + } + + builder := &Builder{} + got := builder.GetHostedNamespace() + if got != tt.expected { + t.Errorf("GetHostedNamespace() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestInjectHostedClusterKubeconfigHook(t *testing.T) { + tests := []struct { + name string + kubeconfigSecret string + hostedNamespace string + initialContainers []corev1.Container + initialVolumes []corev1.Volume + }{ + { + name: "injects kubeconfig into single container deployment", + kubeconfigSecret: "admin-kubeconfig", + hostedNamespace: "clusters-customer1", + initialContainers: []corev1.Container{ + { + Name: "catalogd", + Args: []string{"--some-flag"}, + }, + }, + initialVolumes: []corev1.Volume{}, + }, + { + name: "injects kubeconfig into multi-container deployment", + kubeconfigSecret: "admin-kubeconfig", + hostedNamespace: "clusters-test", + initialContainers: []corev1.Container{ + { + Name: "catalogd", + Args: []string{"--flag1"}, + }, + { + Name: "sidecar", + Args: []string{"--flag2"}, + }, + }, + initialVolumes: []corev1.Volume{ + { + Name: "existing-volume", + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{}, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-deployment", + Namespace: "test-namespace", + }, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: tt.initialContainers, + Volumes: tt.initialVolumes, + }, + }, + }, + } + + // Track initial arg counts before hook runs + initialArgCounts := make([]int, len(deployment.Spec.Template.Spec.Containers)) + for i := range deployment.Spec.Template.Spec.Containers { + initialArgCounts[i] = len(deployment.Spec.Template.Spec.Containers[i].Args) + } + + hook := InjectHostedClusterKubeconfigHook(tt.kubeconfigSecret, tt.hostedNamespace) + err := hook(nil, deployment) + if err != nil { + t.Fatalf("InjectHostedClusterKubeconfigHook() error = %v", err) + } + + // Verify volume was added + expectedVolumeCount := len(tt.initialVolumes) + 1 + if len(deployment.Spec.Template.Spec.Volumes) != expectedVolumeCount { + t.Errorf("Expected %d volumes, got %d", expectedVolumeCount, len(deployment.Spec.Template.Spec.Volumes)) + } + + // Find and verify the kubeconfig volume + var foundKubeconfigVolume bool + for _, vol := range deployment.Spec.Template.Spec.Volumes { + if vol.Name == "hosted-kubeconfig" { + foundKubeconfigVolume = true + if vol.Secret == nil { + t.Error("Expected secret volume source, got nil") + } else if vol.Secret.SecretName != tt.kubeconfigSecret { + t.Errorf("Expected secret name %s, got %s", tt.kubeconfigSecret, vol.Secret.SecretName) + } + break + } + } + if !foundKubeconfigVolume { + t.Error("hosted-kubeconfig volume not found") + } + + // Verify each container was configured + for i, container := range deployment.Spec.Template.Spec.Containers { + // Check volume mount + var foundVolumeMount bool + for _, vm := range container.VolumeMounts { + if vm.Name == "hosted-kubeconfig" { + foundVolumeMount = true + if vm.MountPath != kubeconfigMountPath { + t.Errorf("Container %s: expected mount path %s, got %s", container.Name, kubeconfigMountPath, vm.MountPath) + } + if !vm.ReadOnly { + t.Errorf("Container %s: expected ReadOnly=true for kubeconfig mount", container.Name) + } + break + } + } + if !foundVolumeMount { + t.Errorf("Container %s: hosted-kubeconfig volume mount not found", container.Name) + } + + // Check args - should have original args plus 2 new ones + expectedArgCount := initialArgCounts[i] + 2 + if len(container.Args) != expectedArgCount { + t.Errorf("Container %s: expected %d args, got %d (initial: %d)", container.Name, expectedArgCount, len(container.Args), initialArgCounts[i]) + } + + // Verify kubeconfig flag + kubeconfigFlag := "--kubeconfig=" + kubeconfigFilePath + var foundKubeconfigFlag bool + for _, arg := range container.Args { + if arg == kubeconfigFlag { + foundKubeconfigFlag = true + break + } + } + if !foundKubeconfigFlag { + t.Errorf("Container %s: expected arg %s not found in %v", container.Name, kubeconfigFlag, container.Args) + } + + // Verify system-namespace flag + namespaceFlag := "--system-namespace=" + tt.hostedNamespace + var foundNamespaceFlag bool + for _, arg := range container.Args { + if arg == namespaceFlag { + foundNamespaceFlag = true + break + } + } + if !foundNamespaceFlag { + t.Errorf("Container %s: expected arg %s not found in %v", container.Name, namespaceFlag, container.Args) + } + } + }) + } +}