From e61fb8b3bdceaa3b5b376856e8b5fb5cffcc5a22 Mon Sep 17 00:00:00 2001 From: Bryan Cox Date: Mon, 13 Apr 2026 08:34:42 -0400 Subject: [PATCH 1/7] test: Add unit tests for v2 CAPI controller packages Add behavior-driven unit tests for the CAPI manager and CAPI provider v2 controller packages covering predicates, deployment adaptation, secret management, and role configuration. Co-Authored-By: Claude Opus 4.6 --- .../v2/capi_manager/component_test.go | 89 +++++++ .../v2/capi_manager/deployment_test.go | 153 ++++++++++++ .../v2/capi_manager/secret_test.go | 150 +++++++++++ .../v2/capi_provider/component_test.go | 100 ++++++++ .../v2/capi_provider/deployment_test.go | 233 ++++++++++++++++++ .../v2/capi_provider/role_test.go | 215 ++++++++++++++++ 6 files changed, 940 insertions(+) create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/capi_manager/component_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/capi_manager/deployment_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/capi_manager/secret_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/capi_provider/component_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/capi_provider/deployment_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/capi_provider/role_test.go diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/capi_manager/component_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/capi_manager/component_test.go new file mode 100644 index 00000000000..0bb0c18f10e --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/capi_manager/component_test.go @@ -0,0 +1,89 @@ +package capimanager + +import ( + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + component "github.com/openshift/hypershift/support/controlplane-component" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestPredicate(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + hcpAnnotations map[string]string + expected bool + }{ + { + name: "When HCP has no annotations, it should return true", + expected: true, + }, + { + name: "When HCP has DisableMachineManagement annotation, it should return false", + hcpAnnotations: map[string]string{ + hyperv1.DisableMachineManagement: "true", + }, + expected: false, + }, + { + name: "When HCP has other annotations but not DisableMachineManagement, it should return true", + hcpAnnotations: map[string]string{ + "some.other/annotation": "value", + }, + expected: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + Annotations: tc.hcpAnnotations, + }, + } + + cpContext := component.WorkloadContext{ + Context: t.Context(), + HCP: hcp, + } + + result, err := predicate(cpContext) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(result).To(Equal(tc.expected)) + }) + } +} + +func TestCAPIManagerOptions_IsRequestServing(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + capi := &CAPIManagerOptions{} + g.Expect(capi.IsRequestServing()).To(BeFalse()) +} + +func TestCAPIManagerOptions_MultiZoneSpread(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + capi := &CAPIManagerOptions{} + g.Expect(capi.MultiZoneSpread()).To(BeFalse()) +} + +func TestCAPIManagerOptions_NeedsManagementKASAccess(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + capi := &CAPIManagerOptions{} + g.Expect(capi.NeedsManagementKASAccess()).To(BeTrue()) +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/capi_manager/deployment_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/capi_manager/deployment_test.go new file mode 100644 index 00000000000..2463de9c7fb --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/capi_manager/deployment_test.go @@ -0,0 +1,153 @@ +package capimanager + +import ( + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + assets "github.com/openshift/hypershift/control-plane-operator/controllers/hostedcontrolplane/v2/assets" + component "github.com/openshift/hypershift/support/controlplane-component" + "github.com/openshift/hypershift/support/testutil" + "github.com/openshift/hypershift/support/util" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestAdaptDeployment(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + imageOverride string + version string + hcpAnnotations map[string]string + expectedArgs []string + unexpectedArgs []string + expectedImage string + expectedAnnotKey string + }{ + { + name: "When version is 4.19.0, it should add MachineSetPreflightChecks feature gate", + version: "4.19.0", + expectedArgs: []string{"--feature-gates=MachineSetPreflightChecks=false"}, + expectedImage: "cluster-capi-controllers", + }, + { + name: "When version is 4.20.0, it should add MachineSetPreflightChecks feature gate", + version: "4.20.0", + expectedArgs: []string{"--feature-gates=MachineSetPreflightChecks=false"}, + expectedImage: "cluster-capi-controllers", + }, + { + name: "When version is 4.18.0, it should not add MachineSetPreflightChecks feature gate", + version: "4.18.0", + expectedArgs: []string{}, + unexpectedArgs: []string{"--feature-gates=MachineSetPreflightChecks=false"}, + expectedImage: "cluster-capi-controllers", + }, + { + name: "When imageOverride is set, it should use the override image", + version: "4.19.0", + imageOverride: "quay.io/custom/capi:v1.0.0", + expectedArgs: []string{"--feature-gates=MachineSetPreflightChecks=false"}, + expectedImage: "quay.io/custom/capi:v1.0.0", + }, + { + name: "When HCP has hosted cluster annotation, it should set deployment annotation", + version: "4.19.0", + hcpAnnotations: map[string]string{ + util.HostedClusterAnnotation: "test-namespace/test-cluster", + }, + expectedArgs: []string{"--feature-gates=MachineSetPreflightChecks=false"}, + expectedImage: "cluster-capi-controllers", + expectedAnnotKey: util.HostedClusterAnnotation, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + Annotations: tc.hcpAnnotations, + }, + } + + deployment, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + releaseProvider := testutil.FakeImageProvider(testutil.WithVersion(tc.version)) + + capi := &CAPIManagerOptions{ + imageOverride: tc.imageOverride, + } + + cpContext := component.WorkloadContext{ + Context: t.Context(), + HCP: hcp, + ReleaseImageProvider: releaseProvider, + SkipCertificateSigning: false, + } + + err = capi.adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + // Find the manager container + managerContainer := util.FindContainer("manager", deployment.Spec.Template.Spec.Containers) + g.Expect(managerContainer).ToNot(BeNil()) + + // Check expected args + for _, expectedArg := range tc.expectedArgs { + g.Expect(managerContainer.Args).To(ContainElement(expectedArg)) + } + + // Check unexpected args are absent + for _, unexpectedArg := range tc.unexpectedArgs { + g.Expect(managerContainer.Args).ToNot(ContainElement(unexpectedArg)) + } + + // Check image + g.Expect(managerContainer.Image).To(Equal(tc.expectedImage)) + + // Check annotations + if tc.expectedAnnotKey != "" { + g.Expect(deployment.Annotations).To(HaveKey(tc.expectedAnnotKey)) + g.Expect(deployment.Annotations[tc.expectedAnnotKey]).To(Equal(hcp.Annotations[tc.expectedAnnotKey])) + } + }) + } +} + +func TestAdaptDeployment_ParseVersionError(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + } + + deployment, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + releaseProvider := testutil.FakeImageProvider(testutil.WithVersion("invalid-version")) + + capi := &CAPIManagerOptions{} + + cpContext := component.WorkloadContext{ + Context: t.Context(), + HCP: hcp, + ReleaseImageProvider: releaseProvider, + } + + err = capi.adaptDeployment(cpContext, deployment) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("failed to parse version")) +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/capi_manager/secret_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/capi_manager/secret_test.go new file mode 100644 index 00000000000..e86dd00e996 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/capi_manager/secret_test.go @@ -0,0 +1,150 @@ +package capimanager + +import ( + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + component "github.com/openshift/hypershift/support/controlplane-component" + "github.com/openshift/hypershift/support/util" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestAdaptWebhookTLSSecret(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + existingData map[string][]byte + skipCertificateSigning bool + hcpAnnotations map[string]string + validate func(*testing.T, *WithT, *corev1.Secret, error) + }{ + { + name: "When existing certificate is present, it should preserve it", + existingData: map[string][]byte{ + corev1.TLSCertKey: []byte("existing-cert"), + corev1.TLSPrivateKeyKey: []byte("existing-key"), + }, + validate: func(t *testing.T, g *WithT, secret *corev1.Secret, err error) { + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(secret.Data[corev1.TLSCertKey]).To(Equal([]byte("existing-cert"))) + g.Expect(secret.Data[corev1.TLSPrivateKeyKey]).To(Equal([]byte("existing-key"))) + }, + }, + { + name: "When existing cert is present but key is missing, it should generate new cert and key", + existingData: map[string][]byte{ + corev1.TLSCertKey: []byte("existing-cert"), + }, + validate: func(t *testing.T, g *WithT, secret *corev1.Secret, err error) { + g.Expect(err).ToNot(HaveOccurred()) + // Should generate new cert and key, overwriting the partial data + g.Expect(secret.Data[corev1.TLSCertKey]).ToNot(BeEmpty()) + g.Expect(secret.Data[corev1.TLSCertKey]).ToNot(Equal([]byte("existing-cert"))) + g.Expect(secret.Data[corev1.TLSPrivateKeyKey]).ToNot(BeEmpty()) + }, + }, + { + name: "When existing key is present but cert is missing, it should generate new cert and key", + existingData: map[string][]byte{ + corev1.TLSPrivateKeyKey: []byte("existing-key"), + }, + validate: func(t *testing.T, g *WithT, secret *corev1.Secret, err error) { + g.Expect(err).ToNot(HaveOccurred()) + // Should generate new cert and key, overwriting the partial data + g.Expect(secret.Data[corev1.TLSCertKey]).ToNot(BeEmpty()) + g.Expect(secret.Data[corev1.TLSPrivateKeyKey]).ToNot(BeEmpty()) + g.Expect(secret.Data[corev1.TLSPrivateKeyKey]).ToNot(Equal([]byte("existing-key"))) + }, + }, + { + name: "When skip certificate signing is enabled, it should not generate cert", + existingData: map[string][]byte{}, + skipCertificateSigning: true, + validate: func(t *testing.T, g *WithT, secret *corev1.Secret, err error) { + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(secret.Data[corev1.TLSCertKey]).To(BeEmpty()) + g.Expect(secret.Data[corev1.TLSPrivateKeyKey]).To(BeEmpty()) + }, + }, + { + name: "When no existing cert and signing is enabled, it should generate self-signed cert", + existingData: map[string][]byte{}, + validate: func(t *testing.T, g *WithT, secret *corev1.Secret, err error) { + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(secret.Data[corev1.TLSCertKey]).ToNot(BeEmpty()) + g.Expect(secret.Data[corev1.TLSPrivateKeyKey]).ToNot(BeEmpty()) + }, + }, + { + name: "When secret has no data field, it should create data map and generate cert", + existingData: nil, + validate: func(t *testing.T, g *WithT, secret *corev1.Secret, err error) { + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(secret.Data).ToNot(BeNil()) + g.Expect(secret.Data[corev1.TLSCertKey]).ToNot(BeEmpty()) + g.Expect(secret.Data[corev1.TLSPrivateKeyKey]).ToNot(BeEmpty()) + }, + }, + { + name: "When HCP has hosted cluster annotation, it should set secret annotation", + existingData: map[string][]byte{}, + hcpAnnotations: map[string]string{ + util.HostedClusterAnnotation: "test-namespace/test-cluster", + }, + validate: func(t *testing.T, g *WithT, secret *corev1.Secret, err error) { + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(secret.Annotations).To(HaveKey(util.HostedClusterAnnotation)) + g.Expect(secret.Annotations[util.HostedClusterAnnotation]).To(Equal("test-namespace/test-cluster")) + }, + }, + { + name: "When secret has no annotations field, it should create annotations map", + existingData: map[string][]byte{}, + hcpAnnotations: map[string]string{ + util.HostedClusterAnnotation: "test-namespace/test-cluster", + }, + validate: func(t *testing.T, g *WithT, secret *corev1.Secret, err error) { + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(secret.Annotations).ToNot(BeNil()) + g.Expect(secret.Annotations[util.HostedClusterAnnotation]).To(Equal("test-namespace/test-cluster")) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "capi-webhooks-tls", + Namespace: "test-namespace", + }, + Data: tc.existingData, + } + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + Annotations: tc.hcpAnnotations, + }, + } + + cpContext := component.WorkloadContext{ + Context: t.Context(), + HCP: hcp, + SkipCertificateSigning: tc.skipCertificateSigning, + } + + err := adaptWebhookTLSSecret(cpContext, secret) + tc.validate(t, g, secret, err) + }) + } +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/capi_provider/component_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/capi_provider/component_test.go new file mode 100644 index 00000000000..4ef8ec44b34 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/capi_provider/component_test.go @@ -0,0 +1,100 @@ +package capiprovider + +import ( + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + component "github.com/openshift/hypershift/support/controlplane-component" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestPredicate(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + hcpAnnotations map[string]string + expected bool + }{ + { + name: "When HCP has no annotations, it should return true", + expected: true, + }, + { + name: "When HCP has DisableMachineManagement annotation, it should return false", + hcpAnnotations: map[string]string{ + hyperv1.DisableMachineManagement: "true", + }, + expected: false, + }, + { + name: "When HCP has other annotations but not DisableMachineManagement, it should return true", + hcpAnnotations: map[string]string{ + "some.other/annotation": "value", + }, + expected: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + Annotations: tc.hcpAnnotations, + }, + } + + cpContext := component.WorkloadContext{ + Context: t.Context(), + HCP: hcp, + } + + result, err := predicate(cpContext) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(result).To(Equal(tc.expected)) + }) + } +} + +func TestCAPIProviderOptions_IsRequestServing(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + capi := &CAPIProviderOptions{} + g.Expect(capi.IsRequestServing()).To(BeFalse()) +} + +func TestCAPIProviderOptions_MultiZoneSpread(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + capi := &CAPIProviderOptions{} + g.Expect(capi.MultiZoneSpread()).To(BeFalse()) +} + +func TestCAPIProviderOptions_NeedsManagementKASAccess(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + capi := &CAPIProviderOptions{} + g.Expect(capi.NeedsManagementKASAccess()).To(BeTrue()) +} + +func TestLabels(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + result := labels() + + g.Expect(result).To(HaveKeyWithValue("control-plane", "capi-provider-controller-manager")) + g.Expect(result).To(HaveKeyWithValue("app", "capi-provider-controller-manager")) + g.Expect(result).To(HaveLen(2)) +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/capi_provider/deployment_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/capi_provider/deployment_test.go new file mode 100644 index 00000000000..42f02cead25 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/capi_provider/deployment_test.go @@ -0,0 +1,233 @@ +package capiprovider + +import ( + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + assets "github.com/openshift/hypershift/control-plane-operator/controllers/hostedcontrolplane/v2/assets" + component "github.com/openshift/hypershift/support/controlplane-component" + "github.com/openshift/hypershift/support/util" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" +) + +func TestAdaptDeployment(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + deploymentSpec *appsv1.DeploymentSpec + hcpAnnotations map[string]string + expectedServiceAcct string + expectedLabels map[string]string + expectedAnnotKey string + }{ + { + name: "When deployment spec is provided, it should apply the spec to deployment", + deploymentSpec: &appsv1.DeploymentSpec{ + Replicas: ptr.To[int32](3), + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "provider", + Image: "test-image:v1.0.0", + }, + }, + }, + }, + }, + expectedServiceAcct: "capi-provider", + expectedLabels: map[string]string{ + "control-plane": "capi-provider-controller-manager", + "app": "capi-provider-controller-manager", + }, + }, + { + name: "When HCP has hosted cluster annotation, it should set deployment annotation", + deploymentSpec: &appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "provider", + Image: "test-image:v1.0.0", + }, + }, + }, + }, + }, + hcpAnnotations: map[string]string{ + util.HostedClusterAnnotation: "test-namespace/test-cluster", + }, + expectedServiceAcct: "capi-provider", + expectedLabels: map[string]string{ + "control-plane": "capi-provider-controller-manager", + "app": "capi-provider-controller-manager", + }, + expectedAnnotKey: util.HostedClusterAnnotation, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + Annotations: tc.hcpAnnotations, + }, + } + + deployment, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + capi := &CAPIProviderOptions{ + deploymentSpec: tc.deploymentSpec, + } + + cpContext := component.WorkloadContext{ + Context: t.Context(), + HCP: hcp, + } + + err = capi.adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + // Check that spec was applied + if tc.deploymentSpec.Replicas != nil { + g.Expect(deployment.Spec.Replicas).To(Equal(tc.deploymentSpec.Replicas)) + } + + // Check selector + g.Expect(deployment.Spec.Selector).ToNot(BeNil()) + g.Expect(deployment.Spec.Selector.MatchLabels).To(Equal(tc.expectedLabels)) + + // Check template labels + g.Expect(deployment.Spec.Template.Labels).To(Equal(tc.expectedLabels)) + + // Check service account + g.Expect(deployment.Spec.Template.Spec.ServiceAccountName).To(Equal(tc.expectedServiceAcct)) + + // Check annotations + if tc.expectedAnnotKey != "" { + g.Expect(deployment.Annotations).To(HaveKey(tc.expectedAnnotKey)) + g.Expect(deployment.Annotations[tc.expectedAnnotKey]).To(Equal(hcp.Annotations[tc.expectedAnnotKey])) + } + }) + } +} + +func TestAdaptDeployment_WithProxyEnvVars(t *testing.T) { + // Cannot use t.Parallel() because this test uses t.Setenv + g := NewWithT(t) + + // Set proxy environment variables + t.Setenv("HTTP_PROXY", "http://proxy.example.com:8080") + t.Setenv("HTTPS_PROXY", "https://proxy.example.com:8443") + t.Setenv("NO_PROXY", "localhost,127.0.0.1") + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + } + + deployment, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + deploymentSpec := &appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "provider", + Image: "test-image:v1.0.0", + Env: []corev1.EnvVar{}, + }, + }, + }, + }, + } + + capi := &CAPIProviderOptions{ + deploymentSpec: deploymentSpec, + } + + cpContext := component.WorkloadContext{ + Context: t.Context(), + HCP: hcp, + } + + err = capi.adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + // Check that proxy env vars are set on the container + container := deployment.Spec.Template.Spec.Containers[0] + envNames := make([]string, 0, len(container.Env)) + for _, env := range container.Env { + envNames = append(envNames, env.Name) + } + g.Expect(envNames).To(ContainElement("HTTP_PROXY")) + g.Expect(envNames).To(ContainElement("HTTPS_PROXY")) + g.Expect(envNames).To(ContainElement("NO_PROXY")) +} + +func TestAdaptDeployment_WithNilAnnotations(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + Annotations: map[string]string{ + util.HostedClusterAnnotation: "test-namespace/test-cluster", + }, + }, + } + + deployment, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + // Ensure deployment has nil annotations to test initialization + deployment.Annotations = nil + + deploymentSpec := &appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "provider", + Image: "test-image:v1.0.0", + }, + }, + }, + }, + } + + capi := &CAPIProviderOptions{ + deploymentSpec: deploymentSpec, + } + + cpContext := component.WorkloadContext{ + Context: t.Context(), + HCP: hcp, + } + + err = capi.adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + // Should create annotations map and set the annotation + g.Expect(deployment.Annotations).ToNot(BeNil()) + g.Expect(deployment.Annotations[util.HostedClusterAnnotation]).To(Equal("test-namespace/test-cluster")) +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/capi_provider/role_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/capi_provider/role_test.go new file mode 100644 index 00000000000..8495ebdadc4 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/capi_provider/role_test.go @@ -0,0 +1,215 @@ +package capiprovider + +import ( + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + component "github.com/openshift/hypershift/support/controlplane-component" + "github.com/openshift/hypershift/support/util" + + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestAdaptRole(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + existingRules []rbacv1.PolicyRule + platformPolicyRules []rbacv1.PolicyRule + hcpAnnotations map[string]string + expectedTotalRules int + expectedAnnotKey string + expectedAnnotValue string + shouldAppendPlatform bool + }{ + { + name: "When platform policy rules are provided, it should append them to role", + existingRules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + Verbs: []string{"get", "list"}, + }, + }, + platformPolicyRules: []rbacv1.PolicyRule{ + { + APIGroups: []string{"cluster.x-k8s.io"}, + Resources: []string{"awsmachines"}, + Verbs: []string{"get", "list", "watch"}, + }, + { + APIGroups: []string{"infrastructure.cluster.x-k8s.io"}, + Resources: []string{"awsclusters"}, + Verbs: []string{"get", "list", "watch", "update"}, + }, + }, + expectedTotalRules: 3, + shouldAppendPlatform: true, + }, + { + name: "When platform policy rules are nil, it should not append any rules", + existingRules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + Verbs: []string{"get", "list"}, + }, + }, + platformPolicyRules: nil, + expectedTotalRules: 1, + shouldAppendPlatform: false, + }, + { + name: "When platform policy rules are empty, it should not append any rules", + existingRules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + Verbs: []string{"get", "list"}, + }, + }, + platformPolicyRules: []rbacv1.PolicyRule{}, + expectedTotalRules: 1, + shouldAppendPlatform: false, + }, + { + name: "When HCP has hosted cluster annotation, it should set role annotation", + existingRules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + Verbs: []string{"get", "list"}, + }, + }, + platformPolicyRules: []rbacv1.PolicyRule{ + { + APIGroups: []string{"cluster.x-k8s.io"}, + Resources: []string{"awsmachines"}, + Verbs: []string{"get", "list", "watch"}, + }, + }, + hcpAnnotations: map[string]string{ + util.HostedClusterAnnotation: "test-namespace/test-cluster", + }, + expectedTotalRules: 2, + expectedAnnotKey: util.HostedClusterAnnotation, + expectedAnnotValue: "test-namespace/test-cluster", + shouldAppendPlatform: true, + }, + { + name: "When role has no existing rules, it should append platform rules", + existingRules: []rbacv1.PolicyRule{}, + platformPolicyRules: []rbacv1.PolicyRule{ + { + APIGroups: []string{"cluster.x-k8s.io"}, + Resources: []string{"azuremachines"}, + Verbs: []string{"get", "list", "watch"}, + }, + }, + expectedTotalRules: 1, + shouldAppendPlatform: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + role := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: "capi-provider", + Namespace: "test-namespace", + }, + Rules: tc.existingRules, + } + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + Annotations: tc.hcpAnnotations, + }, + } + + capi := &CAPIProviderOptions{ + platformPolicyRules: tc.platformPolicyRules, + } + + cpContext := component.WorkloadContext{ + Context: t.Context(), + HCP: hcp, + } + + err := capi.adaptRole(cpContext, role) + g.Expect(err).ToNot(HaveOccurred()) + + // Check total number of rules + g.Expect(role.Rules).To(HaveLen(tc.expectedTotalRules)) + + // Verify platform rules were appended if expected + if tc.shouldAppendPlatform && len(tc.platformPolicyRules) > 0 { + // The last N rules should match the platform rules + startIdx := len(tc.existingRules) + for i, platformRule := range tc.platformPolicyRules { + g.Expect(role.Rules[startIdx+i]).To(Equal(platformRule)) + } + } + + // Check annotations + if tc.expectedAnnotKey != "" { + g.Expect(role.Annotations).To(HaveKey(tc.expectedAnnotKey)) + g.Expect(role.Annotations[tc.expectedAnnotKey]).To(Equal(tc.expectedAnnotValue)) + } + }) + } +} + +func TestAdaptRole_WithNilAnnotations(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + role := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: "capi-provider", + Namespace: "test-namespace", + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + Verbs: []string{"get", "list"}, + }, + }, + } + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + Annotations: map[string]string{ + util.HostedClusterAnnotation: "test-namespace/test-cluster", + }, + }, + } + + capi := &CAPIProviderOptions{ + platformPolicyRules: nil, + } + + cpContext := component.WorkloadContext{ + Context: t.Context(), + HCP: hcp, + } + + err := capi.adaptRole(cpContext, role) + g.Expect(err).ToNot(HaveOccurred()) + + // Should create annotations map and set the annotation + g.Expect(role.Annotations).ToNot(BeNil()) + g.Expect(role.Annotations[util.HostedClusterAnnotation]).To(Equal("test-namespace/test-cluster")) +} From eb5159760c408b7b869eaf5063710d3bbca5b8e1 Mon Sep 17 00:00:00 2001 From: Bryan Cox Date: Mon, 13 Apr 2026 08:34:47 -0400 Subject: [PATCH 2/7] test: Add unit tests for v2 cloud provider controller packages Add behavior-driven unit tests for the PowerVS cloud controller manager and cloud credential operator v2 controller packages covering config adaptation, deployment adaptation, and platform predicates. Co-Authored-By: Claude Opus 4.6 --- .../powervs/component_test.go | 126 +++++++++ .../powervs/config_test.go | 242 +++++++++++++++++ .../powervs/deployment_test.go | 250 ++++++++++++++++++ .../testdata/zz_fixture_TestAdaptConfig.yaml | 23 ++ .../component_test.go | 113 ++++++++ .../deployment_test.go | 199 ++++++++++++++ 6 files changed, 953 insertions(+) create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/cloud_controller_manager/powervs/component_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/cloud_controller_manager/powervs/config_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/cloud_controller_manager/powervs/deployment_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/cloud_controller_manager/powervs/testdata/zz_fixture_TestAdaptConfig.yaml create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/cloud_credential_operator/component_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/cloud_credential_operator/deployment_test.go diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/cloud_controller_manager/powervs/component_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/cloud_controller_manager/powervs/component_test.go new file mode 100644 index 00000000000..338a14230c7 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/cloud_controller_manager/powervs/component_test.go @@ -0,0 +1,126 @@ +package powervs + +import ( + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + component "github.com/openshift/hypershift/support/controlplane-component" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestPredicate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + hcp *hyperv1.HostedControlPlane + expected bool + }{ + { + name: "When platform type is PowerVS, it should return true", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Platform: hyperv1.PlatformSpec{ + Type: hyperv1.PowerVSPlatform, + }, + }, + }, + expected: true, + }, + { + name: "When platform type is AWS, it should return false", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Platform: hyperv1.PlatformSpec{ + Type: hyperv1.AWSPlatform, + }, + }, + }, + expected: false, + }, + { + name: "When platform type is Azure, it should return false", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Platform: hyperv1.PlatformSpec{ + Type: hyperv1.AzurePlatform, + }, + }, + }, + expected: false, + }, + { + name: "When platform type is KubeVirt, it should return false", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Platform: hyperv1.PlatformSpec{ + Type: hyperv1.KubevirtPlatform, + }, + }, + }, + expected: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + cpContext := component.WorkloadContext{ + HCP: tc.hcp, + } + + result, err := predicate(cpContext) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(result).To(Equal(tc.expected)) + }) + } +} + +func TestPowerVSOptions(t *testing.T) { + t.Parallel() + + t.Run("When IsRequestServing is called, it should return false", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + opts := &powervsOptions{} + g.Expect(opts.IsRequestServing()).To(BeFalse()) + }) + + t.Run("When MultiZoneSpread is called, it should return true", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + opts := &powervsOptions{} + g.Expect(opts.MultiZoneSpread()).To(BeTrue()) + }) + + t.Run("When NeedsManagementKASAccess is called, it should return false", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + opts := &powervsOptions{} + g.Expect(opts.NeedsManagementKASAccess()).To(BeFalse()) + }) +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/cloud_controller_manager/powervs/config_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/cloud_controller_manager/powervs/config_test.go new file mode 100644 index 00000000000..ef3a3e1a277 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/cloud_controller_manager/powervs/config_test.go @@ -0,0 +1,242 @@ +package powervs + +import ( + "strings" + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + assets "github.com/openshift/hypershift/control-plane-operator/controllers/hostedcontrolplane/v2/assets" + "github.com/openshift/hypershift/control-plane-operator/hostedclusterconfigoperator/api" + component "github.com/openshift/hypershift/support/controlplane-component" + "github.com/openshift/hypershift/support/testutil" + "github.com/openshift/hypershift/support/util" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestAdaptConfig(t *testing.T) { + t.Parallel() + + hcp := newTestHCP() + hcp.Namespace = "HCP_NAMESPACE" + + cm := &corev1.ConfigMap{} + _, _, err := assets.LoadManifestInto(ComponentName, "config.yaml", cm) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + cpContext := component.WorkloadContext{ + HCP: hcp, + } + err = adaptConfig(cpContext, cm) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + yaml, err := util.SerializeResource(cm, api.Scheme) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + testutil.CompareWithFixture(t, yaml) +} + +func TestAdaptConfigTemplateExecution(t *testing.T) { + t.Parallel() + + t.Run("When PowerVS platform is configured, it should populate template with correct values", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + hcp := newTestHCP() + hcp.Spec.Platform.PowerVS.AccountID = "account-123" + hcp.Spec.Platform.PowerVS.ServiceInstanceID = "service-instance-456" + hcp.Spec.Platform.PowerVS.Region = "us-south" + hcp.Spec.Platform.PowerVS.Zone = "us-south-1" + hcp.Spec.Platform.PowerVS.ResourceGroup = "my-resource-group" + hcp.Spec.Platform.PowerVS.VPC = &hyperv1.PowerVSVPC{ + Name: "my-vpc", + Region: "us-south", + Subnet: "my-subnet", + } + + cm := &corev1.ConfigMap{ + Data: map[string]string{ + configKey: `ClusterID={{.ClusterID}},AccountID={{.AccountID}},ServiceInstanceID={{.PowerVSCloudInstanceID}},Region={{.Region}},PowerVSRegion={{.PowerVSRegion}},PowerVSZone={{.PowerVSZone}},ResourceGroup={{.G2ResourceGroupName}},VPCName={{.G2VpcName}},SubnetNames={{.G2VpcSubnetNames}}`, + }, + } + + cpContext := component.WorkloadContext{ + HCP: hcp, + } + err := adaptConfig(cpContext, cm) + g.Expect(err).ToNot(HaveOccurred()) + + result := cm.Data[configKey] + g.Expect(result).To(ContainSubstring("ClusterID=test-cluster")) + g.Expect(result).To(ContainSubstring("AccountID=account-123")) + g.Expect(result).To(ContainSubstring("ServiceInstanceID=service-instance-456")) + g.Expect(result).To(ContainSubstring("Region=us-south")) + g.Expect(result).To(ContainSubstring("PowerVSRegion=us-south")) + g.Expect(result).To(ContainSubstring("PowerVSZone=us-south-1")) + g.Expect(result).To(ContainSubstring("ResourceGroup=my-resource-group")) + g.Expect(result).To(ContainSubstring("VPCName=my-vpc")) + g.Expect(result).To(ContainSubstring("SubnetNames=my-subnet")) + }) +} + +func TestAdaptConfigErrorStates(t *testing.T) { + t.Parallel() + + t.Run("When PowerVS platform is nil, it should return error", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Platform: hyperv1.PlatformSpec{ + Type: hyperv1.PowerVSPlatform, + PowerVS: nil, + }, + }, + } + + cm := &corev1.ConfigMap{ + Data: map[string]string{ + configKey: "{{.ClusterID}}", + }, + } + + cpContext := component.WorkloadContext{ + HCP: hcp, + } + err := adaptConfig(cpContext, cm) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(".spec.platform.powervs is not defined")) + }) + + t.Run("When VPC is nil, it should panic", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + hcp := newTestHCP() + hcp.Spec.Platform.PowerVS.VPC = nil + + cm := &corev1.ConfigMap{ + Data: map[string]string{ + configKey: "{{.ClusterID}}", + }, + } + + cpContext := component.WorkloadContext{ + HCP: hcp, + } + g.Expect(func() { _ = adaptConfig(cpContext, cm) }).To(Panic()) + }) +} + +func TestConfigKeyConstant(t *testing.T) { + t.Parallel() + + t.Run("When configKey is used, it should match expected value", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + g.Expect(configKey).To(Equal("ccm-config")) + }) +} + +func TestAdaptConfigMapDataStructure(t *testing.T) { + t.Parallel() + + t.Run("When PowerVS config is complete, it should build config map with all fields", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + hcp := newTestHCP() + hcp.Name = "my-cluster" + hcp.Namespace = "test-ns" + hcp.Spec.Platform.PowerVS.AccountID = "acc-id" + hcp.Spec.Platform.PowerVS.ServiceInstanceID = "svc-instance" + hcp.Spec.Platform.PowerVS.Region = "eu-gb" + hcp.Spec.Platform.PowerVS.Zone = "eu-gb-1" + hcp.Spec.Platform.PowerVS.ResourceGroup = "rg-test" + hcp.Spec.Platform.PowerVS.VPC = &hyperv1.PowerVSVPC{ + Name: "vpc-name", + Region: "eu-gb", + Subnet: "subnet-1", + } + + templateContent := strings.Join([]string{ + "AccountID={{.AccountID}}", + "G2workerServiceAccountID={{.G2workerServiceAccountID}}", + "G2ResourceGroupName={{.G2ResourceGroupName}}", + "G2VpcSubnetNames={{.G2VpcSubnetNames}}", + "G2VpcName={{.G2VpcName}}", + "ClusterID={{.ClusterID}}", + "Region={{.Region}}", + "PowerVSCloudInstanceID={{.PowerVSCloudInstanceID}}", + "PowerVSRegion={{.PowerVSRegion}}", + "PowerVSZone={{.PowerVSZone}}", + }, "\n") + + cm := &corev1.ConfigMap{ + Data: map[string]string{ + configKey: templateContent, + }, + } + + cpContext := component.WorkloadContext{ + HCP: hcp, + } + err := adaptConfig(cpContext, cm) + g.Expect(err).ToNot(HaveOccurred()) + + result := cm.Data[configKey] + g.Expect(result).To(Equal(strings.Join([]string{ + "AccountID=acc-id", + "G2workerServiceAccountID=acc-id", + "G2ResourceGroupName=rg-test", + "G2VpcSubnetNames=subnet-1", + "G2VpcName=vpc-name", + "ClusterID=my-cluster", + "Region=eu-gb", + "PowerVSCloudInstanceID=svc-instance", + "PowerVSRegion=eu-gb", + "PowerVSZone=eu-gb-1", + }, "\n"))) + }) +} + +// newTestHCP creates a HostedControlPlane with default PowerVS configuration for testing. +func newTestHCP() *hyperv1.HostedControlPlane { + return &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Platform: hyperv1.PlatformSpec{ + Type: hyperv1.PowerVSPlatform, + PowerVS: &hyperv1.PowerVSPlatformSpec{ + AccountID: "test-account-id", + ServiceInstanceID: "test-service-instance-id", + Region: "us-south", + Zone: "us-south-1", + ResourceGroup: "test-resource-group", + VPC: &hyperv1.PowerVSVPC{ + Name: "test-vpc", + Region: "us-south", + Subnet: "test-subnet", + }, + }, + }, + }, + } +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/cloud_controller_manager/powervs/deployment_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/cloud_controller_manager/powervs/deployment_test.go new file mode 100644 index 00000000000..3fce83805da --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/cloud_controller_manager/powervs/deployment_test.go @@ -0,0 +1,250 @@ +package powervs + +import ( + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + assets "github.com/openshift/hypershift/control-plane-operator/controllers/hostedcontrolplane/v2/assets" + component "github.com/openshift/hypershift/support/controlplane-component" + "github.com/openshift/hypershift/support/util" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestAdaptDeployment(t *testing.T) { + t.Parallel() + + t.Run("When cloud controller creds secret name is set, it should update volume secret name", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Platform: hyperv1.PlatformSpec{ + Type: hyperv1.PowerVSPlatform, + PowerVS: &hyperv1.PowerVSPlatformSpec{ + KubeCloudControllerCreds: corev1.LocalObjectReference{ + Name: "my-cloud-creds-secret", + }, + }, + }, + }, + } + + deployment := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Volumes: []corev1.Volume{ + { + Name: cloudCredsVolumeName, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: "original-secret", + }, + }, + }, + }, + }, + }, + }, + } + + cpContext := component.WorkloadContext{ + HCP: hcp, + } + err := adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + cloudCredsVol := util.FindVolume(cloudCredsVolumeName, deployment.Spec.Template.Spec.Volumes) + g.Expect(cloudCredsVol).ToNot(BeNil(), "cloud-creds volume should exist") + g.Expect(cloudCredsVol.Secret.SecretName).To(Equal("my-cloud-creds-secret")) + }) + + t.Run("When deployment has multiple volumes, it should only update cloud-creds volume", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Platform: hyperv1.PlatformSpec{ + Type: hyperv1.PowerVSPlatform, + PowerVS: &hyperv1.PowerVSPlatformSpec{ + KubeCloudControllerCreds: corev1.LocalObjectReference{ + Name: "updated-creds", + }, + }, + }, + }, + } + + deployment := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Volumes: []corev1.Volume{ + { + Name: "other-volume", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: "other-secret", + }, + }, + }, + { + Name: cloudCredsVolumeName, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: "old-secret", + }, + }, + }, + { + Name: "yet-another-volume", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "some-configmap", + }, + }, + }, + }, + }, + }, + }, + }, + } + + cpContext := component.WorkloadContext{ + HCP: hcp, + } + err := adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + g.Expect(deployment.Spec.Template.Spec.Volumes).To(HaveLen(3)) + + otherVol := util.FindVolume("other-volume", deployment.Spec.Template.Spec.Volumes) + g.Expect(otherVol).ToNot(BeNil(), "other-volume should exist") + g.Expect(otherVol.Secret.SecretName).To(Equal("other-secret")) + + cloudCredsVol := util.FindVolume(cloudCredsVolumeName, deployment.Spec.Template.Spec.Volumes) + g.Expect(cloudCredsVol).ToNot(BeNil(), "cloud-creds volume should exist") + g.Expect(cloudCredsVol.Secret.SecretName).To(Equal("updated-creds")) + + anotherVol := util.FindVolume("yet-another-volume", deployment.Spec.Template.Spec.Volumes) + g.Expect(anotherVol).ToNot(BeNil(), "yet-another-volume should exist") + g.Expect(anotherVol.ConfigMap.Name).To(Equal("some-configmap")) + }) +} + +func TestAdaptDeploymentErrorStates(t *testing.T) { + t.Parallel() + + t.Run("When PowerVS platform is nil, it should return error", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Platform: hyperv1.PlatformSpec{ + Type: hyperv1.PowerVSPlatform, + PowerVS: nil, + }, + }, + } + + deployment := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Volumes: []corev1.Volume{ + { + Name: cloudCredsVolumeName, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: "original-secret", + }, + }, + }, + }, + }, + }, + }, + } + + cpContext := component.WorkloadContext{ + HCP: hcp, + } + err := adaptDeployment(cpContext, deployment) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(".spec.platform.powervs is not defined")) + }) +} + +func TestAdaptDeploymentWithAssets(t *testing.T) { + t.Parallel() + + t.Run("When deployment is loaded from assets, it should adapt cloud-creds volume", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Platform: hyperv1.PlatformSpec{ + Type: hyperv1.PowerVSPlatform, + PowerVS: &hyperv1.PowerVSPlatformSpec{ + KubeCloudControllerCreds: corev1.LocalObjectReference{ + Name: "asset-test-creds", + }, + }, + }, + }, + } + + deployment := &appsv1.Deployment{} + _, _, err := assets.LoadManifestInto(ComponentName, "deployment.yaml", deployment) + g.Expect(err).ToNot(HaveOccurred()) + + cpContext := component.WorkloadContext{ + HCP: hcp, + } + err = adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + // Find the cloud-creds volume and verify it was updated + cloudCredsVol := util.FindVolume(cloudCredsVolumeName, deployment.Spec.Template.Spec.Volumes) + g.Expect(cloudCredsVol).ToNot(BeNil(), "cloud-creds volume should exist in deployment") + g.Expect(cloudCredsVol.Secret.SecretName).To(Equal("asset-test-creds")) + }) +} + +func TestCloudCredsVolumeNameConstant(t *testing.T) { + t.Parallel() + + t.Run("When cloudCredsVolumeName is used, it should match expected value", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + g.Expect(cloudCredsVolumeName).To(Equal("cloud-creds")) + }) +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/cloud_controller_manager/powervs/testdata/zz_fixture_TestAdaptConfig.yaml b/control-plane-operator/controllers/hostedcontrolplane/v2/cloud_controller_manager/powervs/testdata/zz_fixture_TestAdaptConfig.yaml new file mode 100644 index 00000000000..36e593cef1e --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/cloud_controller_manager/powervs/testdata/zz_fixture_TestAdaptConfig.yaml @@ -0,0 +1,23 @@ +apiVersion: v1 +data: + ccm-config: | + [global] + version = 1.1.0 + [kubernetes] + config-file = /etc/kubernetes/kubeconfig + [provider] + cluster-default-provider = g2 + g2Credentials = /etc/vpc/ibmcloud_api_key + g2workerServiceAccountID = test-account-id + g2ResourceGroupName = test-resource-group + g2VpcSubnetNames = test-subnet + g2VpcName = test-vpc + accountID = test-account-id + clusterID = test-cluster + region = us-south + powerVSCloudInstanceID = test-service-instance-id + powerVSRegion = us-south + powerVSZone = us-south-1 +kind: ConfigMap +metadata: + name: ccm-config diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/cloud_credential_operator/component_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/cloud_credential_operator/component_test.go new file mode 100644 index 00000000000..a701de5b11f --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/cloud_credential_operator/component_test.go @@ -0,0 +1,113 @@ +package cco + +import ( + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + component "github.com/openshift/hypershift/support/controlplane-component" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestIsAWSPlatform(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + platformType hyperv1.PlatformType + expected bool + }{ + { + name: "When platform is AWS, it should return true", + platformType: hyperv1.AWSPlatform, + expected: true, + }, + { + name: "When platform is Azure, it should return false", + platformType: hyperv1.AzurePlatform, + expected: false, + }, + { + name: "When platform is KubeVirt, it should return false", + platformType: hyperv1.KubevirtPlatform, + expected: false, + }, + { + name: "When platform is Agent, it should return false", + platformType: hyperv1.AgentPlatform, + expected: false, + }, + { + name: "When platform is PowerVS, it should return false", + platformType: hyperv1.PowerVSPlatform, + expected: false, + }, + { + name: "When platform is OpenStack, it should return false", + platformType: hyperv1.OpenStackPlatform, + expected: false, + }, + { + name: "When platform is None, it should return false", + platformType: hyperv1.NonePlatform, + expected: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Platform: hyperv1.PlatformSpec{ + Type: tc.platformType, + }, + }, + } + + cpContext := component.WorkloadContext{ + HCP: hcp, + } + + result, err := isAWSPlatform(cpContext) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(result).To(Equal(tc.expected)) + }) + } +} + +func TestCloudCredentialOperatorOptions(t *testing.T) { + t.Parallel() + + t.Run("When IsRequestServing is called, it should return false", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + cco := &cloudCredentialOperator{} + g.Expect(cco.IsRequestServing()).To(BeFalse()) + }) + + t.Run("When MultiZoneSpread is called, it should return true", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + cco := &cloudCredentialOperator{} + g.Expect(cco.MultiZoneSpread()).To(BeTrue()) + }) + + t.Run("When NeedsManagementKASAccess is called, it should return false", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + cco := &cloudCredentialOperator{} + g.Expect(cco.NeedsManagementKASAccess()).To(BeFalse()) + }) +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/cloud_credential_operator/deployment_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/cloud_credential_operator/deployment_test.go new file mode 100644 index 00000000000..7150eb0c269 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/cloud_credential_operator/deployment_test.go @@ -0,0 +1,199 @@ +package cco + +import ( + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + assets "github.com/openshift/hypershift/control-plane-operator/controllers/hostedcontrolplane/v2/assets" + component "github.com/openshift/hypershift/support/controlplane-component" + "github.com/openshift/hypershift/support/testutil" + "github.com/openshift/hypershift/support/util" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestAdaptDeployment(t *testing.T) { + testCases := []struct { + name string + releaseVersion string + httpProxy string + httpsProxy string + noProxy string + validate func(*WithT, *corev1.Container) + }{ + { + name: "When release version is set, it should add RELEASE_VERSION env var", + releaseVersion: "4.17.0", + validate: func(g *WithT, container *corev1.Container) { + g.Expect(container.Env).To(ContainElement(corev1.EnvVar{ + Name: "RELEASE_VERSION", + Value: "4.17.0", + })) + }, + }, + { + name: "When different release version is set, it should use that version", + releaseVersion: "4.18.0-rc.1", + validate: func(g *WithT, container *corev1.Container) { + g.Expect(container.Env).To(ContainElement(corev1.EnvVar{ + Name: "RELEASE_VERSION", + Value: "4.18.0-rc.1", + })) + }, + }, + { + name: "When proxy environment variables are set, it should add proxy env vars to container", + releaseVersion: "4.17.0", + httpProxy: "http://proxy.example.com:8080", + httpsProxy: "https://proxy.example.com:8443", + noProxy: "localhost,127.0.0.1", + validate: func(g *WithT, container *corev1.Container) { + g.Expect(container.Env).To(ContainElements( + corev1.EnvVar{ + Name: "HTTP_PROXY", + Value: "http://proxy.example.com:8080", + }, + corev1.EnvVar{ + Name: "HTTPS_PROXY", + Value: "https://proxy.example.com:8443", + }, + )) + // NO_PROXY will have kube-apiserver added + var foundNoProxy bool + for _, env := range container.Env { + if env.Name == "NO_PROXY" { + foundNoProxy = true + g.Expect(env.Value).To(ContainSubstring("localhost")) + g.Expect(env.Value).To(ContainSubstring("127.0.0.1")) + g.Expect(env.Value).To(ContainSubstring("kube-apiserver")) + } + } + g.Expect(foundNoProxy).To(BeTrue()) + }, + }, + { + name: "When no proxy is set, it should not add proxy env vars", + releaseVersion: "4.17.0", + validate: func(g *WithT, container *corev1.Container) { + for _, env := range container.Env { + g.Expect(env.Name).ToNot(Equal("HTTP_PROXY")) + g.Expect(env.Name).ToNot(Equal("HTTPS_PROXY")) + g.Expect(env.Name).ToNot(Equal("NO_PROXY")) + } + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + + // Set deterministic baseline for proxy environment variables + t.Setenv("HTTP_PROXY", tc.httpProxy) + t.Setenv("HTTPS_PROXY", tc.httpsProxy) + t.Setenv("NO_PROXY", tc.noProxy) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{}, + } + + releaseImageProvider := testutil.FakeImageProvider(testutil.WithVersion(tc.releaseVersion)) + + cpContext := component.WorkloadContext{ + HCP: hcp, + ReleaseImageProvider: releaseImageProvider, + } + + deployment, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + err = adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + // Find the cloud-credential-operator container + ccoContainer := util.FindContainer(ComponentName, deployment.Spec.Template.Spec.Containers) + g.Expect(ccoContainer).ToNot(BeNil(), "cloud-credential-operator container should exist") + + tc.validate(g, ccoContainer) + }) + } +} + +func TestAdaptDeploymentUpdatesContainer(t *testing.T) { + t.Parallel() + + t.Run("When adaptDeployment is called, it should not return an error", func(t *testing.T) { + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{}, + } + + releaseImageProvider := testutil.FakeImageProvider(testutil.WithVersion("4.17.0")) + + cpContext := component.WorkloadContext{ + HCP: hcp, + ReleaseImageProvider: releaseImageProvider, + } + + deployment, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + err = adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + }) + + t.Run("When adaptDeployment is called, it should preserve other containers", func(t *testing.T) { + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{}, + } + + releaseImageProvider := testutil.FakeImageProvider(testutil.WithVersion("4.17.0")) + + cpContext := component.WorkloadContext{ + HCP: hcp, + ReleaseImageProvider: releaseImageProvider, + } + + deployment, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + originalContainerNames := make([]string, len(deployment.Spec.Template.Spec.Containers)) + for i, c := range deployment.Spec.Template.Spec.Containers { + originalContainerNames[i] = c.Name + } + + err = adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + // The deployment should still have all its original containers + g.Expect(deployment.Spec.Template.Spec.Containers).To(HaveLen(len(originalContainerNames))) + for _, name := range originalContainerNames { + found := false + for _, c := range deployment.Spec.Template.Spec.Containers { + if c.Name == name { + found = true + break + } + } + g.Expect(found).To(BeTrue(), "container %s should be preserved after adaptDeployment", name) + } + }) +} From c5e75f54d49e3bd58e26f1ee9d49c7fb0701f74b Mon Sep 17 00:00:00 2001 From: Bryan Cox Date: Mon, 13 Apr 2026 08:34:52 -0400 Subject: [PATCH 3/7] test: Add unit tests for v2 core controller packages Add behavior-driven unit tests for KCM, konnectivity, machine approver, and PKI operator v2 controller packages covering config adaptation, deployment adaptation, predicates, and kubeconfig generation. Co-Authored-By: Claude Opus 4.6 --- .../konnectivity/params_test.go | 62 +++ .../v2/kcm/component_test.go | 35 ++ .../hostedcontrolplane/v2/kcm/config_test.go | 308 +++++++++++ .../v2/kcm/deployment_test.go | 507 ++++++++++++++++++ .../v2/kcm/kubeconfig_test.go | 145 +++++ .../v2/kcm/servicemonitor_test.go | 123 +++++ .../v2/konnectivity_agent/component_test.go | 85 +++ .../v2/konnectivity_agent/deployment_test.go | 169 ++++++ .../v2/machine_approver/component_test.go | 267 +++++++++ .../v2/machine_approver/deployment_test.go | 178 ++++++ .../v2/pkioperator/component_test.go | 155 ++++++ .../v2/pkioperator/deployment_test.go | 290 ++++++++++ 12 files changed, 2324 insertions(+) create mode 100644 control-plane-operator/controllers/hostedcontrolplane/konnectivity/params_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/kcm/component_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/kcm/config_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/kcm/deployment_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/kcm/kubeconfig_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/kcm/servicemonitor_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/konnectivity_agent/component_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/konnectivity_agent/deployment_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/machine_approver/component_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/machine_approver/deployment_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/pkioperator/component_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/pkioperator/deployment_test.go diff --git a/control-plane-operator/controllers/hostedcontrolplane/konnectivity/params_test.go b/control-plane-operator/controllers/hostedcontrolplane/konnectivity/params_test.go new file mode 100644 index 00000000000..adff13c3f0c --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/konnectivity/params_test.go @@ -0,0 +1,62 @@ +package konnectivity + +import ( + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +func TestNewKonnectivityServiceParams(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + hcp *hyperv1.HostedControlPlane + validate func(*testing.T, *KonnectivityServiceParams) + }{ + { + name: "When HCP is provided it should create params with owner ref", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + UID: "test-uid-123", + }, + }, + validate: func(t *testing.T, params *KonnectivityServiceParams) { + g := NewWithT(t) + g.Expect(params).ToNot(BeNil()) + g.Expect(params.OwnerRef).ToNot(BeNil()) + g.Expect(params.OwnerRef.Reference).ToNot(BeNil()) + g.Expect(params.OwnerRef.Reference.Name).To(Equal("test-hcp")) + g.Expect(params.OwnerRef.Reference.UID).To(Equal(types.UID("test-uid-123"))) + }, + }, + { + name: "When HCP has empty metadata it should still create params", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{}, + }, + validate: func(t *testing.T, params *KonnectivityServiceParams) { + g := NewWithT(t) + g.Expect(params).ToNot(BeNil()) + g.Expect(params.OwnerRef).ToNot(BeNil()) + g.Expect(params.OwnerRef.Reference).ToNot(BeNil()) + g.Expect(params.OwnerRef.Reference.Name).To(BeEmpty()) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + params := NewKonnectivityServiceParams(tc.hcp) + tc.validate(t, params) + }) + } +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/kcm/component_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/kcm/component_test.go new file mode 100644 index 00000000000..05b39549708 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/kcm/component_test.go @@ -0,0 +1,35 @@ +package kcm + +import ( + "testing" + + . "github.com/onsi/gomega" +) + +func TestKubeControllerManagerOptions(t *testing.T) { + t.Parallel() + + t.Run("When IsRequestServing is called, it should return false", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + kcm := &KubeControllerManager{} + g.Expect(kcm.IsRequestServing()).To(BeFalse()) + }) + + t.Run("When MultiZoneSpread is called, it should return true", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + kcm := &KubeControllerManager{} + g.Expect(kcm.MultiZoneSpread()).To(BeTrue()) + }) + + t.Run("When NeedsManagementKASAccess is called, it should return false", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + kcm := &KubeControllerManager{} + g.Expect(kcm.NeedsManagementKASAccess()).To(BeFalse()) + }) +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/kcm/config_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/kcm/config_test.go new file mode 100644 index 00000000000..6e23ccaa2b1 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/kcm/config_test.go @@ -0,0 +1,308 @@ +package kcm + +import ( + "context" + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + "github.com/openshift/hypershift/control-plane-operator/controllers/hostedcontrolplane/imageprovider" + "github.com/openshift/hypershift/control-plane-operator/controllers/hostedcontrolplane/manifests" + "github.com/openshift/hypershift/support/api" + component "github.com/openshift/hypershift/support/controlplane-component" + "github.com/openshift/hypershift/support/util" + + kcpv1 "github.com/openshift/api/kubecontrolplane/v1" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestAdaptConfig(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + existingObjects []client.Object + configData string + validate func(*testing.T, *corev1.ConfigMap, error) + }{ + { + name: "When service serving CA exists, it should set CertFile in config", + existingObjects: []client.Object{ + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "service-serving-ca", + Namespace: "test-namespace", + }, + Data: map[string]string{ + "service-ca.crt": "test-ca-data", + }, + }, + }, + configData: `{"kind":"KubeControllerManagerConfig","apiVersion":"kubecontrolplane.config.openshift.io/v1"}`, + validate: func(t *testing.T, cm *corev1.ConfigMap, err error) { + g := NewWithT(t) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(cm.Data).To(HaveKey(KubeControllerManagerConfigKey)) + + config := &kcpv1.KubeControllerManagerConfig{} + decodeErr := util.DeserializeResource(cm.Data[KubeControllerManagerConfigKey], config, api.Scheme) + g.Expect(decodeErr).ToNot(HaveOccurred()) + g.Expect(config.ServiceServingCert.CertFile).To(Equal("/etc/kubernetes/certs/service-ca/service-ca.crt")) + }, + }, + { + name: "When service serving CA does not exist, it should not set CertFile", + existingObjects: []client.Object{}, + configData: `{"kind":"KubeControllerManagerConfig","apiVersion":"kubecontrolplane.config.openshift.io/v1"}`, + validate: func(t *testing.T, cm *corev1.ConfigMap, err error) { + g := NewWithT(t) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(cm.Data).To(HaveKey(KubeControllerManagerConfigKey)) + + config := &kcpv1.KubeControllerManagerConfig{} + decodeErr := util.DeserializeResource(cm.Data[KubeControllerManagerConfigKey], config, api.Scheme) + g.Expect(decodeErr).ToNot(HaveOccurred()) + g.Expect(config.ServiceServingCert.CertFile).To(BeEmpty()) + }, + }, + { + name: "When config data is invalid, it should return error", + existingObjects: []client.Object{}, + configData: `invalid json`, + validate: func(t *testing.T, cm *corev1.ConfigMap, err error) { + g := NewWithT(t) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("unable to decode existing KubeControllerManager configuration")) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(tc.existingObjects...).Build() + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + } + + cpContext := component.WorkloadContext{ + Context: t.Context(), + Client: fakeClient, + HCP: hcp, + } + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kcm-config", + Namespace: "test-namespace", + }, + Data: map[string]string{ + KubeControllerManagerConfigKey: tc.configData, + }, + } + + err := adaptConfig(cpContext, cm) + tc.validate(t, cm, err) + }) + } +} + +func TestAdaptRecyclerConfig(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + inputTemplate string + toolsImage string + expectedOutput string + }{ + { + name: "When template has tools image placeholder, it should replace it", + inputTemplate: "image: {{.tools_image}}", + toolsImage: "quay.io/openshift/tools:v1.0.0", + expectedOutput: "image: quay.io/openshift/tools:v1.0.0", + }, + { + name: "When template has multiple lines with placeholder, it should replace first occurrence only", + inputTemplate: `apiVersion: v1 +kind: Pod +spec: + containers: + - image: {{.tools_image}} + name: recycler + - image: {{.tools_image}} + name: other`, + toolsImage: "registry.io/tools:latest", + expectedOutput: `apiVersion: v1 +kind: Pod +spec: + containers: + - image: registry.io/tools:latest + name: recycler + - image: {{.tools_image}} + name: other`, + }, + { + name: "When template has no placeholder, it should remain unchanged", + inputTemplate: "image: static-image:v1.0.0", + toolsImage: "quay.io/openshift/tools:v1.0.0", + expectedOutput: "image: static-image:v1.0.0", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + mockImageProvider := imageprovider.NewFromImages(map[string]string{ + "tools": tc.toolsImage, + }) + + cpContext := component.WorkloadContext{ + Context: t.Context(), + ReleaseImageProvider: mockImageProvider, + } + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "recycler-config", + Namespace: "test-namespace", + }, + Data: map[string]string{ + RecyclerPodTemplateKey: tc.inputTemplate, + }, + } + + err := adaptRecyclerConfig(cpContext, cm) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(cm.Data[RecyclerPodTemplateKey]).To(Equal(tc.expectedOutput)) + }) + } +} + +func TestGetServiceServingCA(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + existingObjects []client.Object + validate func(*testing.T, *corev1.ConfigMap, error) + }{ + { + name: "When service serving CA exists, it should return the ConfigMap", + existingObjects: []client.Object{ + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "service-serving-ca", + Namespace: "test-namespace", + }, + Data: map[string]string{ + "service-ca.crt": "test-ca-data", + }, + }, + }, + validate: func(t *testing.T, cm *corev1.ConfigMap, err error) { + g := NewWithT(t) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(cm).ToNot(BeNil()) + g.Expect(cm.Name).To(Equal("service-serving-ca")) + g.Expect(cm.Data).To(HaveKey("service-ca.crt")) + }, + }, + { + name: "When service serving CA does not exist, it should return nil without error", + existingObjects: []client.Object{}, + validate: func(t *testing.T, cm *corev1.ConfigMap, err error) { + g := NewWithT(t) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(cm).To(BeNil()) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(tc.existingObjects...).Build() + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + } + + cpContext := component.WorkloadContext{ + Context: t.Context(), + Client: fakeClient, + HCP: hcp, + } + + cm, err := getServiceServingCA(cpContext) + tc.validate(t, cm, err) + }) + } +} + +func TestGetServiceServingCAError(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + + // Create a client that will return an error + fakeClient := &erroringClient{ + Reader: fake.NewClientBuilder().WithScheme(scheme).Build(), + err: apierrors.NewServiceUnavailable("test error"), + } + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + } + + cpContext := component.WorkloadContext{ + Context: t.Context(), + Client: fakeClient, + HCP: hcp, + } + + _, err := getServiceServingCA(cpContext) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("failed to get service serving CA")) +} + +// erroringClient wraps a client.Reader and returns a custom error on Get +type erroringClient struct { + client.Reader + err error +} + +func (e *erroringClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if key.Name == manifests.ServiceServingCA(key.Namespace).Name { + return e.err + } + return e.Reader.Get(ctx, key, obj, opts...) +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/kcm/deployment_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/kcm/deployment_test.go new file mode 100644 index 00000000000..f849883c903 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/kcm/deployment_test.go @@ -0,0 +1,507 @@ +package kcm + +import ( + "fmt" + "strings" + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + "github.com/openshift/hypershift/api/util/ipnet" + "github.com/openshift/hypershift/support/config" + component "github.com/openshift/hypershift/support/controlplane-component" + "github.com/openshift/hypershift/support/util" + + configv1 "github.com/openshift/api/config/v1" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/utils/ptr" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestAdaptDeployment(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + hcp *hyperv1.HostedControlPlane + existingObjects []client.Object + validate func(*testing.T, *appsv1.Deployment, error) + }{ + { + name: "When HCP has basic networking config, it should set cluster and service CIDR args", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Networking: hyperv1.ClusterNetworking{ + ClusterNetwork: []hyperv1.ClusterNetworkEntry{ + {CIDR: *ipnet.MustParseCIDR("10.132.0.0/14")}, + }, + ServiceNetwork: []hyperv1.ServiceNetworkEntry{ + {CIDR: *ipnet.MustParseCIDR("172.31.0.0/16")}, + }, + }, + }, + }, + validate: func(t *testing.T, deployment *appsv1.Deployment, err error) { + g := NewWithT(t) + g.Expect(err).ToNot(HaveOccurred()) + + container := util.FindContainer(ComponentName, deployment.Spec.Template.Spec.Containers) + g.Expect(container).ToNot(BeNil()) + g.Expect(container.Args).To(ContainElement("--cluster-cidr=10.132.0.0/14")) + g.Expect(container.Args).To(ContainElement("--service-cluster-ip-range=172.31.0.0/16")) + }, + }, + { + name: "When platform is Azure, it should set cloud-provider to external", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Platform: hyperv1.PlatformSpec{ + Type: hyperv1.AzurePlatform, + }, + Networking: hyperv1.ClusterNetworking{ + ClusterNetwork: []hyperv1.ClusterNetworkEntry{ + {CIDR: *ipnet.MustParseCIDR("10.132.0.0/14")}, + }, + ServiceNetwork: []hyperv1.ServiceNetworkEntry{ + {CIDR: *ipnet.MustParseCIDR("172.31.0.0/16")}, + }, + }, + }, + }, + validate: func(t *testing.T, deployment *appsv1.Deployment, err error) { + g := NewWithT(t) + g.Expect(err).ToNot(HaveOccurred()) + + container := util.FindContainer(ComponentName, deployment.Spec.Template.Spec.Containers) + g.Expect(container).ToNot(BeNil()) + g.Expect(container.Args).To(ContainElement("--cloud-provider=external")) + }, + }, + { + name: "When AllocateNodeCIDRs is enabled, it should set allocate-node-cidrs to true", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Networking: hyperv1.ClusterNetworking{ + ClusterNetwork: []hyperv1.ClusterNetworkEntry{ + {CIDR: *ipnet.MustParseCIDR("10.132.0.0/14")}, + }, + ServiceNetwork: []hyperv1.ServiceNetworkEntry{ + {CIDR: *ipnet.MustParseCIDR("172.31.0.0/16")}, + }, + AllocateNodeCIDRs: ptr.To(hyperv1.AllocateNodeCIDRsEnabled), + }, + }, + }, + validate: func(t *testing.T, deployment *appsv1.Deployment, err error) { + g := NewWithT(t) + g.Expect(err).ToNot(HaveOccurred()) + + container := util.FindContainer(ComponentName, deployment.Spec.Template.Spec.Containers) + g.Expect(container).ToNot(BeNil()) + g.Expect(container.Args).To(ContainElement("--allocate-node-cidrs=true")) + }, + }, + { + name: "When AllocateNodeCIDRs is disabled, it should set allocate-node-cidrs to false", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Networking: hyperv1.ClusterNetworking{ + ClusterNetwork: []hyperv1.ClusterNetworkEntry{ + {CIDR: *ipnet.MustParseCIDR("10.132.0.0/14")}, + }, + ServiceNetwork: []hyperv1.ServiceNetworkEntry{ + {CIDR: *ipnet.MustParseCIDR("172.31.0.0/16")}, + }, + AllocateNodeCIDRs: ptr.To(hyperv1.AllocateNodeCIDRsDisabled), + }, + }, + }, + validate: func(t *testing.T, deployment *appsv1.Deployment, err error) { + g := NewWithT(t) + g.Expect(err).ToNot(HaveOccurred()) + + container := util.FindContainer(ComponentName, deployment.Spec.Template.Spec.Containers) + g.Expect(container).ToNot(BeNil()) + g.Expect(container.Args).To(ContainElement("--allocate-node-cidrs=false")) + }, + }, + { + name: "When AllocateNodeCIDRs is nil, it should set allocate-node-cidrs to false", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Networking: hyperv1.ClusterNetworking{ + ClusterNetwork: []hyperv1.ClusterNetworkEntry{ + {CIDR: *ipnet.MustParseCIDR("10.132.0.0/14")}, + }, + ServiceNetwork: []hyperv1.ServiceNetworkEntry{ + {CIDR: *ipnet.MustParseCIDR("172.31.0.0/16")}, + }, + }, + }, + }, + validate: func(t *testing.T, deployment *appsv1.Deployment, err error) { + g := NewWithT(t) + g.Expect(err).ToNot(HaveOccurred()) + + container := util.FindContainer(ComponentName, deployment.Spec.Template.Spec.Containers) + g.Expect(container).ToNot(BeNil()) + g.Expect(container.Args).To(ContainElement("--allocate-node-cidrs=false")) + }, + }, + { + name: "When platform is IBMCloud, it should set node-monitor-grace-period to 55s", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Platform: hyperv1.PlatformSpec{ + Type: hyperv1.IBMCloudPlatform, + }, + Networking: hyperv1.ClusterNetworking{ + ClusterNetwork: []hyperv1.ClusterNetworkEntry{ + {CIDR: *ipnet.MustParseCIDR("10.132.0.0/14")}, + }, + ServiceNetwork: []hyperv1.ServiceNetworkEntry{ + {CIDR: *ipnet.MustParseCIDR("172.31.0.0/16")}, + }, + }, + }, + }, + validate: func(t *testing.T, deployment *appsv1.Deployment, err error) { + g := NewWithT(t) + g.Expect(err).ToNot(HaveOccurred()) + + container := util.FindContainer(ComponentName, deployment.Spec.Template.Spec.Containers) + g.Expect(container).ToNot(BeNil()) + g.Expect(container.Args).To(ContainElement("--node-monitor-grace-period=55s")) + }, + }, + { + name: "When platform is not IBMCloud, it should set node-monitor-grace-period to 50s", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Platform: hyperv1.PlatformSpec{ + Type: hyperv1.AWSPlatform, + }, + Networking: hyperv1.ClusterNetworking{ + ClusterNetwork: []hyperv1.ClusterNetworkEntry{ + {CIDR: *ipnet.MustParseCIDR("10.132.0.0/14")}, + }, + ServiceNetwork: []hyperv1.ServiceNetworkEntry{ + {CIDR: *ipnet.MustParseCIDR("172.31.0.0/16")}, + }, + }, + }, + }, + validate: func(t *testing.T, deployment *appsv1.Deployment, err error) { + g := NewWithT(t) + g.Expect(err).ToNot(HaveOccurred()) + + container := util.FindContainer(ComponentName, deployment.Spec.Template.Spec.Containers) + g.Expect(container).ToNot(BeNil()) + g.Expect(container.Args).To(ContainElement("--node-monitor-grace-period=50s")) + }, + }, + { + name: "When TLS security profile is set, it should configure TLS min version and cipher suites", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Networking: hyperv1.ClusterNetworking{ + ClusterNetwork: []hyperv1.ClusterNetworkEntry{ + {CIDR: *ipnet.MustParseCIDR("10.132.0.0/14")}, + }, + ServiceNetwork: []hyperv1.ServiceNetworkEntry{ + {CIDR: *ipnet.MustParseCIDR("172.31.0.0/16")}, + }, + }, + Configuration: &hyperv1.ClusterConfiguration{ + APIServer: &configv1.APIServerSpec{ + TLSSecurityProfile: &configv1.TLSSecurityProfile{ + Type: configv1.TLSProfileModernType, + }, + }, + }, + }, + }, + validate: func(t *testing.T, deployment *appsv1.Deployment, err error) { + g := NewWithT(t) + g.Expect(err).ToNot(HaveOccurred()) + + container := util.FindContainer(ComponentName, deployment.Spec.Template.Spec.Containers) + g.Expect(container).ToNot(BeNil()) + + tlsProfile := &configv1.TLSSecurityProfile{ + Type: configv1.TLSProfileModernType, + } + minTLSVersion := config.MinTLSVersion(tlsProfile) + g.Expect(container.Args).To(ContainElement(fmt.Sprintf("--tls-min-version=%s", minTLSVersion))) + + cipherSuites := config.CipherSuites(tlsProfile) + if len(cipherSuites) > 0 { + g.Expect(container.Args).To(ContainElement(fmt.Sprintf("--tls-cipher-suites=%s", strings.Join(cipherSuites, ",")))) + } + }, + }, + { + name: "When disable profiling annotation is set, it should add profiling=false arg", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + Annotations: map[string]string{ + hyperv1.DisableProfilingAnnotation: ComponentName, + }, + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Networking: hyperv1.ClusterNetworking{ + ClusterNetwork: []hyperv1.ClusterNetworkEntry{ + {CIDR: *ipnet.MustParseCIDR("10.132.0.0/14")}, + }, + ServiceNetwork: []hyperv1.ServiceNetworkEntry{ + {CIDR: *ipnet.MustParseCIDR("172.31.0.0/16")}, + }, + }, + }, + }, + validate: func(t *testing.T, deployment *appsv1.Deployment, err error) { + g := NewWithT(t) + g.Expect(err).ToNot(HaveOccurred()) + + container := util.FindContainer(ComponentName, deployment.Spec.Template.Spec.Containers) + g.Expect(container).ToNot(BeNil()) + g.Expect(container.Args).To(ContainElement("--profiling=false")) + }, + }, + { + name: "When feature gates are configured, it should add feature-gates args", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Networking: hyperv1.ClusterNetworking{ + ClusterNetwork: []hyperv1.ClusterNetworkEntry{ + {CIDR: *ipnet.MustParseCIDR("10.132.0.0/14")}, + }, + ServiceNetwork: []hyperv1.ServiceNetworkEntry{ + {CIDR: *ipnet.MustParseCIDR("172.31.0.0/16")}, + }, + }, + }, + }, + existingObjects: []client.Object{ + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "feature-gate", + Namespace: "test-namespace", + }, + Data: map[string]string{ + "feature-gate.yaml": `{ + "apiVersion": "config.openshift.io/v1", + "kind": "FeatureGate", + "spec": {}, + "status": { + "featureGates": [ + { + "version": "4.16", + "enabled": [{"name": "FeatureGate1"}], + "disabled": [{"name": "FeatureGate2"}] + } + ] + } + }`, + }, + }, + }, + validate: func(t *testing.T, deployment *appsv1.Deployment, err error) { + g := NewWithT(t) + g.Expect(err).ToNot(HaveOccurred()) + + container := util.FindContainer(ComponentName, deployment.Spec.Template.Spec.Containers) + g.Expect(container).ToNot(BeNil()) + g.Expect(container.Args).To(ContainElement("--feature-gates=FeatureGate1=true")) + g.Expect(container.Args).To(ContainElement("--feature-gates=FeatureGate2=false")) + }, + }, + { + name: "When service serving CA exists, it should add volume and volume mount", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Networking: hyperv1.ClusterNetworking{ + ClusterNetwork: []hyperv1.ClusterNetworkEntry{ + {CIDR: *ipnet.MustParseCIDR("10.132.0.0/14")}, + }, + ServiceNetwork: []hyperv1.ServiceNetworkEntry{ + {CIDR: *ipnet.MustParseCIDR("172.31.0.0/16")}, + }, + }, + }, + }, + existingObjects: []client.Object{ + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "service-serving-ca", + Namespace: "test-namespace", + }, + Data: map[string]string{ + "service-ca.crt": "test-ca-data", + }, + }, + }, + validate: func(t *testing.T, deployment *appsv1.Deployment, err error) { + g := NewWithT(t) + g.Expect(err).ToNot(HaveOccurred()) + + // Check volume is added + vol := util.FindVolume("service-serving-ca", deployment.Spec.Template.Spec.Volumes) + g.Expect(vol).ToNot(BeNil(), "service-serving-ca volume should be added") + g.Expect(vol.VolumeSource.ConfigMap).ToNot(BeNil()) + g.Expect(vol.VolumeSource.ConfigMap.Name).To(Equal("service-serving-ca")) + + // Check volume mount is added + container := util.FindContainer(ComponentName, deployment.Spec.Template.Spec.Containers) + g.Expect(container).ToNot(BeNil()) + mount := util.FindVolumeMount("service-serving-ca", container.VolumeMounts) + g.Expect(mount).ToNot(BeNil(), "service-serving-ca volume mount should be added") + g.Expect(mount.MountPath).To(Equal("/etc/kubernetes/certs/service-ca")) + }, + }, + { + name: "When service serving CA does not exist, it should not add volume or mount", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Networking: hyperv1.ClusterNetworking{ + ClusterNetwork: []hyperv1.ClusterNetworkEntry{ + {CIDR: *ipnet.MustParseCIDR("10.132.0.0/14")}, + }, + ServiceNetwork: []hyperv1.ServiceNetworkEntry{ + {CIDR: *ipnet.MustParseCIDR("172.31.0.0/16")}, + }, + }, + }, + }, + existingObjects: []client.Object{}, + validate: func(t *testing.T, deployment *appsv1.Deployment, err error) { + g := NewWithT(t) + g.Expect(err).ToNot(HaveOccurred()) + + // Check volume is not added + g.Expect(util.FindVolume("service-serving-ca", deployment.Spec.Template.Spec.Volumes)).To(BeNil()) + + // Check volume mount is not added + container := util.FindContainer(ComponentName, deployment.Spec.Template.Spec.Containers) + g.Expect(container).ToNot(BeNil()) + g.Expect(util.FindVolumeMount("service-serving-ca", container.VolumeMounts)).To(BeNil()) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + + // Make a local copy to avoid mutating the shared tc.existingObjects slice in parallel subtests. + existingObjects := append([]client.Object(nil), tc.existingObjects...) + + // Add feature-gate configmap if not already in existingObjects + hasFeatureGateConfigMap := false + for _, obj := range existingObjects { + if cm, ok := obj.(*corev1.ConfigMap); ok && cm.Name == "feature-gate" { + hasFeatureGateConfigMap = true + break + } + } + if !hasFeatureGateConfigMap { + existingObjects = append(existingObjects, &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "feature-gate", + Namespace: tc.hcp.Namespace, + }, + Data: map[string]string{ + "feature-gate.yaml": `{"apiVersion":"config.openshift.io/v1","kind":"FeatureGate","spec":{},"status":{"featureGates":[]}}`, + }, + }) + } + + fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(existingObjects...).Build() + + cpContext := component.WorkloadContext{ + Context: t.Context(), + Client: fakeClient, + HCP: tc.hcp, + } + + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: ComponentName, + Namespace: tc.hcp.Namespace, + }, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: ComponentName, + Image: "test-image:latest", + Args: []string{}, + }, + }, + }, + }, + }, + } + + err := adaptDeployment(cpContext, deployment) + tc.validate(t, deployment, err) + }) + } +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/kcm/kubeconfig_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/kcm/kubeconfig_test.go new file mode 100644 index 00000000000..07e0fee3b3f --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/kcm/kubeconfig_test.go @@ -0,0 +1,145 @@ +package kcm + +import ( + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + "github.com/openshift/hypershift/control-plane-operator/controllers/hostedcontrolplane/imageprovider" + component "github.com/openshift/hypershift/support/controlplane-component" + "github.com/openshift/hypershift/support/util" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestAdaptKubeconfig(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + platformType hyperv1.PlatformType + existingObjects []client.Object + validate func(*testing.T, *corev1.Secret, error) + }{ + { + name: "When kubeconfig is generated successfully, it should populate secret data", + platformType: hyperv1.AWSPlatform, + existingObjects: []client.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-controller-manager", + Namespace: "test-namespace", + }, + Data: map[string][]byte{ + "tls.crt": []byte("test-cert"), + "tls.key": []byte("test-key"), + }, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "root-ca", + Namespace: "test-namespace", + }, + Data: map[string][]byte{ + "ca.crt": []byte("test-ca"), + }, + }, + }, + validate: func(t *testing.T, secret *corev1.Secret, err error) { + g := NewWithT(t) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(secret.Data).To(HaveKey(util.KubeconfigKey)) + g.Expect(secret.Data[util.KubeconfigKey]).ToNot(BeEmpty()) + }, + }, + { + name: "When secret data is nil, it should initialize the data map", + platformType: hyperv1.AzurePlatform, + existingObjects: []client.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-controller-manager", + Namespace: "test-namespace", + }, + Data: map[string][]byte{ + "tls.crt": []byte("test-cert"), + "tls.key": []byte("test-key"), + }, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "root-ca", + Namespace: "test-namespace", + }, + Data: map[string][]byte{ + "ca.crt": []byte("test-ca"), + }, + }, + }, + validate: func(t *testing.T, secret *corev1.Secret, err error) { + g := NewWithT(t) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(secret.Data).ToNot(BeNil()) + g.Expect(secret.Data).To(HaveKey(util.KubeconfigKey)) + }, + }, + { + name: "When client cert secret is missing, it should return error", + platformType: hyperv1.AWSPlatform, + existingObjects: []client.Object{}, + validate: func(t *testing.T, secret *corev1.Secret, err error) { + g := NewWithT(t) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("failed to generate kubeconfig")) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + g := NewWithT(t) + scheme := runtime.NewScheme() + g.Expect(corev1.AddToScheme(scheme)).To(Succeed()) + fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(tc.existingObjects...).Build() + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Platform: hyperv1.PlatformSpec{ + Type: tc.platformType, + }, + }, + } + + mockImageProvider := imageprovider.NewFromImages(map[string]string{}) + + cpContext := component.WorkloadContext{ + Context: t.Context(), + Client: fakeClient, + HCP: hcp, + ReleaseImageProvider: mockImageProvider, + } + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kubeconfig", + Namespace: "test-namespace", + }, + } + + err := adaptKubeconfig(cpContext, secret) + tc.validate(t, secret, err) + }) + } +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/kcm/servicemonitor_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/kcm/servicemonitor_test.go new file mode 100644 index 00000000000..bae42f08162 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/kcm/servicemonitor_test.go @@ -0,0 +1,123 @@ +package kcm + +import ( + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + component "github.com/openshift/hypershift/support/controlplane-component" + "github.com/openshift/hypershift/support/metrics" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + prometheusoperatorv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" +) + +func TestAdaptServiceMonitor(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + metricsSet metrics.MetricsSet + clusterID string + validate func(*testing.T, *prometheusoperatorv1.ServiceMonitor, error) + }{ + { + name: "When service monitor is adapted, it should set namespace selector", + metricsSet: metrics.MetricsSetTelemetry, + clusterID: "test-cluster-id", + validate: func(t *testing.T, sm *prometheusoperatorv1.ServiceMonitor, err error) { + g := NewWithT(t) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(sm.Spec.NamespaceSelector.MatchNames).To(Equal([]string{"test-namespace"})) + }, + }, + { + name: "When service monitor is adapted, it should apply metric relabel configs", + metricsSet: metrics.MetricsSetTelemetry, + clusterID: "test-cluster-id", + validate: func(t *testing.T, sm *prometheusoperatorv1.ServiceMonitor, err error) { + g := NewWithT(t) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(sm.Spec.Endpoints).To(HaveLen(1)) + + // MetricRelabelConfigs should be set based on metrics set + g.Expect(sm.Spec.Endpoints[0].MetricRelabelConfigs).ToNot(BeNil()) + }, + }, + { + name: "When service monitor is adapted, it should apply cluster ID label", + metricsSet: metrics.MetricsSetAll, + clusterID: "cluster-abc-123", + validate: func(t *testing.T, sm *prometheusoperatorv1.ServiceMonitor, err error) { + g := NewWithT(t) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(sm.Spec.Endpoints).To(HaveLen(1)) + + // Check that cluster ID label is applied + relabelConfigs := sm.Spec.Endpoints[0].RelabelConfigs + foundClusterIDLabel := false + for _, config := range relabelConfigs { + if config.TargetLabel == "_id" && config.Replacement != nil && *config.Replacement == "cluster-abc-123" { + foundClusterIDLabel = true + break + } + } + g.Expect(foundClusterIDLabel).To(BeTrue(), "cluster ID label should be applied") + }, + }, + { + name: "When metrics set is SRE, it should apply SRE configs", + metricsSet: metrics.MetricsSetSRE, + clusterID: "test-cluster", + validate: func(t *testing.T, sm *prometheusoperatorv1.ServiceMonitor, err error) { + g := NewWithT(t) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(sm.Spec.Endpoints).To(HaveLen(1)) + + // MetricRelabelConfigs should be set based on metrics set + g.Expect(sm.Spec.Endpoints[0].MetricRelabelConfigs).ToNot(BeNil()) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + ClusterID: tc.clusterID, + }, + } + + cpContext := component.WorkloadContext{ + Context: t.Context(), + HCP: hcp, + MetricsSet: tc.metricsSet, + } + + sm := &prometheusoperatorv1.ServiceMonitor{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-controller-manager", + Namespace: "test-namespace", + }, + Spec: prometheusoperatorv1.ServiceMonitorSpec{ + Endpoints: []prometheusoperatorv1.Endpoint{ + { + Port: "metrics", + }, + }, + }, + } + + err := adaptServiceMonitor(cpContext, sm) + tc.validate(t, sm, err) + }) + } +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/konnectivity_agent/component_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/konnectivity_agent/component_test.go new file mode 100644 index 00000000000..2c6290ee080 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/konnectivity_agent/component_test.go @@ -0,0 +1,85 @@ +package konnectivity + +import ( + "testing" + + . "github.com/onsi/gomega" +) + +func TestKonnectivityAgentIsRequestServing(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + expected bool + }{ + { + name: "When called it should return false", + expected: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + ka := &konnectivityAgent{} + result := ka.IsRequestServing() + + g.Expect(result).To(Equal(tc.expected)) + }) + } +} + +func TestKonnectivityAgentMultiZoneSpread(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + expected bool + }{ + { + name: "When called it should return true", + expected: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + ka := &konnectivityAgent{} + result := ka.MultiZoneSpread() + + g.Expect(result).To(Equal(tc.expected)) + }) + } +} + +func TestKonnectivityAgentNeedsManagementKASAccess(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + expected bool + }{ + { + name: "When called it should return false", + expected: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + ka := &konnectivityAgent{} + result := ka.NeedsManagementKASAccess() + + g.Expect(result).To(Equal(tc.expected)) + }) + } +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/konnectivity_agent/deployment_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/konnectivity_agent/deployment_test.go new file mode 100644 index 00000000000..b7eedc8b467 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/konnectivity_agent/deployment_test.go @@ -0,0 +1,169 @@ +package konnectivity + +import ( + "strings" + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + "github.com/openshift/hypershift/control-plane-operator/controllers/hostedcontrolplane/infra" + "github.com/openshift/hypershift/control-plane-operator/controllers/hostedcontrolplane/v2/assets" + "github.com/openshift/hypershift/support/api" + component "github.com/openshift/hypershift/support/controlplane-component" + "github.com/openshift/hypershift/support/util" + + configv1 "github.com/openshift/api/config/v1" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestAdaptDeployment(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + hcpAnnotations map[string]string + hcpConfiguration *hyperv1.ClusterConfiguration + infraStatus infra.InfrastructureStatus + expectedAgentIDCount int + expectedImage string + validateAgentIDs func(*testing.T, []string) + }{ + { + name: "When OAuth is disabled it should add two agent identifiers", + hcpAnnotations: map[string]string{}, + hcpConfiguration: &hyperv1.ClusterConfiguration{ + Authentication: &configv1.AuthenticationSpec{ + Type: configv1.AuthenticationTypeOIDC, + }, + }, + infraStatus: infra.InfrastructureStatus{ + OpenShiftAPIHost: "api.example.com", + PackageServerAPIAddress: "pkgserver.example.com", + }, + expectedAgentIDCount: 2, + expectedImage: "apiserver-network-proxy", + validateAgentIDs: func(t *testing.T, ids []string) { + g := NewWithT(t) + g.Expect(ids).To(ContainElement("ipv4=api.example.com")) + g.Expect(ids).To(ContainElement("ipv4=pkgserver.example.com")) + }, + }, + { + name: "When OAuth is enabled it should add three agent identifiers", + hcpAnnotations: map[string]string{}, + hcpConfiguration: nil, // OAuth enabled by default when nil + infraStatus: infra.InfrastructureStatus{ + OpenShiftAPIHost: "api.example.com", + PackageServerAPIAddress: "pkgserver.example.com", + OauthAPIServerHost: "oauth.example.com", + }, + expectedAgentIDCount: 3, + expectedImage: "apiserver-network-proxy", + validateAgentIDs: func(t *testing.T, ids []string) { + g := NewWithT(t) + g.Expect(ids).To(ContainElement("ipv4=api.example.com")) + g.Expect(ids).To(ContainElement("ipv4=pkgserver.example.com")) + g.Expect(ids).To(ContainElement("ipv4=oauth.example.com")) + }, + }, + { + name: "When custom konnectivity agent image is specified it should use that image", + hcpAnnotations: map[string]string{ + hyperv1.KonnectivityAgentImageAnnotation: "custom-registry.io/konnectivity:v1.2.3", + }, + hcpConfiguration: &hyperv1.ClusterConfiguration{ + Authentication: &configv1.AuthenticationSpec{ + Type: configv1.AuthenticationTypeOIDC, + }, + }, + infraStatus: infra.InfrastructureStatus{ + OpenShiftAPIHost: "api.example.com", + PackageServerAPIAddress: "pkgserver.example.com", + }, + expectedAgentIDCount: 2, + expectedImage: "custom-registry.io/konnectivity:v1.2.3", + validateAgentIDs: func(t *testing.T, ids []string) { + g := NewWithT(t) + g.Expect(ids).To(ContainElement("ipv4=api.example.com")) + g.Expect(ids).To(ContainElement("ipv4=pkgserver.example.com")) + }, + }, + { + name: "When all IPs are different it should create unique agent identifiers", + hcpAnnotations: map[string]string{}, + hcpConfiguration: nil, // OAuth enabled by default + infraStatus: infra.InfrastructureStatus{ + OpenShiftAPIHost: "api.different.com", + PackageServerAPIAddress: "pkgserver.different.com", + OauthAPIServerHost: "oauth.different.com", + }, + expectedAgentIDCount: 3, + expectedImage: "apiserver-network-proxy", + validateAgentIDs: func(t *testing.T, ids []string) { + g := NewWithT(t) + g.Expect(ids).To(ContainElement("ipv4=api.different.com")) + g.Expect(ids).To(ContainElement("ipv4=pkgserver.different.com")) + g.Expect(ids).To(ContainElement("ipv4=oauth.different.com")) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + Annotations: tc.hcpAnnotations, + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Configuration: tc.hcpConfiguration, + }, + Status: hyperv1.HostedControlPlaneStatus{}, + } + + cpContext := component.WorkloadContext{ + Client: fake.NewClientBuilder().WithScheme(api.Scheme).Build(), + HCP: hcp, + InfraStatus: tc.infraStatus, + } + + deployment, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + err = adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + // Verify container exists + konnectivityContainer := util.FindContainer(ComponentName, deployment.Spec.Template.Spec.Containers) + g.Expect(konnectivityContainer).ToNot(BeNil(), "konnectivity-agent container should exist") + + // Verify image + g.Expect(konnectivityContainer.Image).To(Equal(tc.expectedImage)) + + // Verify agent identifiers + var agentIDsArg string + for i, arg := range konnectivityContainer.Args { + if arg == "--agent-identifiers" { + g.Expect(i+1).To(BeNumerically("<", len(konnectivityContainer.Args)), "agent-identifiers should have a value") + agentIDsArg = konnectivityContainer.Args[i+1] + break + } + } + g.Expect(agentIDsArg).ToNot(BeEmpty(), "--agent-identifiers argument should be present") + + // Parse agent identifiers + agentIDs := strings.Split(agentIDsArg, "&") + g.Expect(len(agentIDs)).To(Equal(tc.expectedAgentIDCount)) + + tc.validateAgentIDs(t, agentIDs) + }) + } +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/machine_approver/component_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/machine_approver/component_test.go new file mode 100644 index 00000000000..52839809572 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/machine_approver/component_test.go @@ -0,0 +1,267 @@ +package machineapprover + +import ( + "context" + "fmt" + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + "github.com/openshift/hypershift/control-plane-operator/controllers/hostedcontrolplane/manifests" + component "github.com/openshift/hypershift/support/controlplane-component" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestIsRequestServing(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + expected bool + }{ + { + name: "When called, it should return false", + expected: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + approver := &machineApprover{} + result := approver.IsRequestServing() + + g.Expect(result).To(Equal(tc.expected)) + }) + } +} + +func TestMultiZoneSpread(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + expected bool + }{ + { + name: "When called, it should return false", + expected: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + approver := &machineApprover{} + result := approver.MultiZoneSpread() + + g.Expect(result).To(Equal(tc.expected)) + }) + } +} + +func TestNeedsManagementKASAccess(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + expected bool + }{ + { + name: "When called, it should return true", + expected: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + approver := &machineApprover{} + result := approver.NeedsManagementKASAccess() + + g.Expect(result).To(Equal(tc.expected)) + }) + } +} + +func TestPredicate(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + _ = hyperv1.AddToScheme(scheme) + + tests := []struct { + name string + hcp *hyperv1.HostedControlPlane + objects []client.Object + expectEnabled bool + expectError bool + }{ + { + name: "When DisableMachineManagement annotation exists, it should return false", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + Annotations: map[string]string{ + hyperv1.DisableMachineManagement: "true", + }, + }, + Status: hyperv1.HostedControlPlaneStatus{ + KubeConfig: &hyperv1.KubeconfigSecretRef{Name: "kubeconfig", Key: "kubeconfig"}, + }, + }, + expectEnabled: false, + expectError: false, + }, + { + name: "When KubeConfig is nil, it should return false", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Status: hyperv1.HostedControlPlaneStatus{ + KubeConfig: nil, + }, + }, + expectEnabled: false, + expectError: false, + }, + { + name: "When kubeconfig secret is not found, it should return false", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Status: hyperv1.HostedControlPlaneStatus{ + KubeConfig: &hyperv1.KubeconfigSecretRef{Name: "kubeconfig", Key: "kubeconfig"}, + }, + }, + objects: []client.Object{}, + expectEnabled: false, + expectError: false, + }, + { + name: "When kubeconfig secret exists, it should return true", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Status: hyperv1.HostedControlPlaneStatus{ + KubeConfig: &hyperv1.KubeconfigSecretRef{Name: "kubeconfig", Key: "kubeconfig"}, + }, + }, + objects: []client.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: manifests.KASServiceKubeconfigSecret("test-namespace").Name, + Namespace: "test-namespace", + }, + }, + }, + expectEnabled: true, + expectError: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(tc.objects...). + Build() + + cpContext := component.WorkloadContext{ + Context: context.TODO(), + HCP: tc.hcp, + Client: fakeClient, + } + + enabled, err := predicate(cpContext) + + if tc.expectError { + g.Expect(err).To(HaveOccurred()) + } else { + g.Expect(err).ToNot(HaveOccurred()) + } + g.Expect(enabled).To(Equal(tc.expectEnabled)) + }) + } +} + +func TestPredicateError(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + _ = hyperv1.AddToScheme(scheme) + + tests := []struct { + name string + hcp *hyperv1.HostedControlPlane + client client.Client + }{ + { + name: "When client Get fails with non-NotFound error, it should return error", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Status: hyperv1.HostedControlPlaneStatus{ + KubeConfig: &hyperv1.KubeconfigSecretRef{Name: "kubeconfig", Key: "kubeconfig"}, + }, + }, + client: &fakeClientWithError{}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + cpContext := component.WorkloadContext{ + Context: context.TODO(), + HCP: tc.hcp, + Client: tc.client, + } + + enabled, err := predicate(cpContext) + + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("failed to get hosted controlplane kubeconfig secret")) + g.Expect(enabled).To(BeFalse()) + }) + } +} + +// fakeClientWithError is a fake client that returns a non-NotFound error +type fakeClientWithError struct { + client.Client +} + +func (f *fakeClientWithError) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + return apierrors.NewInternalError(fmt.Errorf("test error")) +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/machine_approver/deployment_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/machine_approver/deployment_test.go new file mode 100644 index 00000000000..8167042af1a --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/machine_approver/deployment_test.go @@ -0,0 +1,178 @@ +package machineapprover + +import ( + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + component "github.com/openshift/hypershift/support/controlplane-component" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestAdaptDeployment(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + namespace string + initialContainers []corev1.Container + validate func(*testing.T, *appsv1.Deployment) + }{ + { + name: "When deployment has machine-approver container, it should add machine-namespace arg", + namespace: "test-namespace", + initialContainers: []corev1.Container{ + { + Name: ComponentName, + Args: []string{"--existing-arg=value"}, + }, + }, + validate: func(t *testing.T, deployment *appsv1.Deployment) { + g := NewWithT(t) + g.Expect(deployment.Spec.Template.Spec.Containers).To(HaveLen(1)) + container := deployment.Spec.Template.Spec.Containers[0] + g.Expect(container.Name).To(Equal(ComponentName)) + g.Expect(container.Args).To(ContainElement("--existing-arg=value")) + g.Expect(container.Args).To(ContainElement("--machine-namespace=test-namespace")) + }, + }, + { + name: "When deployment has machine-approver container with no args, it should add machine-namespace arg", + namespace: "another-namespace", + initialContainers: []corev1.Container{ + { + Name: ComponentName, + }, + }, + validate: func(t *testing.T, deployment *appsv1.Deployment) { + g := NewWithT(t) + g.Expect(deployment.Spec.Template.Spec.Containers).To(HaveLen(1)) + container := deployment.Spec.Template.Spec.Containers[0] + g.Expect(container.Name).To(Equal(ComponentName)) + g.Expect(container.Args).To(ContainElement("--machine-namespace=another-namespace")) + }, + }, + { + name: "When deployment has multiple containers, it should only modify machine-approver container", + namespace: "test-namespace", + initialContainers: []corev1.Container{ + { + Name: "other-container", + Args: []string{"--other-arg=value"}, + }, + { + Name: ComponentName, + Args: []string{"--existing-arg=value"}, + }, + { + Name: "another-container", + Args: []string{"--another-arg=value"}, + }, + }, + validate: func(t *testing.T, deployment *appsv1.Deployment) { + g := NewWithT(t) + g.Expect(deployment.Spec.Template.Spec.Containers).To(HaveLen(3)) + + // First container should be unchanged + g.Expect(deployment.Spec.Template.Spec.Containers[0].Name).To(Equal("other-container")) + g.Expect(deployment.Spec.Template.Spec.Containers[0].Args).To(Equal([]string{"--other-arg=value"})) + + // Machine approver container should have the new arg + g.Expect(deployment.Spec.Template.Spec.Containers[1].Name).To(Equal(ComponentName)) + g.Expect(deployment.Spec.Template.Spec.Containers[1].Args).To(ContainElement("--existing-arg=value")) + g.Expect(deployment.Spec.Template.Spec.Containers[1].Args).To(ContainElement("--machine-namespace=test-namespace")) + + // Third container should be unchanged + g.Expect(deployment.Spec.Template.Spec.Containers[2].Name).To(Equal("another-container")) + g.Expect(deployment.Spec.Template.Spec.Containers[2].Args).To(Equal([]string{"--another-arg=value"})) + }, + }, + { + name: "When deployment has no machine-approver container, it should not modify any containers", + namespace: "test-namespace", + initialContainers: []corev1.Container{ + { + Name: "other-container", + Args: []string{"--other-arg=value"}, + }, + }, + validate: func(t *testing.T, deployment *appsv1.Deployment) { + g := NewWithT(t) + g.Expect(deployment.Spec.Template.Spec.Containers).To(HaveLen(1)) + g.Expect(deployment.Spec.Template.Spec.Containers[0].Name).To(Equal("other-container")) + g.Expect(deployment.Spec.Template.Spec.Containers[0].Args).To(Equal([]string{"--other-arg=value"})) + }, + }, + { + name: "When deployment has empty namespace, it should add empty machine-namespace arg", + namespace: "", + initialContainers: []corev1.Container{ + { + Name: ComponentName, + }, + }, + validate: func(t *testing.T, deployment *appsv1.Deployment) { + g := NewWithT(t) + g.Expect(deployment.Spec.Template.Spec.Containers).To(HaveLen(1)) + container := deployment.Spec.Template.Spec.Containers[0] + g.Expect(container.Args).To(ContainElement("--machine-namespace=")) + }, + }, + { + name: "When namespace has special characters, it should correctly format the arg", + namespace: "test-namespace-with-dashes_and_underscores", + initialContainers: []corev1.Container{ + { + Name: ComponentName, + }, + }, + validate: func(t *testing.T, deployment *appsv1.Deployment) { + g := NewWithT(t) + g.Expect(deployment.Spec.Template.Spec.Containers).To(HaveLen(1)) + container := deployment.Spec.Template.Spec.Containers[0] + g.Expect(container.Args).To(ContainElement("--machine-namespace=test-namespace-with-dashes_and_underscores")) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: tc.namespace, + }, + } + + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-deployment", + Namespace: tc.namespace, + }, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: tc.initialContainers, + }, + }, + }, + } + + cpContext := component.WorkloadContext{ + HCP: hcp, + } + + err := adaptDeployment(cpContext, deployment) + + g.Expect(err).ToNot(HaveOccurred()) + tc.validate(t, deployment) + }) + } +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/pkioperator/component_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/pkioperator/component_test.go new file mode 100644 index 00000000000..a42432898e6 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/pkioperator/component_test.go @@ -0,0 +1,155 @@ +package pkioperator + +import ( + "testing" + "time" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + component "github.com/openshift/hypershift/support/controlplane-component" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestPredicate(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + hcpAnnotations map[string]string + expected bool + }{ + { + name: "When DisablePKIReconciliationAnnotation is not present, it should return true", + hcpAnnotations: map[string]string{}, + expected: true, + }, + { + name: "When annotations are nil, it should return true", + hcpAnnotations: nil, + expected: true, + }, + { + name: "When DisablePKIReconciliationAnnotation is present, it should return false", + hcpAnnotations: map[string]string{ + hyperv1.DisablePKIReconciliationAnnotation: "true", + }, + expected: false, + }, + { + name: "When DisablePKIReconciliationAnnotation is present with any value, it should return false", + hcpAnnotations: map[string]string{ + hyperv1.DisablePKIReconciliationAnnotation: "false", + }, + expected: false, + }, + { + name: "When DisablePKIReconciliationAnnotation is present with empty value, it should return false", + hcpAnnotations: map[string]string{ + hyperv1.DisablePKIReconciliationAnnotation: "", + }, + expected: false, + }, + { + name: "When other annotations are present, it should return true", + hcpAnnotations: map[string]string{ + "some.other.annotation": "value", + }, + expected: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + Annotations: tc.hcpAnnotations, + }, + Spec: hyperv1.HostedControlPlaneSpec{}, + } + + cpContext := component.WorkloadContext{ + HCP: hcp, + } + + result, err := predicate(cpContext) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(result).To(Equal(tc.expected)) + }) + } +} + +func TestPKIOperatorOptions(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + certRotationScale time.Duration + validate func(*WithT, *pkiOperator) + }{ + { + name: "When created with 1 hour rotation scale, it should store that value", + certRotationScale: time.Hour, + validate: func(g *WithT, op *pkiOperator) { + g.Expect(op.certRotationScale).To(Equal(time.Hour)) + }, + }, + { + name: "When created with 24 hour rotation scale, it should store that value", + certRotationScale: 24 * time.Hour, + validate: func(g *WithT, op *pkiOperator) { + g.Expect(op.certRotationScale).To(Equal(24 * time.Hour)) + }, + }, + { + name: "When created with 30 minute rotation scale, it should store that value", + certRotationScale: 30 * time.Minute, + validate: func(g *WithT, op *pkiOperator) { + g.Expect(op.certRotationScale).To(Equal(30 * time.Minute)) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + op := &pkiOperator{ + certRotationScale: tc.certRotationScale, + } + + tc.validate(g, op) + }) + } + + t.Run("When IsRequestServing is called, it should return false", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + op := &pkiOperator{} + g.Expect(op.IsRequestServing()).To(BeFalse()) + }) + + t.Run("When MultiZoneSpread is called, it should return false", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + op := &pkiOperator{} + g.Expect(op.MultiZoneSpread()).To(BeFalse()) + }) + + t.Run("When NeedsManagementKASAccess is called, it should return true", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + op := &pkiOperator{} + g.Expect(op.NeedsManagementKASAccess()).To(BeTrue()) + }) +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/pkioperator/deployment_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/pkioperator/deployment_test.go new file mode 100644 index 00000000000..e71a7435073 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/pkioperator/deployment_test.go @@ -0,0 +1,290 @@ +package pkioperator + +import ( + "testing" + "time" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + assets "github.com/openshift/hypershift/control-plane-operator/controllers/hostedcontrolplane/v2/assets" + component "github.com/openshift/hypershift/support/controlplane-component" + "github.com/openshift/hypershift/support/util" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestAdaptDeployment(t *testing.T) { + testCases := []struct { + name string + hcpName string + certRotationScale time.Duration + httpProxy string + httpsProxy string + noProxy string + validate func(*WithT, *corev1.Container) + }{ + { + name: "When HCP name is set, it should add HOSTED_CONTROL_PLANE_NAME env var", + hcpName: "test-hcp", + certRotationScale: time.Hour, + validate: func(g *WithT, container *corev1.Container) { + g.Expect(container.Env).To(ContainElement(corev1.EnvVar{ + Name: "HOSTED_CONTROL_PLANE_NAME", + Value: "test-hcp", + })) + }, + }, + { + name: "When different HCP name is set, it should use that name", + hcpName: "another-hcp", + certRotationScale: time.Hour, + validate: func(g *WithT, container *corev1.Container) { + g.Expect(container.Env).To(ContainElement(corev1.EnvVar{ + Name: "HOSTED_CONTROL_PLANE_NAME", + Value: "another-hcp", + })) + }, + }, + { + name: "When cert rotation scale is 1 hour, it should add CERT_ROTATION_SCALE env var", + hcpName: "test-hcp", + certRotationScale: time.Hour, + validate: func(g *WithT, container *corev1.Container) { + g.Expect(container.Env).To(ContainElement(corev1.EnvVar{ + Name: "CERT_ROTATION_SCALE", + Value: "1h0m0s", + })) + }, + }, + { + name: "When cert rotation scale is 24 hours, it should format correctly", + hcpName: "test-hcp", + certRotationScale: 24 * time.Hour, + validate: func(g *WithT, container *corev1.Container) { + g.Expect(container.Env).To(ContainElement(corev1.EnvVar{ + Name: "CERT_ROTATION_SCALE", + Value: "24h0m0s", + })) + }, + }, + { + name: "When cert rotation scale is 30 minutes, it should format correctly", + hcpName: "test-hcp", + certRotationScale: 30 * time.Minute, + validate: func(g *WithT, container *corev1.Container) { + g.Expect(container.Env).To(ContainElement(corev1.EnvVar{ + Name: "CERT_ROTATION_SCALE", + Value: "30m0s", + })) + }, + }, + { + name: "When proxy environment variables are set, it should add proxy env vars to container", + hcpName: "test-hcp", + certRotationScale: time.Hour, + httpProxy: "http://proxy.example.com:8080", + httpsProxy: "https://proxy.example.com:8443", + noProxy: "localhost,127.0.0.1", + validate: func(g *WithT, container *corev1.Container) { + g.Expect(container.Env).To(ContainElements( + corev1.EnvVar{ + Name: "HTTP_PROXY", + Value: "http://proxy.example.com:8080", + }, + corev1.EnvVar{ + Name: "HTTPS_PROXY", + Value: "https://proxy.example.com:8443", + }, + )) + // NO_PROXY will have kube-apiserver added + var foundNoProxy bool + for _, env := range container.Env { + if env.Name == "NO_PROXY" { + foundNoProxy = true + g.Expect(env.Value).To(ContainSubstring("localhost")) + g.Expect(env.Value).To(ContainSubstring("127.0.0.1")) + g.Expect(env.Value).To(ContainSubstring("kube-apiserver")) + } + } + g.Expect(foundNoProxy).To(BeTrue()) + }, + }, + { + name: "When no proxy is set, it should not add proxy env vars", + hcpName: "test-hcp", + certRotationScale: time.Hour, + validate: func(g *WithT, container *corev1.Container) { + for _, env := range container.Env { + g.Expect(env.Name).ToNot(Equal("HTTP_PROXY")) + g.Expect(env.Name).ToNot(Equal("HTTPS_PROXY")) + g.Expect(env.Name).ToNot(Equal("NO_PROXY")) + } + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + + // Set deterministic baseline for proxy environment variables + t.Setenv("HTTP_PROXY", tc.httpProxy) + t.Setenv("HTTPS_PROXY", tc.httpsProxy) + t.Setenv("NO_PROXY", tc.noProxy) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: tc.hcpName, + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{}, + } + + operator := &pkiOperator{ + certRotationScale: tc.certRotationScale, + } + + cpContext := component.WorkloadContext{ + HCP: hcp, + } + + deployment, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + err = operator.adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + // Find the control-plane-pki-operator container + pkiContainer := util.FindContainer(ComponentName, deployment.Spec.Template.Spec.Containers) + g.Expect(pkiContainer).ToNot(BeNil(), "control-plane-pki-operator container should exist") + + tc.validate(g, pkiContainer) + }) + } +} + +func TestAdaptDeploymentCombinedEnvVars(t *testing.T) { + g := NewWithT(t) + + t.Setenv("HTTP_PROXY", "http://proxy.example.com:8080") + t.Setenv("HTTPS_PROXY", "https://proxy.example.com:8443") + t.Setenv("NO_PROXY", "localhost,127.0.0.1") + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "combined-test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{}, + } + + operator := &pkiOperator{ + certRotationScale: 2 * time.Hour, + } + + cpContext := component.WorkloadContext{ + HCP: hcp, + } + + deployment, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + err = operator.adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + // Find the control-plane-pki-operator container + pkiContainer := util.FindContainer(ComponentName, deployment.Spec.Template.Spec.Containers) + g.Expect(pkiContainer).ToNot(BeNil()) + + t.Run("When all configuration is set, it should include all environment variables", func(t *testing.T) { + g.Expect(pkiContainer.Env).To(ContainElements( + corev1.EnvVar{ + Name: "HOSTED_CONTROL_PLANE_NAME", + Value: "combined-test-hcp", + }, + corev1.EnvVar{ + Name: "CERT_ROTATION_SCALE", + Value: "2h0m0s", + }, + corev1.EnvVar{ + Name: "HTTP_PROXY", + Value: "http://proxy.example.com:8080", + }, + corev1.EnvVar{ + Name: "HTTPS_PROXY", + Value: "https://proxy.example.com:8443", + }, + )) + // Check NO_PROXY contains expected values (it will also have kube-apiserver added) + var foundNoProxy bool + for _, env := range pkiContainer.Env { + if env.Name == "NO_PROXY" { + foundNoProxy = true + g.Expect(env.Value).To(ContainSubstring("localhost")) + g.Expect(env.Value).To(ContainSubstring("127.0.0.1")) + g.Expect(env.Value).To(ContainSubstring("kube-apiserver")) + } + } + g.Expect(foundNoProxy).To(BeTrue()) + }) +} + +func TestAdaptDeploymentReturnsNoError(t *testing.T) { + t.Parallel() + + t.Run("When adaptDeployment is called, it should not return an error", func(t *testing.T) { + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{}, + } + + operator := &pkiOperator{ + certRotationScale: time.Hour, + } + + cpContext := component.WorkloadContext{ + HCP: hcp, + } + + deployment, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + err = operator.adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + }) + + t.Run("When adaptDeployment is called, it should preserve other containers", func(t *testing.T) { + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{}, + } + + operator := &pkiOperator{ + certRotationScale: time.Hour, + } + + cpContext := component.WorkloadContext{ + HCP: hcp, + } + + deployment, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + err = operator.adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + g.Expect(len(deployment.Spec.Template.Spec.Containers)).To(BeNumerically(">", 0)) + }) +} From 77b5f8b9ad281c47efba90f669594a9af3929544 Mon Sep 17 00:00:00 2001 From: Bryan Cox Date: Mon, 13 Apr 2026 08:34:56 -0400 Subject: [PATCH 4/7] test: Add unit tests for v2 networking controller packages Add behavior-driven unit tests for the DNS operator and ignition server proxy v2 controller packages covering deployment adaptation, service configuration, and component predicates. Co-Authored-By: Claude Opus 4.6 --- .../v2/dnsoperator/component_test.go | 46 +++ .../v2/dnsoperator/deployment_test.go | 159 ++++++++ .../v2/ignitionserver_proxy/component_test.go | 131 ++++++ .../ignitionserver_proxy/deployment_test.go | 145 +++++++ .../v2/ignitionserver_proxy/service_test.go | 379 ++++++++++++++++++ 5 files changed, 860 insertions(+) create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/dnsoperator/component_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/dnsoperator/deployment_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/ignitionserver_proxy/component_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/ignitionserver_proxy/deployment_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/ignitionserver_proxy/service_test.go diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/dnsoperator/component_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/dnsoperator/component_test.go new file mode 100644 index 00000000000..8354f51d021 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/dnsoperator/component_test.go @@ -0,0 +1,46 @@ +package dnsoperator + +import ( + "testing" + + . "github.com/onsi/gomega" +) + +func TestComponentOptions(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + validate func(*testing.T, *dnsOperator) + }{ + { + name: "When checking IsRequestServing, it should return false", + validate: func(t *testing.T, d *dnsOperator) { + g := NewWithT(t) + g.Expect(d.IsRequestServing()).To(BeFalse()) + }, + }, + { + name: "When checking MultiZoneSpread, it should return true", + validate: func(t *testing.T, d *dnsOperator) { + g := NewWithT(t) + g.Expect(d.MultiZoneSpread()).To(BeTrue()) + }, + }, + { + name: "When checking NeedsManagementKASAccess, it should return false", + validate: func(t *testing.T, d *dnsOperator) { + g := NewWithT(t) + g.Expect(d.NeedsManagementKASAccess()).To(BeFalse()) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + d := &dnsOperator{} + tc.validate(t, d) + }) + } +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/dnsoperator/deployment_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/dnsoperator/deployment_test.go new file mode 100644 index 00000000000..3902ebe0d21 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/dnsoperator/deployment_test.go @@ -0,0 +1,159 @@ +package dnsoperator + +import ( + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + assets "github.com/openshift/hypershift/control-plane-operator/controllers/hostedcontrolplane/v2/assets" + component "github.com/openshift/hypershift/support/controlplane-component" + "github.com/openshift/hypershift/support/testutil" + "github.com/openshift/hypershift/support/util" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" +) + +func TestAdaptDeployment(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + validate func(*testing.T, component.WorkloadContext) + }{ + { + name: "When adapting deployment, it should set correct command", + validate: func(t *testing.T, cpContext component.WorkloadContext) { + g := NewWithT(t) + deployment, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + err = adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + dnsContainer := util.FindContainer("dns-operator", deployment.Spec.Template.Spec.Containers) + + g.Expect(dnsContainer).ToNot(BeNil()) + g.Expect(dnsContainer.Command).To(Equal([]string{"dns-operator"})) + }, + }, + { + name: "When adapting deployment, it should set ImagePullPolicy to IfNotPresent", + validate: func(t *testing.T, cpContext component.WorkloadContext) { + g := NewWithT(t) + deployment, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + err = adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + dnsContainer := util.FindContainer("dns-operator", deployment.Spec.Template.Spec.Containers) + + g.Expect(dnsContainer).ToNot(BeNil()) + g.Expect(dnsContainer.ImagePullPolicy).To(Equal(corev1.PullIfNotPresent)) + }, + }, + { + name: "When adapting deployment, it should configure all required environment variables", + validate: func(t *testing.T, cpContext component.WorkloadContext) { + g := NewWithT(t) + deployment, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + err = adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + dnsContainer := util.FindContainer("dns-operator", deployment.Spec.Template.Spec.Containers) + + g.Expect(dnsContainer).ToNot(BeNil()) + g.Expect(dnsContainer.Env).To(HaveLen(5)) + + envMap := make(map[string]string) + for _, env := range dnsContainer.Env { + envMap[env.Name] = env.Value + } + + g.Expect(envMap).To(HaveKeyWithValue("RELEASE_VERSION", "4.18.0")) + g.Expect(envMap).To(HaveKeyWithValue("IMAGE", "coredns")) + g.Expect(envMap).To(HaveKeyWithValue("OPENSHIFT_CLI_IMAGE", "cli")) + g.Expect(envMap).To(HaveKeyWithValue("KUBE_RBAC_PROXY_IMAGE", "kube-rbac-proxy")) + g.Expect(envMap).To(HaveKeyWithValue("KUBECONFIG", "/etc/kubernetes/kubeconfig")) + }, + }, + { + name: "When adapting deployment, it should set correct resource requests", + validate: func(t *testing.T, cpContext component.WorkloadContext) { + g := NewWithT(t) + deployment, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + err = adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + dnsContainer := util.FindContainer("dns-operator", deployment.Spec.Template.Spec.Containers) + + g.Expect(dnsContainer).ToNot(BeNil()) + g.Expect(dnsContainer.Resources.Requests).To(HaveKeyWithValue(corev1.ResourceCPU, resource.MustParse("10m"))) + g.Expect(dnsContainer.Resources.Requests).To(HaveKeyWithValue(corev1.ResourceMemory, resource.MustParse("29Mi"))) + }, + }, + { + name: "When adapting deployment, it should set TerminationMessagePolicy to FallbackToLogsOnError", + validate: func(t *testing.T, cpContext component.WorkloadContext) { + g := NewWithT(t) + deployment, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + err = adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + dnsContainer := util.FindContainer("dns-operator", deployment.Spec.Template.Spec.Containers) + + g.Expect(dnsContainer).ToNot(BeNil()) + g.Expect(dnsContainer.TerminationMessagePolicy).To(Equal(corev1.TerminationMessageFallbackToLogsOnError)) + }, + }, + { + name: "When adapting deployment, it should set termination grace period to 2 seconds", + validate: func(t *testing.T, cpContext component.WorkloadContext) { + g := NewWithT(t) + deployment, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + err = adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + g.Expect(deployment.Spec.Template.Spec.TerminationGracePeriodSeconds).To(Equal(ptr.To[int64](2))) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Platform: hyperv1.PlatformSpec{ + Type: hyperv1.AWSPlatform, + }, + }, + } + + cpContext := component.WorkloadContext{ + Context: t.Context(), + HCP: hcp, + UserReleaseImageProvider: testutil.FakeImageProvider(), + } + + tc.validate(t, cpContext) + }) + } +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/ignitionserver_proxy/component_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/ignitionserver_proxy/component_test.go new file mode 100644 index 00000000000..a880243a915 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/ignitionserver_proxy/component_test.go @@ -0,0 +1,131 @@ +package ignitionserverproxy + +import ( + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + component "github.com/openshift/hypershift/support/controlplane-component" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestPredicate(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + platform hyperv1.PlatformType + annotations map[string]string + expected bool + }{ + { + name: "When platform is AWS and ignition is not disabled, it should return true", + platform: hyperv1.AWSPlatform, + expected: true, + }, + { + name: "When platform is Azure and ignition is not disabled, it should return true", + platform: hyperv1.AzurePlatform, + expected: true, + }, + { + name: "When platform is IBMCloud, it should return false", + platform: hyperv1.IBMCloudPlatform, + expected: false, + }, + { + name: "When platform is PowerVS, it should return true", + platform: hyperv1.PowerVSPlatform, + expected: true, + }, + { + name: "When platform is KubeVirt, it should return true", + platform: hyperv1.KubevirtPlatform, + expected: true, + }, + { + name: "When platform is Agent, it should return true", + platform: hyperv1.AgentPlatform, + expected: true, + }, + { + name: "When platform is OpenStack, it should return true", + platform: hyperv1.OpenStackPlatform, + expected: true, + }, + { + name: "When DisableIgnitionServerAnnotation is set, it should return false", + platform: hyperv1.AWSPlatform, + annotations: map[string]string{ + hyperv1.DisableIgnitionServerAnnotation: "true", + }, + expected: false, + }, + { + name: "When DisableIgnitionServerAnnotation is set on IBMCloud, it should return false", + platform: hyperv1.IBMCloudPlatform, + annotations: map[string]string{ + hyperv1.DisableIgnitionServerAnnotation: "true", + }, + expected: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + Annotations: tc.annotations, + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Platform: hyperv1.PlatformSpec{ + Type: tc.platform, + }, + }, + } + + cpContext := component.WorkloadContext{ + HCP: hcp, + } + + result, err := predicate(cpContext) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(result).To(Equal(tc.expected)) + }) + } +} + +func TestIsRequestServing(t *testing.T) { + t.Parallel() + + g := NewWithT(t) + + proxy := &ignitionServerProxy{} + g.Expect(proxy.IsRequestServing()).To(BeTrue()) +} + +func TestMultiZoneSpread(t *testing.T) { + t.Parallel() + + g := NewWithT(t) + + proxy := &ignitionServerProxy{} + g.Expect(proxy.MultiZoneSpread()).To(BeTrue()) +} + +func TestNeedsManagementKASAccess(t *testing.T) { + t.Parallel() + + g := NewWithT(t) + + proxy := &ignitionServerProxy{} + g.Expect(proxy.NeedsManagementKASAccess()).To(BeFalse()) +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/ignitionserver_proxy/deployment_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/ignitionserver_proxy/deployment_test.go new file mode 100644 index 00000000000..3f0944dc99c --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/ignitionserver_proxy/deployment_test.go @@ -0,0 +1,145 @@ +package ignitionserverproxy + +import ( + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + assets "github.com/openshift/hypershift/control-plane-operator/controllers/hostedcontrolplane/v2/assets" + component "github.com/openshift/hypershift/support/controlplane-component" + "github.com/openshift/hypershift/support/util" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestAdaptDeployment(t *testing.T) { + testCases := []struct { + name string + additionalTrustBundle *corev1.LocalObjectReference + expectTrustBundleVolume bool + expectTrustBundleMount bool + expectedVolumeName string + expectedConfigMapName string + }{ + { + name: "When no additional trust bundle is set, it should not add trust bundle volume", + additionalTrustBundle: nil, + expectTrustBundleVolume: false, + expectTrustBundleMount: false, + }, + { + name: "When additional trust bundle is set, it should add trust bundle volume and mount", + additionalTrustBundle: &corev1.LocalObjectReference{ + Name: "custom-ca-bundle", + }, + expectTrustBundleVolume: true, + expectTrustBundleMount: true, + expectedVolumeName: "trusted-ca", + expectedConfigMapName: "custom-ca-bundle", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + AdditionalTrustBundle: tc.additionalTrustBundle, + }, + } + + cpContext := component.WorkloadContext{ + HCP: hcp, + } + + deployment, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + err = adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + // Verify trust bundle volume configuration + if tc.expectTrustBundleVolume { + volume := util.FindVolume(tc.expectedVolumeName, deployment.Spec.Template.Spec.Volumes) + g.Expect(volume).ToNot(BeNil(), "trust bundle volume should exist") + g.Expect(volume.ConfigMap).ToNot(BeNil(), "volume should use ConfigMap source") + g.Expect(volume.ConfigMap.Name).To(Equal(tc.expectedConfigMapName)) + g.Expect(volume.ConfigMap.Items).To(HaveLen(1)) + g.Expect(volume.ConfigMap.Items[0].Key).To(Equal("ca-bundle.crt")) + g.Expect(volume.ConfigMap.Items[0].Path).To(Equal("user-ca-bundle.pem")) + } else { + // Verify trust bundle volume is NOT present when not configured + g.Expect(util.FindVolume("trusted-ca", deployment.Spec.Template.Spec.Volumes)).To(BeNil(), "trust bundle volume should not exist") + } + + // Verify trust bundle mount on first container (DeploymentAddTrustBundleVolume adds to first container) + if tc.expectTrustBundleMount { + g.Expect(deployment.Spec.Template.Spec.Containers).ToNot(BeEmpty(), "deployment should have containers") + + firstContainer := &deployment.Spec.Template.Spec.Containers[0] + mount := util.FindVolumeMount(tc.expectedVolumeName, firstContainer.VolumeMounts) + g.Expect(mount).ToNot(BeNil(), "trust bundle volume mount should exist on first container") + g.Expect(mount.MountPath).To(Equal("/etc/pki/tls/certs")) + g.Expect(mount.ReadOnly).To(BeTrue()) + } else { + // Verify trust bundle mount is NOT present + if len(deployment.Spec.Template.Spec.Containers) > 0 { + firstContainer := &deployment.Spec.Template.Spec.Containers[0] + g.Expect(util.FindVolumeMount("trusted-ca", firstContainer.VolumeMounts)).To(BeNil(), "trust bundle volume mount should not exist") + } + } + }) + } +} + +func TestAdaptDeploymentWithProxyEnvVars(t *testing.T) { + g := NewWithT(t) + + // Set proxy environment variables for this test + t.Setenv("HTTP_PROXY", "http://proxy.example.com:8080") + t.Setenv("HTTPS_PROXY", "https://proxy.example.com:8443") + t.Setenv("NO_PROXY", "localhost,127.0.0.1") + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{}, + } + + cpContext := component.WorkloadContext{ + HCP: hcp, + } + + deployment, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + err = adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + // Find haproxy container + haproxyContainer := util.FindContainer("haproxy", deployment.Spec.Template.Spec.Containers) + g.Expect(haproxyContainer).ToNot(BeNil(), "haproxy container should exist") + + // Verify proxy env vars are set + httpProxy := util.FindEnvVar("HTTP_PROXY", haproxyContainer.Env) + g.Expect(httpProxy).ToNot(BeNil()) + g.Expect(httpProxy.Value).To(Equal("http://proxy.example.com:8080")) + + httpsProxy := util.FindEnvVar("HTTPS_PROXY", haproxyContainer.Env) + g.Expect(httpsProxy).ToNot(BeNil()) + g.Expect(httpsProxy.Value).To(Equal("https://proxy.example.com:8443")) + + noProxy := util.FindEnvVar("NO_PROXY", haproxyContainer.Env) + g.Expect(noProxy).ToNot(BeNil()) + g.Expect(noProxy.Value).To(ContainSubstring("localhost")) + g.Expect(noProxy.Value).To(ContainSubstring("kube-apiserver")) +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/ignitionserver_proxy/service_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/ignitionserver_proxy/service_test.go new file mode 100644 index 00000000000..95f128c5ddd --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/ignitionserver_proxy/service_test.go @@ -0,0 +1,379 @@ +package ignitionserverproxy + +import ( + "fmt" + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + component "github.com/openshift/hypershift/support/controlplane-component" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestAdaptService(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + services []hyperv1.ServicePublishingStrategyMapping + expectedType corev1.ServiceType + expectedPort int32 + expectError bool + errorMessage string + }{ + { + name: "When NodePort strategy is configured without specific port, it should create NodePort service", + services: []hyperv1.ServicePublishingStrategyMapping{ + { + Service: hyperv1.Ignition, + ServicePublishingStrategy: hyperv1.ServicePublishingStrategy{ + Type: hyperv1.NodePort, + }, + }, + }, + expectedType: corev1.ServiceTypeNodePort, + expectedPort: 0, + expectError: false, + }, + { + name: "When NodePort strategy is configured with specific port, it should create NodePort service with specified port", + services: []hyperv1.ServicePublishingStrategyMapping{ + { + Service: hyperv1.Ignition, + ServicePublishingStrategy: hyperv1.ServicePublishingStrategy{ + Type: hyperv1.NodePort, + NodePort: &hyperv1.NodePortPublishingStrategy{ + Port: 30123, + }, + }, + }, + }, + expectedType: corev1.ServiceTypeNodePort, + expectedPort: 30123, + expectError: false, + }, + { + name: "When Route strategy is configured, it should create ClusterIP service", + services: []hyperv1.ServicePublishingStrategyMapping{ + { + Service: hyperv1.Ignition, + ServicePublishingStrategy: hyperv1.ServicePublishingStrategy{ + Type: hyperv1.Route, + }, + }, + }, + expectedType: corev1.ServiceTypeClusterIP, + expectedPort: 0, + expectError: false, + }, + { + name: "When LoadBalancer strategy is configured, it should return error", + services: []hyperv1.ServicePublishingStrategyMapping{ + { + Service: hyperv1.Ignition, + ServicePublishingStrategy: hyperv1.ServicePublishingStrategy{ + Type: hyperv1.LoadBalancer, + }, + }, + }, + expectError: true, + errorMessage: "invalid publishing strategy for Ignition service: LoadBalancer", + }, + { + name: "When S3 strategy is configured, it should return error", + services: []hyperv1.ServicePublishingStrategyMapping{ + { + Service: hyperv1.Ignition, + ServicePublishingStrategy: hyperv1.ServicePublishingStrategy{ + Type: hyperv1.S3, + }, + }, + }, + expectError: true, + errorMessage: "invalid publishing strategy for Ignition service: S3", + }, + { + name: "When ignition service strategy is not specified, it should return error", + services: []hyperv1.ServicePublishingStrategyMapping{}, + expectError: true, + errorMessage: "ignition service strategy not specified", + }, + { + name: "When ignition service strategy is missing in service list, it should return error", + services: []hyperv1.ServicePublishingStrategyMapping{ + { + Service: hyperv1.APIServer, + ServicePublishingStrategy: hyperv1.ServicePublishingStrategy{ + Type: hyperv1.Route, + }, + }, + }, + expectError: true, + errorMessage: "ignition service strategy not specified", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Services: tc.services, + }, + } + + cpContext := component.WorkloadContext{ + HCP: hcp, + } + + svc := &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Ports: []corev1.ServicePort{ + { + Name: "https", + Port: 443, + Protocol: corev1.ProtocolTCP, + }, + }, + }, + } + + err := adaptService(cpContext, svc) + + if tc.expectError { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(Equal(tc.errorMessage)) + } else { + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(svc.Spec.Type).To(Equal(tc.expectedType)) + if tc.expectedPort > 0 { + g.Expect(svc.Spec.Ports).To(HaveLen(1)) + g.Expect(svc.Spec.Ports[0].NodePort).To(Equal(tc.expectedPort)) + } else { + g.Expect(svc.Spec.Ports).To(HaveLen(1)) + g.Expect(svc.Spec.Ports[0].NodePort).To(Equal(int32(0)), "NodePort should not be set when no specific port is configured") + } + } + }) + } +} + +func TestAdaptServicePreservesNodePort(t *testing.T) { + t.Parallel() + + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Services: []hyperv1.ServicePublishingStrategyMapping{ + { + Service: hyperv1.Ignition, + ServicePublishingStrategy: hyperv1.ServicePublishingStrategy{ + Type: hyperv1.NodePort, + NodePort: &hyperv1.NodePortPublishingStrategy{ + Port: 30456, + }, + }, + }, + }, + }, + } + + cpContext := component.WorkloadContext{ + HCP: hcp, + } + + // Simulate existing service with different NodePort + svc := &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Ports: []corev1.ServicePort{ + { + Name: "https", + Port: 443, + Protocol: corev1.ProtocolTCP, + NodePort: 31000, // Existing port, different from strategy + }, + }, + }, + } + + err := adaptService(cpContext, svc) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(svc.Spec.Type).To(Equal(corev1.ServiceTypeNodePort)) + // The adapt function should update the NodePort to match the strategy + g.Expect(svc.Spec.Ports[0].NodePort).To(Equal(int32(30456))) +} + +func TestAdaptServiceMultiplePorts(t *testing.T) { + t.Parallel() + + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Services: []hyperv1.ServicePublishingStrategyMapping{ + { + Service: hyperv1.Ignition, + ServicePublishingStrategy: hyperv1.ServicePublishingStrategy{ + Type: hyperv1.NodePort, + NodePort: &hyperv1.NodePortPublishingStrategy{ + Port: 30789, + }, + }, + }, + }, + }, + } + + cpContext := component.WorkloadContext{ + HCP: hcp, + } + + // Service with multiple ports + svc := &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Ports: []corev1.ServicePort{ + { + Name: "https", + Port: 443, + Protocol: corev1.ProtocolTCP, + }, + { + Name: "metrics", + Port: 9090, + Protocol: corev1.ProtocolTCP, + }, + }, + }, + } + + err := adaptService(cpContext, svc) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(svc.Spec.Type).To(Equal(corev1.ServiceTypeNodePort)) + // Only the first port should have the NodePort set + g.Expect(svc.Spec.Ports[0].NodePort).To(Equal(int32(30789))) +} + +func TestServicePublishingStrategyByTypeForHCP(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + services []hyperv1.ServicePublishingStrategyMapping + validate func(g Gomega, err error) + }{ + { + name: "When nil services are provided, it should return error", + services: nil, + validate: func(g Gomega, err error) { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(Equal("ignition service strategy not specified")) + }, + }, + { + name: "When empty services slice is provided, it should return error", + services: []hyperv1.ServicePublishingStrategyMapping{}, + validate: func(g Gomega, err error) { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(Equal("ignition service strategy not specified")) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + Spec: hyperv1.HostedControlPlaneSpec{ + Services: tc.services, + }, + } + + cpContext := component.WorkloadContext{ + HCP: hcp, + } + + svc := &corev1.Service{ + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + {Name: "https", Port: 443}, + }, + }, + } + + err := adaptService(cpContext, svc) + tc.validate(g, err) + }) + } +} + +func TestAdaptServiceValidatesStrategyType(t *testing.T) { + t.Parallel() + + invalidStrategies := []hyperv1.PublishingStrategyType{ + hyperv1.LoadBalancer, + hyperv1.S3, + // Any other non-NodePort/Route strategy + } + + for _, strategyType := range invalidStrategies { + t.Run(fmt.Sprintf("When strategy type is %s, it should return error", strategyType), func(t *testing.T) { + t.Parallel() + + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + Spec: hyperv1.HostedControlPlaneSpec{ + Services: []hyperv1.ServicePublishingStrategyMapping{ + { + Service: hyperv1.Ignition, + ServicePublishingStrategy: hyperv1.ServicePublishingStrategy{ + Type: strategyType, + }, + }, + }, + }, + } + + cpContext := component.WorkloadContext{ + HCP: hcp, + } + + svc := &corev1.Service{ + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + {Name: "https", Port: 443}, + }, + }, + } + + err := adaptService(cpContext, svc) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("invalid publishing strategy for Ignition service")) + }) + } +} From 14f2a962e472350d928d93d27fb29842bff349bd Mon Sep 17 00:00:00 2001 From: Bryan Cox Date: Mon, 13 Apr 2026 08:35:00 -0400 Subject: [PATCH 5/7] test: Add unit tests for v2 workload controller packages Add behavior-driven unit tests for the NTO, OAuth API server, and Karpenter operator v2 controller packages covering deployment adaptation, service monitor configuration, predicates, and secret generation. Co-Authored-By: Claude Opus 4.6 --- .../v2/karpenteroperator/component_test.go | 173 +++++ .../v2/karpenteroperator/deployment_test.go | 233 ++++++ .../v2/karpenteroperator/podmonitor_test.go | 107 +++ .../v2/karpenteroperator/secret_test.go | 161 +++++ .../v2/machine_approver/deployment_test.go | 7 +- .../v2/nto/component_test.go | 139 ++++ .../v2/nto/deployment_test.go | 139 ++++ .../v2/nto/servicemonitor_test.go | 283 ++++++++ .../v2/oauth_apiserver/component_test.go | 137 ++++ .../v2/oauth_apiserver/deployment_test.go | 673 ++++++++++++++++++ support/testutil/fake.go | 42 +- support/util/containers.go | 27 + support/util/containers_test.go | 115 +++ 13 files changed, 2231 insertions(+), 5 deletions(-) create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/karpenteroperator/component_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/karpenteroperator/deployment_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/karpenteroperator/podmonitor_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/karpenteroperator/secret_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/nto/component_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/nto/deployment_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/nto/servicemonitor_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/oauth_apiserver/component_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/oauth_apiserver/deployment_test.go diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/karpenteroperator/component_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/karpenteroperator/component_test.go new file mode 100644 index 00000000000..aec2953e697 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/karpenteroperator/component_test.go @@ -0,0 +1,173 @@ +package karpenteroperator + +import ( + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + "github.com/openshift/hypershift/control-plane-operator/hostedclusterconfigoperator/api" + controlplanecomponent "github.com/openshift/hypershift/support/controlplane-component" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestPredicate(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + autoNode *hyperv1.AutoNode + hcpStatus *hyperv1.KubeconfigSecretRef + kubeconfigSecret client.Object + expected bool + expectError bool + }{ + { + name: "When Karpenter is enabled and kubeconfig exists, it should return true", + autoNode: &hyperv1.AutoNode{ + Provisioner: hyperv1.ProvisionerConfig{ + Name: hyperv1.ProvisionerKarpenter, + Karpenter: &hyperv1.KarpenterConfig{ + Platform: hyperv1.AWSPlatform, + AWS: &hyperv1.KarpenterAWSConfig{ + RoleARN: "arn:aws:iam::123456789012:role/karpenter", + }, + }, + }, + }, + hcpStatus: &hyperv1.KubeconfigSecretRef{ + Name: "hcco-kubeconfig", + }, + kubeconfigSecret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "hcco-kubeconfig", + Namespace: "test-namespace", + }, + }, + expected: true, + expectError: false, + }, + { + name: "When Karpenter is not enabled, it should return false", + autoNode: &hyperv1.AutoNode{ + Provisioner: hyperv1.ProvisionerConfig{ + Name: "", + }, + }, + expected: false, + expectError: false, + }, + { + name: "When autoNode is nil, it should return false", + autoNode: nil, + expected: false, + }, + { + name: "When kubeconfig status is nil, it should return false", + autoNode: &hyperv1.AutoNode{ + Provisioner: hyperv1.ProvisionerConfig{ + Name: hyperv1.ProvisionerKarpenter, + Karpenter: &hyperv1.KarpenterConfig{ + Platform: hyperv1.AWSPlatform, + AWS: &hyperv1.KarpenterAWSConfig{ + RoleARN: "arn:aws:iam::123456789012:role/karpenter", + }, + }, + }, + }, + hcpStatus: nil, + expected: false, + expectError: false, + }, + { + name: "When kubeconfig secret does not exist, it should return error", + autoNode: &hyperv1.AutoNode{ + Provisioner: hyperv1.ProvisionerConfig{ + Name: hyperv1.ProvisionerKarpenter, + Karpenter: &hyperv1.KarpenterConfig{ + Platform: hyperv1.AWSPlatform, + AWS: &hyperv1.KarpenterAWSConfig{ + RoleARN: "arn:aws:iam::123456789012:role/karpenter", + }, + }, + }, + }, + hcpStatus: &hyperv1.KubeconfigSecretRef{ + Name: "hcco-kubeconfig", + }, + kubeconfigSecret: nil, + expected: false, + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + AutoNode: tc.autoNode, + }, + Status: hyperv1.HostedControlPlaneStatus{ + KubeConfig: tc.hcpStatus, + }, + } + + clientBuilder := fake.NewClientBuilder().WithScheme(api.Scheme) + if tc.kubeconfigSecret != nil { + clientBuilder = clientBuilder.WithObjects(tc.kubeconfigSecret) + } + client := clientBuilder.Build() + + cpContext := controlplanecomponent.WorkloadContext{ + Context: t.Context(), + Client: client, + HCP: hcp, + } + + result, err := predicate(cpContext) + + if tc.expectError { + g.Expect(err).To(HaveOccurred()) + } else { + g.Expect(err).ToNot(HaveOccurred()) + } + g.Expect(result).To(Equal(tc.expected)) + }) + } +} + +func TestKarpenterOperatorOptions_IsRequestServing(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + opts := &KarpenterOperatorOptions{} + g.Expect(opts.IsRequestServing()).To(BeFalse()) +} + +func TestKarpenterOperatorOptions_MultiZoneSpread(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + opts := &KarpenterOperatorOptions{} + g.Expect(opts.MultiZoneSpread()).To(BeFalse()) +} + +func TestKarpenterOperatorOptions_NeedsManagementKASAccess(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + opts := &KarpenterOperatorOptions{} + g.Expect(opts.NeedsManagementKASAccess()).To(BeTrue()) +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/karpenteroperator/deployment_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/karpenteroperator/deployment_test.go new file mode 100644 index 00000000000..aa13f3d12b6 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/karpenteroperator/deployment_test.go @@ -0,0 +1,233 @@ +package karpenteroperator + +import ( + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + assets "github.com/openshift/hypershift/control-plane-operator/controllers/hostedcontrolplane/v2/assets" + controlplanecomponent "github.com/openshift/hypershift/support/controlplane-component" + "github.com/openshift/hypershift/support/rhobsmonitoring" + "github.com/openshift/hypershift/support/util" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestAdaptDeployment(t *testing.T) { + testCases := []struct { + name string + platformType hyperv1.PlatformType + awsRegion string + hyperShiftOperatorImage string + controlPlaneOperatorImage string + ignitionEndpoint string + rhobsEnabled bool + validateFunc func(t *testing.T, g Gomega, opts *KarpenterOperatorOptions, cpContext controlplanecomponent.WorkloadContext) + }{ + { + name: "When platform is AWS, it should configure AWS-specific volumes and environment", + platformType: hyperv1.AWSPlatform, + awsRegion: "us-west-2", + hyperShiftOperatorImage: "quay.io/hypershift/operator:latest", + ignitionEndpoint: "https://ignition.example.com", + validateFunc: func(t *testing.T, g Gomega, opts *KarpenterOperatorOptions, cpContext controlplanecomponent.WorkloadContext) { + t.Helper() + deploymentObj, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + err = opts.adaptDeployment(cpContext, deploymentObj) + g.Expect(err).ToNot(HaveOccurred()) + + // Verify provider-creds volume is added + g.Expect(deploymentObj.Spec.Template.Spec.Volumes).To(ContainElement( + WithTransform(func(vol corev1.Volume) string { + return vol.Name + }, Equal("provider-creds")), + )) + + // Verify the secret name for provider-creds + providerCredsVolume := util.FindVolume("provider-creds", deploymentObj.Spec.Template.Spec.Volumes) + g.Expect(providerCredsVolume).ToNot(BeNil()) + g.Expect(providerCredsVolume.VolumeSource.Secret).ToNot(BeNil()) + g.Expect(providerCredsVolume.VolumeSource.Secret.SecretName).To(Equal("karpenter-credentials")) + + // Verify container configuration + container := util.FindContainer(ComponentName, deploymentObj.Spec.Template.Spec.Containers) + g.Expect(container).ToNot(BeNil(), "container %s should exist", ComponentName) + g.Expect(container.Image).To(Equal("quay.io/hypershift/operator:latest")) + + // Verify AWS environment variables + g.Expect(container.Env).To(ContainElements( + corev1.EnvVar{ + Name: "AWS_SHARED_CREDENTIALS_FILE", + Value: "/etc/provider/credentials", + }, + corev1.EnvVar{ + Name: "AWS_REGION", + Value: "us-west-2", + }, + corev1.EnvVar{ + Name: "AWS_SDK_LOAD_CONFIG", + Value: "true", + }, + )) + + // Verify volume mount + g.Expect(container.VolumeMounts).To(ContainElement( + corev1.VolumeMount{ + Name: "provider-creds", + MountPath: "/etc/provider", + }, + )) + + // Verify arguments + g.Expect(container.Args).To(ContainElements( + "--hypershift-operator-image=quay.io/hypershift/operator:latest", + "--ignition-endpoint=https://ignition.example.com", + )) + }, + }, + { + name: "When platform is AWS with control plane operator image, it should include CPO image arg", + platformType: hyperv1.AWSPlatform, + awsRegion: "eu-central-1", + hyperShiftOperatorImage: "quay.io/hypershift/operator:v1.0", + controlPlaneOperatorImage: "quay.io/hypershift/cpo:v1.0", + ignitionEndpoint: "https://ignition.example.com", + validateFunc: func(t *testing.T, g Gomega, opts *KarpenterOperatorOptions, cpContext controlplanecomponent.WorkloadContext) { + t.Helper() + deploymentObj, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + err = opts.adaptDeployment(cpContext, deploymentObj) + g.Expect(err).ToNot(HaveOccurred()) + + container := util.FindContainer(ComponentName, deploymentObj.Spec.Template.Spec.Containers) + g.Expect(container).ToNot(BeNil(), "container %s should exist", ComponentName) + g.Expect(container.Args).To(ContainElement("--control-plane-operator-image=quay.io/hypershift/cpo:v1.0")) + }, + }, + { + name: "When RHOBS monitoring is enabled on AWS, it should set environment variable", + platformType: hyperv1.AWSPlatform, + awsRegion: "us-east-1", + hyperShiftOperatorImage: "quay.io/hypershift/operator:latest", + ignitionEndpoint: "https://ignition.example.com", + rhobsEnabled: true, + validateFunc: func(t *testing.T, g Gomega, opts *KarpenterOperatorOptions, cpContext controlplanecomponent.WorkloadContext) { + t.Helper() + deploymentObj, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + err = opts.adaptDeployment(cpContext, deploymentObj) + g.Expect(err).ToNot(HaveOccurred()) + + container := util.FindContainer(ComponentName, deploymentObj.Spec.Template.Spec.Containers) + g.Expect(container).ToNot(BeNil(), "container %s should exist", ComponentName) + g.Expect(container.Env).To(ContainElement( + corev1.EnvVar{ + Name: rhobsmonitoring.EnvironmentVariable, + Value: "1", + }, + )) + }, + }, + { + name: "When RHOBS monitoring is disabled on AWS, it should not set environment variable", + platformType: hyperv1.AWSPlatform, + awsRegion: "us-east-1", + hyperShiftOperatorImage: "quay.io/hypershift/operator:latest", + ignitionEndpoint: "https://ignition.example.com", + rhobsEnabled: false, + validateFunc: func(t *testing.T, g Gomega, opts *KarpenterOperatorOptions, cpContext controlplanecomponent.WorkloadContext) { + t.Helper() + deploymentObj, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + err = opts.adaptDeployment(cpContext, deploymentObj) + g.Expect(err).ToNot(HaveOccurred()) + + container := util.FindContainer(ComponentName, deploymentObj.Spec.Template.Spec.Containers) + g.Expect(container).ToNot(BeNil(), "container %s should exist", ComponentName) + g.Expect(util.FindEnvVar(rhobsmonitoring.EnvironmentVariable, container.Env)).To(BeNil()) + }, + }, + { + name: "When platform is not AWS, it should only set basic configuration", + platformType: hyperv1.AzurePlatform, + hyperShiftOperatorImage: "quay.io/hypershift/operator:latest", + ignitionEndpoint: "https://ignition.example.com", + validateFunc: func(t *testing.T, g Gomega, opts *KarpenterOperatorOptions, cpContext controlplanecomponent.WorkloadContext) { + t.Helper() + deploymentObj, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + err = opts.adaptDeployment(cpContext, deploymentObj) + g.Expect(err).ToNot(HaveOccurred()) + + // Verify NO provider-creds volume is added for non-AWS + g.Expect(util.FindVolume("provider-creds", deploymentObj.Spec.Template.Spec.Volumes)).To(BeNil()) + + container := util.FindContainer(ComponentName, deploymentObj.Spec.Template.Spec.Containers) + g.Expect(container).ToNot(BeNil(), "container %s should exist", ComponentName) + g.Expect(container.Image).To(Equal("quay.io/hypershift/operator:latest")) + + // Verify AWS-specific env vars are NOT present + g.Expect(util.FindEnvVar("AWS_SHARED_CREDENTIALS_FILE", container.Env)).To(BeNil()) + g.Expect(util.FindEnvVar("AWS_REGION", container.Env)).To(BeNil()) + g.Expect(util.FindEnvVar("AWS_SDK_LOAD_CONFIG", container.Env)).To(BeNil()) + + // Verify basic args are set + g.Expect(container.Args).To(ContainElements( + "--hypershift-operator-image=quay.io/hypershift/operator:latest", + "--ignition-endpoint=https://ignition.example.com", + )) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + + if tc.rhobsEnabled { + t.Setenv(rhobsmonitoring.EnvironmentVariable, "1") + } else { + t.Setenv(rhobsmonitoring.EnvironmentVariable, "") + } + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Platform: hyperv1.PlatformSpec{ + Type: tc.platformType, + }, + }, + } + + if tc.platformType == hyperv1.AWSPlatform { + hcp.Spec.Platform.AWS = &hyperv1.AWSPlatformSpec{ + Region: tc.awsRegion, + } + } + + opts := &KarpenterOperatorOptions{ + HyperShiftOperatorImage: tc.hyperShiftOperatorImage, + ControlPlaneOperatorImage: tc.controlPlaneOperatorImage, + IgnitionEndpoint: tc.ignitionEndpoint, + } + + cpContext := controlplanecomponent.WorkloadContext{ + Context: t.Context(), + HCP: hcp, + } + + tc.validateFunc(t, g, opts, cpContext) + }) + } +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/karpenteroperator/podmonitor_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/karpenteroperator/podmonitor_test.go new file mode 100644 index 00000000000..d5082df9eb1 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/karpenteroperator/podmonitor_test.go @@ -0,0 +1,107 @@ +package karpenteroperator + +import ( + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + controlplanecomponent "github.com/openshift/hypershift/support/controlplane-component" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + prometheusoperatorv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" +) + +func TestAdaptPodMonitor(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + namespace string + clusterID string + expectedNamespace string + expectedClusterID string + numEndpoints int + validateEndpoint func(t *testing.T, g Gomega, endpoint *prometheusoperatorv1.PodMetricsEndpoint) + }{ + { + name: "When podmonitor is adapted, it should set namespace selector and cluster ID label", + namespace: "test-namespace", + clusterID: "cluster-12345", + expectedNamespace: "test-namespace", + expectedClusterID: "cluster-12345", + numEndpoints: 1, + validateEndpoint: func(t *testing.T, g Gomega, endpoint *prometheusoperatorv1.PodMetricsEndpoint) { + t.Helper() + g.Expect(endpoint.RelabelConfigs).ToNot(BeNil()) + hasClusterIDLabel := false + for _, relabelConfig := range endpoint.RelabelConfigs { + if relabelConfig.TargetLabel == "_id" { + hasClusterIDLabel = true + g.Expect(relabelConfig.Replacement).ToNot(BeNil()) + g.Expect(*relabelConfig.Replacement).To(Equal("cluster-12345")) + break + } + } + g.Expect(hasClusterIDLabel).To(BeTrue(), "Expected cluster ID label to be set") + }, + }, + { + name: "When namespace is different, it should update namespace selector correctly", + namespace: "another-namespace", + clusterID: "test-cluster", + expectedNamespace: "another-namespace", + expectedClusterID: "test-cluster", + numEndpoints: 1, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: tc.namespace, + }, + Spec: hyperv1.HostedControlPlaneSpec{ + ClusterID: tc.clusterID, + }, + } + + cpContext := controlplanecomponent.WorkloadContext{ + Context: t.Context(), + HCP: hcp, + } + + podMonitor := &prometheusoperatorv1.PodMonitor{ + ObjectMeta: metav1.ObjectMeta{ + Name: "karpenter-operator", + Namespace: "default", + }, + Spec: prometheusoperatorv1.PodMonitorSpec{ + PodMetricsEndpoints: []prometheusoperatorv1.PodMetricsEndpoint{ + {}, + }, + }, + } + + err := adaptPodMonitor(cpContext, podMonitor) + g.Expect(err).ToNot(HaveOccurred()) + + // Verify namespace selector + g.Expect(podMonitor.Spec.NamespaceSelector.MatchNames).To(HaveLen(1)) + g.Expect(podMonitor.Spec.NamespaceSelector.MatchNames[0]).To(Equal(tc.expectedNamespace)) + + // Verify endpoints + g.Expect(podMonitor.Spec.PodMetricsEndpoints).To(HaveLen(tc.numEndpoints)) + + if tc.validateEndpoint != nil { + tc.validateEndpoint(t, g, &podMonitor.Spec.PodMetricsEndpoints[0]) + } + }) + } +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/karpenteroperator/secret_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/karpenteroperator/secret_test.go new file mode 100644 index 00000000000..d178a9743f9 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/karpenteroperator/secret_test.go @@ -0,0 +1,161 @@ +package karpenteroperator + +import ( + "fmt" + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + controlplanecomponent "github.com/openshift/hypershift/support/controlplane-component" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestAdaptCredentialsSecret(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + roleARN string + validateCredentials func(t *testing.T, g Gomega, credentials string) + }{ + { + name: "When AWS role ARN is provided, it should generate correct credentials format", + roleARN: "arn:aws:iam::123456789012:role/karpenter-role", + validateCredentials: func(t *testing.T, g Gomega, credentials string) { + t.Helper() + g.Expect(credentials).To(ContainSubstring("[default]")) + g.Expect(credentials).To(ContainSubstring("role_arn = arn:aws:iam::123456789012:role/karpenter-role")) + g.Expect(credentials).To(ContainSubstring("web_identity_token_file = /var/run/secrets/openshift/serviceaccount/token")) + g.Expect(credentials).To(ContainSubstring("sts_regional_endpoints = regional")) + }, + }, + { + name: "When different role ARN format is provided, it should be included correctly", + roleARN: "arn:aws:iam::999999999999:role/my-custom-karpenter-role", + validateCredentials: func(t *testing.T, g Gomega, credentials string) { + t.Helper() + g.Expect(credentials).To(ContainSubstring("role_arn = arn:aws:iam::999999999999:role/my-custom-karpenter-role")) + }, + }, + { + name: "When role ARN has path component, it should be preserved", + roleARN: "arn:aws:iam::111111111111:role/path/to/role/karpenter", + validateCredentials: func(t *testing.T, g Gomega, credentials string) { + t.Helper() + g.Expect(credentials).To(ContainSubstring("role_arn = arn:aws:iam::111111111111:role/path/to/role/karpenter")) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + AutoNode: &hyperv1.AutoNode{ + Provisioner: hyperv1.ProvisionerConfig{ + Karpenter: &hyperv1.KarpenterConfig{ + Platform: hyperv1.AWSPlatform, + AWS: &hyperv1.KarpenterAWSConfig{ + RoleARN: tc.roleARN, + }, + }, + }, + }, + }, + } + + cpContext := controlplanecomponent.WorkloadContext{ + Context: t.Context(), + HCP: hcp, + } + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "karpenter-credentials", + Namespace: "test-namespace", + }, + } + + err := adaptCredentialsSecret(cpContext, secret) + g.Expect(err).ToNot(HaveOccurred()) + + // Verify secret type + g.Expect(secret.Type).To(Equal(corev1.SecretTypeOpaque)) + + // Verify credentials data exists + g.Expect(secret.Data).To(HaveKey("credentials")) + credentials := string(secret.Data["credentials"]) + + // Verify credentials content + if tc.validateCredentials != nil { + tc.validateCredentials(t, g, credentials) + } + + // Verify role ARN is in credentials + g.Expect(credentials).To(ContainSubstring(tc.roleARN)) + }) + } +} + +func TestAdaptCredentialsSecretFormat(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + roleARN := "arn:aws:iam::123456789012:role/test-role" + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + AutoNode: &hyperv1.AutoNode{ + Provisioner: hyperv1.ProvisionerConfig{ + Karpenter: &hyperv1.KarpenterConfig{ + Platform: hyperv1.AWSPlatform, + AWS: &hyperv1.KarpenterAWSConfig{ + RoleARN: roleARN, + }, + }, + }, + }, + }, + } + + cpContext := controlplanecomponent.WorkloadContext{ + Context: t.Context(), + HCP: hcp, + } + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "karpenter-credentials", + Namespace: "test-namespace", + }, + } + + err := adaptCredentialsSecret(cpContext, secret) + g.Expect(err).ToNot(HaveOccurred()) + + credentials := string(secret.Data["credentials"]) + + // Verify the exact format matches AWS credentials file format + expectedTemplate := `[default] + role_arn = %s + web_identity_token_file = /var/run/secrets/openshift/serviceaccount/token + sts_regional_endpoints = regional +` + expected := fmt.Sprintf(expectedTemplate, roleARN) + + g.Expect(credentials).To(Equal(expected)) +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/machine_approver/deployment_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/machine_approver/deployment_test.go index 8167042af1a..0bcf5d154a2 100644 --- a/control-plane-operator/controllers/hostedcontrolplane/v2/machine_approver/deployment_test.go +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/machine_approver/deployment_test.go @@ -151,6 +151,11 @@ func TestAdaptDeployment(t *testing.T) { }, } + containers := make([]corev1.Container, len(tc.initialContainers)) + for i := range tc.initialContainers { + containers[i] = *tc.initialContainers[i].DeepCopy() + } + deployment := &appsv1.Deployment{ ObjectMeta: metav1.ObjectMeta{ Name: "test-deployment", @@ -159,7 +164,7 @@ func TestAdaptDeployment(t *testing.T) { Spec: appsv1.DeploymentSpec{ Template: corev1.PodTemplateSpec{ Spec: corev1.PodSpec{ - Containers: tc.initialContainers, + Containers: containers, }, }, }, diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/nto/component_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/nto/component_test.go new file mode 100644 index 00000000000..fcf1f615737 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/nto/component_test.go @@ -0,0 +1,139 @@ +package nto + +import ( + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + component "github.com/openshift/hypershift/support/controlplane-component" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestIsNodeTuningCapabilityEnabled(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + capabilities *hyperv1.Capabilities + expected bool + }{ + { + name: "When capabilities is nil, it should return true", + capabilities: nil, + expected: true, + }, + { + name: "When NodeTuning capability is not disabled, it should return true", + capabilities: &hyperv1.Capabilities{ + Disabled: []hyperv1.OptionalCapability{ + hyperv1.IngressCapability, + }, + }, + expected: true, + }, + { + name: "When NodeTuning capability is disabled, it should return false", + capabilities: &hyperv1.Capabilities{ + Disabled: []hyperv1.OptionalCapability{ + hyperv1.NodeTuningCapability, + }, + }, + expected: false, + }, + { + name: "When NodeTuning capability is disabled with other capabilities, it should return false", + capabilities: &hyperv1.Capabilities{ + Disabled: []hyperv1.OptionalCapability{ + hyperv1.IngressCapability, + hyperv1.NodeTuningCapability, + hyperv1.ImageRegistryCapability, + }, + }, + expected: false, + }, + { + name: "When capabilities has empty disabled list, it should return true", + capabilities: &hyperv1.Capabilities{ + Disabled: []hyperv1.OptionalCapability{}, + }, + expected: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Capabilities: tc.capabilities, + }, + } + + cpContext := component.WorkloadContext{ + Context: t.Context(), + HCP: hcp, + } + + result, err := isNodeTuningCapabilityEnabled(cpContext) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(result).To(Equal(tc.expected)) + }) + } +} + +func TestClusterNodeTuningOperatorOptions(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + methodName string + expectedIsRequestServing bool + expectedMultiZoneSpread bool + expectedNeedsManagementKAS bool + }{ + { + name: "When IsRequestServing is called, it should return false", + methodName: "IsRequestServing", + expectedIsRequestServing: false, + expectedMultiZoneSpread: false, + expectedNeedsManagementKAS: true, + }, + { + name: "When MultiZoneSpread is called, it should return false", + methodName: "MultiZoneSpread", + expectedIsRequestServing: false, + expectedMultiZoneSpread: false, + expectedNeedsManagementKAS: true, + }, + { + name: "When NeedsManagementKASAccess is called, it should return true", + methodName: "NeedsManagementKASAccess", + expectedIsRequestServing: false, + expectedMultiZoneSpread: false, + expectedNeedsManagementKAS: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + g := NewWithT(t) + + operator := &clusterNodeTuningOperator{} + + g.Expect(operator.IsRequestServing()).To(Equal(tc.expectedIsRequestServing)) + g.Expect(operator.MultiZoneSpread()).To(Equal(tc.expectedMultiZoneSpread)) + g.Expect(operator.NeedsManagementKASAccess()).To(Equal(tc.expectedNeedsManagementKAS)) + }) + } +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/nto/deployment_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/nto/deployment_test.go new file mode 100644 index 00000000000..8589ca53dc0 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/nto/deployment_test.go @@ -0,0 +1,139 @@ +package nto + +import ( + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + "github.com/openshift/hypershift/control-plane-operator/controllers/hostedcontrolplane/v2/assets" + component "github.com/openshift/hypershift/support/controlplane-component" + "github.com/openshift/hypershift/support/testutil" + "github.com/openshift/hypershift/support/util" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestAdaptDeployment(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + releaseVersion string + expectedReleaseVersionEnv string + expectedClusterNodeTunedEnv string + }{ + { + name: "When deployment is adapted with release info, it should set RELEASE_VERSION and CLUSTER_NODE_TUNED_IMAGE env vars", + releaseVersion: "4.15.0", + expectedReleaseVersionEnv: "4.15.0", + expectedClusterNodeTunedEnv: "test-registry/cluster-node-tuning-operator:4.15.0", + }, + { + name: "When deployment is adapted with different release version, it should update env vars accordingly", + releaseVersion: "4.16.1", + expectedReleaseVersionEnv: "4.16.1", + expectedClusterNodeTunedEnv: "test-registry/cluster-node-tuning-operator:4.16.1", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + } + + releaseProvider := testutil.FakeImageProvider( + testutil.WithVersion(tc.releaseVersion), + testutil.WithImages(map[string]string{ + ComponentName: "test-registry/" + ComponentName + ":" + tc.releaseVersion, + }), + ) + + cpContext := component.WorkloadContext{ + Context: t.Context(), + HCP: hcp, + UserReleaseImageProvider: releaseProvider, + } + + deployment, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + err = adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + // Verify container has the expected environment variables + g.Expect(deployment.Spec.Template.Spec.Containers).ToNot(BeEmpty()) + container := deployment.Spec.Template.Spec.Containers[0] + + releaseVersionEnv := util.FindEnvVar("RELEASE_VERSION", container.Env) + g.Expect(releaseVersionEnv).ToNot(BeNil()) + g.Expect(releaseVersionEnv.Value).To(Equal(tc.expectedReleaseVersionEnv)) + + clusterNodeTunedImageEnv := util.FindEnvVar("CLUSTER_NODE_TUNED_IMAGE", container.Env) + g.Expect(clusterNodeTunedImageEnv).ToNot(BeNil()) + g.Expect(clusterNodeTunedImageEnv.Value).To(Equal(tc.expectedClusterNodeTunedEnv)) + }) + } +} + +func TestAdaptDeploymentUpdatesExistingEnvVars(t *testing.T) { + t.Parallel() + + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + } + + releaseProvider := testutil.FakeImageProvider( + testutil.WithVersion("4.15.0"), + testutil.WithImages(map[string]string{ + ComponentName: "new-registry/cluster-node-tuning-operator:4.15.0", + }), + ) + + cpContext := component.WorkloadContext{ + Context: t.Context(), + HCP: hcp, + UserReleaseImageProvider: releaseProvider, + } + + deployment, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + // The deployment manifest already has these env vars with empty values, + // so adaptDeployment should update them + err = adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + g.Expect(deployment.Spec.Template.Spec.Containers).ToNot(BeEmpty(), "deployment must have at least one container") + container := deployment.Spec.Template.Spec.Containers[0] + + // Verify values were updated, not duplicated + releaseVersionCount := 0 + clusterNodeTunedImageCount := 0 + for _, env := range container.Env { + if env.Name == "RELEASE_VERSION" { + releaseVersionCount++ + g.Expect(env.Value).To(Equal("4.15.0")) + } + if env.Name == "CLUSTER_NODE_TUNED_IMAGE" { + clusterNodeTunedImageCount++ + g.Expect(env.Value).To(Equal("new-registry/cluster-node-tuning-operator:4.15.0")) + } + } + + g.Expect(releaseVersionCount).To(Equal(1), "RELEASE_VERSION should appear exactly once") + g.Expect(clusterNodeTunedImageCount).To(Equal(1), "CLUSTER_NODE_TUNED_IMAGE should appear exactly once") +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/nto/servicemonitor_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/nto/servicemonitor_test.go new file mode 100644 index 00000000000..6a296062063 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/nto/servicemonitor_test.go @@ -0,0 +1,283 @@ +package nto + +import ( + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + "github.com/openshift/hypershift/control-plane-operator/controllers/hostedcontrolplane/v2/assets" + component "github.com/openshift/hypershift/support/controlplane-component" + "github.com/openshift/hypershift/support/metrics" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + + prometheusoperatorv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" +) + +func TestAdaptServiceMonitor(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + namespace string + clusterID string + metricsSet metrics.MetricsSet + expectedServerName string + }{ + { + name: "When service monitor is adapted with default namespace, it should set namespace selector and server name", + namespace: "test-namespace", + clusterID: "test-cluster-id", + metricsSet: metrics.MetricsSetTelemetry, + expectedServerName: "node-tuning-operator.test-namespace.svc", + }, + { + name: "When service monitor is adapted with different namespace, it should update server name accordingly", + namespace: "clusters-production", + clusterID: "prod-cluster", + metricsSet: metrics.MetricsSetSRE, + expectedServerName: "node-tuning-operator.clusters-production.svc", + }, + { + name: "When service monitor is adapted with all metrics set, it should configure correctly", + namespace: "clusters-dev", + clusterID: "dev-cluster", + metricsSet: metrics.MetricsSetAll, + expectedServerName: "node-tuning-operator.clusters-dev.svc", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: tc.namespace, + }, + Spec: hyperv1.HostedControlPlaneSpec{ + ClusterID: tc.clusterID, + }, + } + + cpContext := component.WorkloadContext{ + Context: t.Context(), + HCP: hcp, + MetricsSet: tc.metricsSet, + } + + sm, _, err := assets.LoadManifest(ComponentName, "servicemonitor.yaml") + g.Expect(err).ToNot(HaveOccurred()) + + serviceMonitor, ok := sm.(*prometheusoperatorv1.ServiceMonitor) + g.Expect(ok).To(BeTrue()) + serviceMonitor.Namespace = tc.namespace + + err = adaptServiceMonitor(cpContext, serviceMonitor) + g.Expect(err).ToNot(HaveOccurred()) + + // Verify namespace selector + g.Expect(serviceMonitor.Spec.NamespaceSelector.MatchNames).To(Equal([]string{tc.namespace})) + + // Verify TLS server name + g.Expect(serviceMonitor.Spec.Endpoints).ToNot(BeEmpty()) + g.Expect(serviceMonitor.Spec.Endpoints[0].TLSConfig).ToNot(BeNil()) + g.Expect(serviceMonitor.Spec.Endpoints[0].TLSConfig.ServerName).To(Equal(ptr.To(tc.expectedServerName))) + + // Verify metric relabel configs are set based on metrics set + // ApplyClusterIDLabel appends cluster ID to MetricRelabelConfigs, so we need to account for that + actualMetricRelabelConfigs := serviceMonitor.Spec.Endpoints[0].MetricRelabelConfigs + expectedBaseConfigs := metrics.NTORelabelConfigs(tc.metricsSet) + + // The last entry should be the cluster ID label + g.Expect(actualMetricRelabelConfigs).ToNot(BeEmpty()) + lastConfig := actualMetricRelabelConfigs[len(actualMetricRelabelConfigs)-1] + g.Expect(lastConfig.TargetLabel).To(Equal("_id")) + g.Expect(*lastConfig.Replacement).To(Equal(tc.clusterID)) + + // Check the configs before the cluster ID match the expected + configsBeforeClusterID := actualMetricRelabelConfigs[:len(actualMetricRelabelConfigs)-1] + if expectedBaseConfigs == nil { + g.Expect(configsBeforeClusterID).To(BeEmpty()) + } else { + g.Expect(configsBeforeClusterID).To(Equal(expectedBaseConfigs)) + } + + // Verify cluster ID label is also applied to RelabelConfigs + foundClusterIDInRelabelConfigs := false + for _, relabel := range serviceMonitor.Spec.Endpoints[0].RelabelConfigs { + if relabel.TargetLabel == "_id" { + foundClusterIDInRelabelConfigs = true + g.Expect(*relabel.Replacement).To(Equal(tc.clusterID)) + } + } + g.Expect(foundClusterIDInRelabelConfigs).To(BeTrue(), "cluster ID label should be applied to RelabelConfigs") + }) + } +} + +func TestAdaptServiceMonitorErrorCases(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + modifySM func(*prometheusoperatorv1.ServiceMonitor) + expectedErr string + }{ + { + name: "When service monitor has no endpoints, it should return error", + modifySM: func(sm *prometheusoperatorv1.ServiceMonitor) { + sm.Spec.Endpoints = []prometheusoperatorv1.Endpoint{} + }, + expectedErr: "has no endpoints defined", + }, + { + name: "When service monitor endpoint has no TLS config, it should return error", + modifySM: func(sm *prometheusoperatorv1.ServiceMonitor) { + sm.Spec.Endpoints[0].TLSConfig = nil + }, + expectedErr: "has no TLSConfig", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + ClusterID: "test-cluster", + }, + } + + cpContext := component.WorkloadContext{ + Context: t.Context(), + HCP: hcp, + MetricsSet: metrics.MetricsSetTelemetry, + } + + sm, _, err := assets.LoadManifest(ComponentName, "servicemonitor.yaml") + g.Expect(err).ToNot(HaveOccurred()) + + serviceMonitor, ok := sm.(*prometheusoperatorv1.ServiceMonitor) + g.Expect(ok).To(BeTrue()) + serviceMonitor.Namespace = "test-namespace" + + // Apply the modification + tc.modifySM(serviceMonitor) + + err = adaptServiceMonitor(cpContext, serviceMonitor) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tc.expectedErr)) + }) + } +} + +func TestAdaptServiceMonitorMetricsSetConfigurations(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + metricsSet metrics.MetricsSet + validateMetricRelabelings func(*GomegaWithT, []prometheusoperatorv1.RelabelConfig) + }{ + { + name: "When metrics set is Telemetry, it should filter to specific NTO metrics", + metricsSet: metrics.MetricsSetTelemetry, + validateMetricRelabelings: func(g *GomegaWithT, relabelConfigs []prometheusoperatorv1.RelabelConfig) { + // Should have 2 configs: the NTO filter + cluster ID label + g.Expect(relabelConfigs).To(HaveLen(2)) + g.Expect(relabelConfigs[0].Action).To(Equal("keep")) + g.Expect(relabelConfigs[0].Regex).To(Equal("nto_profile_calculated_total")) + g.Expect(relabelConfigs[1].TargetLabel).To(Equal("_id")) + }, + }, + { + name: "When metrics set is All, it should not filter metrics", + metricsSet: metrics.MetricsSetAll, + validateMetricRelabelings: func(g *GomegaWithT, relabelConfigs []prometheusoperatorv1.RelabelConfig) { + // Should only have cluster ID label, no filtering + g.Expect(relabelConfigs).To(HaveLen(1)) + g.Expect(relabelConfigs[0].TargetLabel).To(Equal("_id")) + }, + }, + { + name: "When metrics set is SRE, it should use SRE configuration", + metricsSet: metrics.MetricsSetSRE, + validateMetricRelabelings: func(g *GomegaWithT, relabelConfigs []prometheusoperatorv1.RelabelConfig) { + // SRE metrics set configuration is loaded dynamically, + // Last entry should be cluster ID + g.Expect(relabelConfigs).ToNot(BeEmpty()) + lastIdx := len(relabelConfigs) - 1 + g.Expect(relabelConfigs[lastIdx].TargetLabel).To(Equal("_id")) + + // Configs before cluster ID should match SRE config + expectedConfigs := metrics.NTORelabelConfigs(metrics.MetricsSetSRE) + configsBeforeClusterID := relabelConfigs[:lastIdx] + if expectedConfigs == nil { + g.Expect(configsBeforeClusterID).To(BeEmpty()) + } else { + g.Expect(configsBeforeClusterID).To(Equal(expectedConfigs)) + } + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + ClusterID: "test-cluster", + }, + } + + cpContext := component.WorkloadContext{ + Context: t.Context(), + HCP: hcp, + MetricsSet: tc.metricsSet, + } + + sm, _, err := assets.LoadManifest(ComponentName, "servicemonitor.yaml") + g.Expect(err).ToNot(HaveOccurred()) + + serviceMonitor, ok := sm.(*prometheusoperatorv1.ServiceMonitor) + g.Expect(ok).To(BeTrue()) + serviceMonitor.Namespace = "test-namespace" + + err = adaptServiceMonitor(cpContext, serviceMonitor) + g.Expect(err).ToNot(HaveOccurred()) + + // Validate metric relabel configs + tc.validateMetricRelabelings(g, serviceMonitor.Spec.Endpoints[0].MetricRelabelConfigs) + + // All configurations should have cluster ID relabel config + foundClusterIDLabel := false + for _, relabel := range serviceMonitor.Spec.Endpoints[0].RelabelConfigs { + if relabel.TargetLabel == "_id" { + foundClusterIDLabel = true + g.Expect(*relabel.Replacement).To(Equal("test-cluster")) + } + } + g.Expect(foundClusterIDLabel).To(BeTrue(), "cluster ID label should be applied for all metrics sets") + }) + } +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/oauth_apiserver/component_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/oauth_apiserver/component_test.go new file mode 100644 index 00000000000..276036d25ee --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/oauth_apiserver/component_test.go @@ -0,0 +1,137 @@ +package oapi + +import ( + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + component "github.com/openshift/hypershift/support/controlplane-component" + + configv1 "github.com/openshift/api/config/v1" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestOpenshiftOAuthAPIServerOptions(t *testing.T) { + t.Parallel() + + options := &openshiftOAuthAPIServer{} + + t.Run("When checking IsRequestServing, it should return false", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + g.Expect(options.IsRequestServing()).To(BeFalse()) + }) + + t.Run("When checking MultiZoneSpread, it should return true", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + g.Expect(options.MultiZoneSpread()).To(BeTrue()) + }) + + t.Run("When checking NeedsManagementKASAccess, it should return false", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + g.Expect(options.NeedsManagementKASAccess()).To(BeFalse()) + }) +} + +func TestPredicate(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + hcp *hyperv1.HostedControlPlane + expectedValue bool + }{ + { + name: "When OAuth is enabled by default, it should return true", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-ns", + }, + }, + expectedValue: true, + }, + { + name: "When OAuth is explicitly enabled with nil configuration, it should return true", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-ns", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Configuration: nil, + }, + }, + expectedValue: true, + }, + { + name: "When OAuth is enabled with empty authentication config, it should return true", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-ns", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Configuration: &hyperv1.ClusterConfiguration{}, + }, + }, + expectedValue: true, + }, + { + name: "When authentication type is OIDC, it should return false", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-ns", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Configuration: &hyperv1.ClusterConfiguration{ + Authentication: &configv1.AuthenticationSpec{ + Type: configv1.AuthenticationTypeOIDC, + }, + }, + }, + }, + expectedValue: false, + }, + { + name: "When authentication type is IntegratedOAuth, it should return true", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-ns", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Configuration: &hyperv1.ClusterConfiguration{ + Authentication: &configv1.AuthenticationSpec{ + Type: configv1.AuthenticationTypeIntegratedOAuth, + }, + }, + }, + }, + expectedValue: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + cpContext := component.WorkloadContext{ + HCP: tc.hcp, + } + + result, err := predicate(cpContext) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(result).To(Equal(tc.expectedValue)) + }) + } +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/oauth_apiserver/deployment_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/oauth_apiserver/deployment_test.go new file mode 100644 index 00000000000..7a3279b8038 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/oauth_apiserver/deployment_test.go @@ -0,0 +1,673 @@ +package oapi + +import ( + "testing" + "time" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + "github.com/openshift/hypershift/control-plane-operator/controllers/hostedcontrolplane/v2/assets" + "github.com/openshift/hypershift/support/api" + component "github.com/openshift/hypershift/support/controlplane-component" + "github.com/openshift/hypershift/support/util" + + configv1 "github.com/openshift/api/config/v1" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestAdaptDeployment(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + hcp *hyperv1.HostedControlPlane + validate func(*testing.T, *GomegaWithT, *hyperv1.HostedControlPlane) + }{ + { + name: "When etcd is managed, it should configure default etcd URL", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-ns", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Platform: hyperv1.PlatformSpec{ + Type: hyperv1.AWSPlatform, + }, + IssuerURL: "https://test-issuer.example.com", + Etcd: hyperv1.EtcdSpec{ + ManagementType: hyperv1.Managed, + }, + }, + }, + validate: func(t *testing.T, g *GomegaWithT, hcp *hyperv1.HostedControlPlane) { + + deployment, loadErr := assets.LoadDeploymentManifest(ComponentName) + g.Expect(loadErr).ToNot(HaveOccurred()) + + cpContext := component.WorkloadContext{ + Client: fake.NewClientBuilder().WithScheme(api.Scheme).Build(), + HCP: hcp, + } + + err := adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + container := util.FindContainer(ComponentName, deployment.Spec.Template.Spec.Containers) + g.Expect(container).ToNot(BeNil()) + g.Expect(container.Args).To(ContainElement("--etcd-servers=https://etcd-client:2379")) + g.Expect(container.Args).To(ContainElement("--api-audiences=https://test-issuer.example.com")) + }, + }, + { + name: "When etcd is unmanaged, it should configure custom etcd endpoint", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-ns", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Platform: hyperv1.PlatformSpec{ + Type: hyperv1.AWSPlatform, + }, + IssuerURL: "https://test-issuer.example.com", + Etcd: hyperv1.EtcdSpec{ + ManagementType: hyperv1.Unmanaged, + Unmanaged: &hyperv1.UnmanagedEtcdSpec{ + Endpoint: "https://custom-etcd.example.com:2379", + }, + }, + }, + }, + validate: func(t *testing.T, g *GomegaWithT, hcp *hyperv1.HostedControlPlane) { + + deployment, loadErr := assets.LoadDeploymentManifest(ComponentName) + g.Expect(loadErr).ToNot(HaveOccurred()) + + cpContext := component.WorkloadContext{ + Client: fake.NewClientBuilder().WithScheme(api.Scheme).Build(), + HCP: hcp, + } + + err := adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + container := util.FindContainer(ComponentName, deployment.Spec.Template.Spec.Containers) + g.Expect(container).ToNot(BeNil()) + g.Expect(container.Args).To(ContainElement("--etcd-servers=https://custom-etcd.example.com:2379")) + + // Check NO_PROXY includes the custom etcd hostname + noProxyEnv := util.FindEnvVar("NO_PROXY", container.Env) + g.Expect(noProxyEnv).ToNot(BeNil()) + g.Expect(noProxyEnv.Value).To(ContainSubstring("custom-etcd.example.com")) + }, + }, + { + name: "When TLS security profile is set, it should configure tls-min-version", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-ns", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Platform: hyperv1.PlatformSpec{ + Type: hyperv1.AWSPlatform, + }, + IssuerURL: "https://test-issuer.example.com", + Etcd: hyperv1.EtcdSpec{ + ManagementType: hyperv1.Managed, + }, + Configuration: &hyperv1.ClusterConfiguration{ + APIServer: &configv1.APIServerSpec{ + TLSSecurityProfile: &configv1.TLSSecurityProfile{ + Type: configv1.TLSProfileModernType, + Modern: &configv1.ModernTLSProfile{}, + }, + }, + }, + }, + }, + validate: func(t *testing.T, g *GomegaWithT, hcp *hyperv1.HostedControlPlane) { + + deployment, loadErr := assets.LoadDeploymentManifest(ComponentName) + g.Expect(loadErr).ToNot(HaveOccurred()) + + cpContext := component.WorkloadContext{ + Client: fake.NewClientBuilder().WithScheme(api.Scheme).Build(), + HCP: hcp, + } + + err := adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + container := util.FindContainer(ComponentName, deployment.Spec.Template.Spec.Containers) + g.Expect(container).ToNot(BeNil()) + g.Expect(container.Args).To(ContainElement("--tls-min-version=VersionTLS13")) + }, + }, + { + name: "When audit webhook is configured, it should add audit webhook arguments and volume", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-ns", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Platform: hyperv1.PlatformSpec{ + Type: hyperv1.AWSPlatform, + }, + IssuerURL: "https://test-issuer.example.com", + Etcd: hyperv1.EtcdSpec{ + ManagementType: hyperv1.Managed, + }, + AuditWebhook: &corev1.LocalObjectReference{ + Name: "audit-webhook-secret", + }, + }, + }, + validate: func(t *testing.T, g *GomegaWithT, hcp *hyperv1.HostedControlPlane) { + + deployment, loadErr := assets.LoadDeploymentManifest(ComponentName) + g.Expect(loadErr).ToNot(HaveOccurred()) + + cpContext := component.WorkloadContext{ + Client: fake.NewClientBuilder().WithScheme(api.Scheme).Build(), + HCP: hcp, + } + + err := adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + container := util.FindContainer(ComponentName, deployment.Spec.Template.Spec.Containers) + g.Expect(container).ToNot(BeNil()) + g.Expect(container.Args).To(ContainElement("--audit-webhook-config-file=/etc/kubernetes/auditwebhook/webhook-kubeconfig")) + g.Expect(container.Args).To(ContainElement("--audit-webhook-mode=batch")) + g.Expect(container.Args).To(ContainElement("--audit-webhook-initial-backoff=5s")) + + // Check volume mount + volumeMount := util.FindVolumeMount(auditWebhookConfigFileVolumeName, container.VolumeMounts) + g.Expect(volumeMount).ToNot(BeNil()) + g.Expect(volumeMount.MountPath).To(Equal("/etc/kubernetes/auditwebhook")) + + // Check volume + volume := util.FindVolume(auditWebhookConfigFileVolumeName, deployment.Spec.Template.Spec.Volumes) + g.Expect(volume).ToNot(BeNil()) + g.Expect(volume.VolumeSource.Secret).ToNot(BeNil()) + g.Expect(volume.VolumeSource.Secret.SecretName).To(Equal("audit-webhook-secret")) + }, + }, + { + name: "When audit webhook is not configured, it should not add audit webhook arguments", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-ns", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Platform: hyperv1.PlatformSpec{ + Type: hyperv1.AWSPlatform, + }, + IssuerURL: "https://test-issuer.example.com", + Etcd: hyperv1.EtcdSpec{ + ManagementType: hyperv1.Managed, + }, + }, + }, + validate: func(t *testing.T, g *GomegaWithT, hcp *hyperv1.HostedControlPlane) { + + deployment, loadErr := assets.LoadDeploymentManifest(ComponentName) + g.Expect(loadErr).ToNot(HaveOccurred()) + + cpContext := component.WorkloadContext{ + Client: fake.NewClientBuilder().WithScheme(api.Scheme).Build(), + HCP: hcp, + } + + err := adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + container := util.FindContainer(ComponentName, deployment.Spec.Template.Spec.Containers) + g.Expect(container).ToNot(BeNil()) + g.Expect(container.Args).ToNot(ContainElement(ContainSubstring("--audit-webhook-config-file"))) + g.Expect(container.Args).ToNot(ContainElement("--audit-webhook-mode=batch")) + + // Check volume mount doesn't exist + volumeMount := util.FindVolumeMount(auditWebhookConfigFileVolumeName, container.VolumeMounts) + g.Expect(volumeMount).To(BeNil()) + + // Check volume doesn't exist + volume := util.FindVolume(auditWebhookConfigFileVolumeName, deployment.Spec.Template.Spec.Volumes) + g.Expect(volume).To(BeNil()) + }, + }, + { + name: "When access token inactivity timeout is configured, it should add timeout argument", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-ns", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Platform: hyperv1.PlatformSpec{ + Type: hyperv1.AWSPlatform, + }, + IssuerURL: "https://test-issuer.example.com", + Etcd: hyperv1.EtcdSpec{ + ManagementType: hyperv1.Managed, + }, + Configuration: &hyperv1.ClusterConfiguration{ + OAuth: &configv1.OAuthSpec{ + TokenConfig: configv1.TokenConfig{ + AccessTokenInactivityTimeout: &metav1.Duration{ + Duration: 10 * time.Minute, + }, + }, + }, + }, + }, + }, + validate: func(t *testing.T, g *GomegaWithT, hcp *hyperv1.HostedControlPlane) { + + deployment, loadErr := assets.LoadDeploymentManifest(ComponentName) + g.Expect(loadErr).ToNot(HaveOccurred()) + + cpContext := component.WorkloadContext{ + Client: fake.NewClientBuilder().WithScheme(api.Scheme).Build(), + HCP: hcp, + } + + err := adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + container := util.FindContainer(ComponentName, deployment.Spec.Template.Spec.Containers) + g.Expect(container).ToNot(BeNil()) + g.Expect(container.Args).To(ContainElement("--accesstoken-inactivity-timeout=10m0s")) + }, + }, + { + name: "When access token inactivity timeout is not configured, it should not add timeout argument", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-ns", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Platform: hyperv1.PlatformSpec{ + Type: hyperv1.AWSPlatform, + }, + IssuerURL: "https://test-issuer.example.com", + Etcd: hyperv1.EtcdSpec{ + ManagementType: hyperv1.Managed, + }, + Configuration: &hyperv1.ClusterConfiguration{ + OAuth: &configv1.OAuthSpec{ + TokenConfig: configv1.TokenConfig{}, + }, + }, + }, + }, + validate: func(t *testing.T, g *GomegaWithT, hcp *hyperv1.HostedControlPlane) { + + deployment, loadErr := assets.LoadDeploymentManifest(ComponentName) + g.Expect(loadErr).ToNot(HaveOccurred()) + + cpContext := component.WorkloadContext{ + Client: fake.NewClientBuilder().WithScheme(api.Scheme).Build(), + HCP: hcp, + } + + err := adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + container := util.FindContainer(ComponentName, deployment.Spec.Template.Spec.Containers) + g.Expect(container).ToNot(BeNil()) + g.Expect(container.Args).ToNot(ContainElement(ContainSubstring("--accesstoken-inactivity-timeout"))) + }, + }, + { + name: "When audit profile is None, it should remove audit-logs container", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-ns", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Platform: hyperv1.PlatformSpec{ + Type: hyperv1.AWSPlatform, + }, + IssuerURL: "https://test-issuer.example.com", + Etcd: hyperv1.EtcdSpec{ + ManagementType: hyperv1.Managed, + }, + Configuration: &hyperv1.ClusterConfiguration{ + APIServer: &configv1.APIServerSpec{ + Audit: configv1.Audit{ + Profile: configv1.NoneAuditProfileType, + }, + }, + }, + }, + }, + validate: func(t *testing.T, g *GomegaWithT, hcp *hyperv1.HostedControlPlane) { + + deployment, loadErr := assets.LoadDeploymentManifest(ComponentName) + g.Expect(loadErr).ToNot(HaveOccurred()) + + // Verify audit-logs container exists in base manifest before adaptation + preContainer := util.FindContainer("audit-logs", deployment.Spec.Template.Spec.Containers) + g.Expect(preContainer).ToNot(BeNil(), "audit-logs container should exist in base manifest") + + cpContext := component.WorkloadContext{ + Client: fake.NewClientBuilder().WithScheme(api.Scheme).Build(), + HCP: hcp, + } + + err := adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + container := util.FindContainer("audit-logs", deployment.Spec.Template.Spec.Containers) + g.Expect(container).To(BeNil(), "audit-logs container should be removed after adaptation") + }, + }, + { + name: "When NO_PROXY is set, it should include kube-apiserver and etcd", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-ns", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Platform: hyperv1.PlatformSpec{ + Type: hyperv1.AWSPlatform, + }, + IssuerURL: "https://test-issuer.example.com", + Etcd: hyperv1.EtcdSpec{ + ManagementType: hyperv1.Managed, + }, + }, + }, + validate: func(t *testing.T, g *GomegaWithT, hcp *hyperv1.HostedControlPlane) { + + deployment, loadErr := assets.LoadDeploymentManifest(ComponentName) + g.Expect(loadErr).ToNot(HaveOccurred()) + + cpContext := component.WorkloadContext{ + Client: fake.NewClientBuilder().WithScheme(api.Scheme).Build(), + HCP: hcp, + } + + err := adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + container := util.FindContainer(ComponentName, deployment.Spec.Template.Spec.Containers) + g.Expect(container).ToNot(BeNil()) + + noProxyEnv := util.FindEnvVar("NO_PROXY", container.Env) + g.Expect(noProxyEnv).ToNot(BeNil()) + g.Expect(noProxyEnv.Value).To(ContainSubstring("kube-apiserver")) + g.Expect(noProxyEnv.Value).To(ContainSubstring("etcd-client")) + }, + }, + { + name: "When deployment is adapted, it should add KAS readiness check container", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-ns", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Platform: hyperv1.PlatformSpec{ + Type: hyperv1.AWSPlatform, + }, + IssuerURL: "https://test-issuer.example.com", + Etcd: hyperv1.EtcdSpec{ + ManagementType: hyperv1.Managed, + }, + }, + }, + validate: func(t *testing.T, g *GomegaWithT, hcp *hyperv1.HostedControlPlane) { + + deployment, loadErr := assets.LoadDeploymentManifest(ComponentName) + g.Expect(loadErr).ToNot(HaveOccurred()) + + originalContainerCount := len(deployment.Spec.Template.Spec.Containers) + + cpContext := component.WorkloadContext{ + Client: fake.NewClientBuilder().WithScheme(api.Scheme).Build(), + HCP: hcp, + } + + err := adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + // KAS readiness check container should be added + g.Expect(deployment.Spec.Template.Spec.Containers).To(HaveLen(originalContainerCount + 1)) + kasReadinessContainer := util.FindContainer("kas-readiness-check", deployment.Spec.Template.Spec.Containers) + g.Expect(kasReadinessContainer).ToNot(BeNil(), "kas-readiness-check container should be present") + }, + }, + { + name: "When platform is Azure, it should use correct KAS URL", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-ns", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Platform: hyperv1.PlatformSpec{ + Type: hyperv1.AzurePlatform, + }, + IssuerURL: "https://test-issuer.example.com", + Etcd: hyperv1.EtcdSpec{ + ManagementType: hyperv1.Managed, + }, + }, + }, + validate: func(t *testing.T, g *GomegaWithT, hcp *hyperv1.HostedControlPlane) { + + deployment, loadErr := assets.LoadDeploymentManifest(ComponentName) + g.Expect(loadErr).ToNot(HaveOccurred()) + + cpContext := component.WorkloadContext{ + Client: fake.NewClientBuilder().WithScheme(api.Scheme).Build(), + HCP: hcp, + } + + err := adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + // Verify KAS readiness check container was added with /livez URL + kasReadinessContainer := util.FindContainer("kas-readiness-check", deployment.Spec.Template.Spec.Containers) + g.Expect(kasReadinessContainer).ToNot(BeNil(), "KAS readiness check container should be present") + g.Expect(kasReadinessContainer.ReadinessProbe).ToNot(BeNil(), "KAS readiness check container should have a readiness probe") + g.Expect(kasReadinessContainer.ReadinessProbe.Exec).ToNot(BeNil(), "readiness probe should use exec") + g.Expect(kasReadinessContainer.ReadinessProbe.Exec.Command).To(ContainElement(ContainSubstring("/livez"))) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + tc.validate(t, g, tc.hcp) + }) + } +} + +func TestApplyAuditWebhookConfigFileVolume(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + auditWebhookRef *corev1.LocalObjectReference + expectedVolume *corev1.Volume + expectedMountPath string + }{ + { + name: "When audit webhook ref is provided, it should add volume and mount", + auditWebhookRef: &corev1.LocalObjectReference{ + Name: "test-audit-webhook", + }, + expectedVolume: &corev1.Volume{ + Name: auditWebhookConfigFileVolumeName, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: "test-audit-webhook", + }, + }, + }, + expectedMountPath: "/etc/kubernetes/auditwebhook", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + deployment, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + applyAuditWebhookConfigFileVolume(&deployment.Spec.Template.Spec, tc.auditWebhookRef) + + // Check volume was added + volume := util.FindVolume(auditWebhookConfigFileVolumeName, deployment.Spec.Template.Spec.Volumes) + g.Expect(volume).ToNot(BeNil()) + g.Expect(volume.Name).To(Equal(tc.expectedVolume.Name)) + g.Expect(volume.VolumeSource.Secret).ToNot(BeNil()) + g.Expect(volume.VolumeSource.Secret.SecretName).To(Equal(tc.expectedVolume.VolumeSource.Secret.SecretName)) + + // Check volume mount was added to the component container + container := util.FindContainer(ComponentName, deployment.Spec.Template.Spec.Containers) + g.Expect(container).ToNot(BeNil()) + + volumeMount := util.FindVolumeMount(auditWebhookConfigFileVolumeName, container.VolumeMounts) + g.Expect(volumeMount).ToNot(BeNil()) + g.Expect(volumeMount.MountPath).To(Equal(tc.expectedMountPath)) + }) + } +} + +func TestAdaptDeploymentWithInvalidEtcdURL(t *testing.T) { + t.Parallel() + + t.Run("When unmanaged etcd endpoint is invalid, it should return error", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-ns", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Platform: hyperv1.PlatformSpec{ + Type: hyperv1.AWSPlatform, + }, + IssuerURL: "https://test-issuer.example.com", + Etcd: hyperv1.EtcdSpec{ + ManagementType: hyperv1.Unmanaged, + Unmanaged: &hyperv1.UnmanagedEtcdSpec{ + Endpoint: "://invalid-url", + }, + }, + }, + } + + deployment, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + cpContext := component.WorkloadContext{ + Client: fake.NewClientBuilder().WithScheme(api.Scheme).Build(), + HCP: hcp, + } + + err = adaptDeployment(cpContext, deployment) + g.Expect(err).To(HaveOccurred()) + }) +} + +func TestAdaptDeploymentMultipleConfigurations(t *testing.T) { + t.Parallel() + + t.Run("When multiple configurations are set, it should apply all of them", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-ns", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Platform: hyperv1.PlatformSpec{ + Type: hyperv1.AWSPlatform, + }, + IssuerURL: "https://test-issuer.example.com", + Etcd: hyperv1.EtcdSpec{ + ManagementType: hyperv1.Unmanaged, + Unmanaged: &hyperv1.UnmanagedEtcdSpec{ + Endpoint: "https://custom-etcd.example.com:2379", + }, + }, + AuditWebhook: &corev1.LocalObjectReference{ + Name: "audit-webhook-secret", + }, + Configuration: &hyperv1.ClusterConfiguration{ + OAuth: &configv1.OAuthSpec{ + TokenConfig: configv1.TokenConfig{ + AccessTokenInactivityTimeout: &metav1.Duration{ + Duration: 15 * time.Minute, + }, + }, + }, + APIServer: &configv1.APIServerSpec{ + TLSSecurityProfile: &configv1.TLSSecurityProfile{ + Type: configv1.TLSProfileModernType, + Modern: &configv1.ModernTLSProfile{}, + }, + }, + }, + }, + } + + deployment, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + cpContext := component.WorkloadContext{ + Client: fake.NewClientBuilder().WithScheme(api.Scheme).Build(), + HCP: hcp, + } + + err = adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + container := util.FindContainer(ComponentName, deployment.Spec.Template.Spec.Containers) + g.Expect(container).ToNot(BeNil()) + + // Check all configurations are applied + g.Expect(container.Args).To(ContainElement("--etcd-servers=https://custom-etcd.example.com:2379")) + g.Expect(container.Args).To(ContainElement("--audit-webhook-config-file=/etc/kubernetes/auditwebhook/webhook-kubeconfig")) + g.Expect(container.Args).To(ContainElement("--accesstoken-inactivity-timeout=15m0s")) + g.Expect(container.Args).To(ContainElement("--tls-min-version=VersionTLS13")) + + // Check volume and mount + volume := util.FindVolume(auditWebhookConfigFileVolumeName, deployment.Spec.Template.Spec.Volumes) + g.Expect(volume).ToNot(BeNil()) + + volumeMount := util.FindVolumeMount(auditWebhookConfigFileVolumeName, container.VolumeMounts) + g.Expect(volumeMount).ToNot(BeNil()) + + // Check NO_PROXY + noProxyEnv := util.FindEnvVar("NO_PROXY", container.Env) + g.Expect(noProxyEnv).ToNot(BeNil()) + g.Expect(noProxyEnv.Value).To(ContainSubstring("custom-etcd.example.com")) + }) +} diff --git a/support/testutil/fake.go b/support/testutil/fake.go index f5429c1a30e..e1e62dfc854 100644 --- a/support/testutil/fake.go +++ b/support/testutil/fake.go @@ -4,11 +4,33 @@ import "github.com/openshift/hypershift/control-plane-operator/controllers/hoste var _ imageprovider.ReleaseImageProvider = &fakeImageProvider{} -func FakeImageProvider() imageprovider.ReleaseImageProvider { - return &fakeImageProvider{} +func FakeImageProvider(opts ...FakeImageProviderOpt) imageprovider.ReleaseImageProvider { + f := &fakeImageProvider{ + version: "4.18.0", + } + for _, opt := range opts { + opt(f) + } + return f +} + +type FakeImageProviderOpt func(*fakeImageProvider) + +func WithVersion(version string) FakeImageProviderOpt { + return func(f *fakeImageProvider) { + f.version = version + } +} + +func WithImages(images map[string]string) FakeImageProviderOpt { + return func(f *fakeImageProvider) { + f.images = images + } } type fakeImageProvider struct { + version string + images map[string]string } // ComponentVersions implements imageprovider.ReleaseImageProvider. @@ -20,17 +42,29 @@ func (f *fakeImageProvider) ComponentVersions() (map[string]string, error) { // Version implements imageprovider.ReleaseImageProvider. func (f *fakeImageProvider) Version() string { - return "4.18.0" + return f.version } func (f *fakeImageProvider) GetImage(key string) string { + if f.images != nil { + if img, ok := f.images[key]; ok { + return img + } + } return key } func (f *fakeImageProvider) ImageExist(key string) (string, bool) { + if f.images != nil { + img, ok := f.images[key] + return img, ok + } return key, true } -func (f *fakeImageProvider) ComponentImages() map[string]string { // not used +func (f *fakeImageProvider) ComponentImages() map[string]string { + if f.images != nil { + return f.images + } return map[string]string{} } diff --git a/support/util/containers.go b/support/util/containers.go index 419f03bcb1e..406bcd2b4f0 100644 --- a/support/util/containers.go +++ b/support/util/containers.go @@ -25,6 +25,33 @@ func FindContainer(name string, containers []corev1.Container) *corev1.Container return nil } +func FindEnvVar(name string, envVars []corev1.EnvVar) *corev1.EnvVar { + for i := range envVars { + if envVars[i].Name == name { + return &envVars[i] + } + } + return nil +} + +func FindVolume(name string, volumes []corev1.Volume) *corev1.Volume { + for i := range volumes { + if volumes[i].Name == name { + return &volumes[i] + } + } + return nil +} + +func FindVolumeMount(name string, mounts []corev1.VolumeMount) *corev1.VolumeMount { + for i := range mounts { + if mounts[i].Name == name { + return &mounts[i] + } + } + return nil +} + func UpdateContainer(name string, containers []corev1.Container, update func(c *corev1.Container)) { for i, c := range containers { if c.Name == name { diff --git a/support/util/containers_test.go b/support/util/containers_test.go index 50c72af8418..15868452e15 100644 --- a/support/util/containers_test.go +++ b/support/util/containers_test.go @@ -3,12 +3,127 @@ package util import ( "testing" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" "k8s.io/utils/ptr" "github.com/google/go-cmp/cmp" ) +func TestFindContainer(t *testing.T) { + t.Parallel() + containers := []corev1.Container{ + {Name: "first"}, + {Name: "second"}, + {Name: "third"}, + } + + t.Run("When the container exists, it should return a pointer to it", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + result := FindContainer("second", containers) + g.Expect(result).ToNot(BeNil()) + g.Expect(result.Name).To(Equal("second")) + }) + + t.Run("When the container does not exist, it should return nil", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + g.Expect(FindContainer("nonexistent", containers)).To(BeNil()) + }) + + t.Run("When the slice is empty, it should return nil", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + g.Expect(FindContainer("any", nil)).To(BeNil()) + }) +} + +func TestFindEnvVar(t *testing.T) { + t.Parallel() + envVars := []corev1.EnvVar{ + {Name: "FOO", Value: "bar"}, + {Name: "BAZ", Value: "qux"}, + } + + t.Run("When the env var exists, it should return a pointer to it", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + result := FindEnvVar("BAZ", envVars) + g.Expect(result).ToNot(BeNil()) + g.Expect(result.Value).To(Equal("qux")) + }) + + t.Run("When the env var does not exist, it should return nil", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + g.Expect(FindEnvVar("MISSING", envVars)).To(BeNil()) + }) + + t.Run("When the slice is empty, it should return nil", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + g.Expect(FindEnvVar("FOO", nil)).To(BeNil()) + }) +} + +func TestFindVolume(t *testing.T) { + t.Parallel() + volumes := []corev1.Volume{ + {Name: "config"}, + {Name: "certs"}, + } + + t.Run("When the volume exists, it should return a pointer to it", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + result := FindVolume("certs", volumes) + g.Expect(result).ToNot(BeNil()) + g.Expect(result.Name).To(Equal("certs")) + }) + + t.Run("When the volume does not exist, it should return nil", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + g.Expect(FindVolume("missing", volumes)).To(BeNil()) + }) + + t.Run("When the slice is empty, it should return nil", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + g.Expect(FindVolume("any", nil)).To(BeNil()) + }) +} + +func TestFindVolumeMount(t *testing.T) { + t.Parallel() + mounts := []corev1.VolumeMount{ + {Name: "data", MountPath: "/data"}, + {Name: "config", MountPath: "/etc/config"}, + } + + t.Run("When the volume mount exists, it should return a pointer to it", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + result := FindVolumeMount("config", mounts) + g.Expect(result).ToNot(BeNil()) + g.Expect(result.MountPath).To(Equal("/etc/config")) + }) + + t.Run("When the volume mount does not exist, it should return nil", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + g.Expect(FindVolumeMount("missing", mounts)).To(BeNil()) + }) + + t.Run("When the slice is empty, it should return nil", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + g.Expect(FindVolumeMount("any", nil)).To(BeNil()) + }) +} + func TestEnforceRestrictedSecurityContextToContainers(t *testing.T) { tests := []struct { name string From 23bb050506ab38c9cab3e9be34df18144206e7da Mon Sep 17 00:00:00 2001 From: Bryan Cox Date: Mon, 13 Apr 2026 08:35:04 -0400 Subject: [PATCH 6/7] test: Add unit tests for v2 OLM controller packages Add behavior-driven unit tests for all OLM v2 controller sub-packages including catalog operator, catalogs, collect profiles, OLM operator, and packageserver covering deployment adaptation, service monitors, predicates, and cron schedule generation. Co-Authored-By: Claude Opus 4.6 --- .../olm/catalog_operator/deployment_test.go | 115 ++++++++++++ .../catalog_operator/servicemonitor_test.go | 97 ++++++++++ .../v2/olm/catalogs/component_test.go | 173 ++++++++++++++++++ .../v2/olm/catalogs/deployment_test.go | 167 +++++++++++++++++ .../v2/olm/collect_profiles/component_test.go | 75 ++++++++ .../v2/olm/collect_profiles/cronjob_test.go | 157 ++++++++++++++++ .../v2/olm/component_test.go | 42 +++++ .../v2/olm/olm_operator/deployment_test.go | 92 ++++++++++ .../olm/olm_operator/servicemonitor_test.go | 108 +++++++++++ .../v2/olm/packageserver/deployment_test.go | 150 +++++++++++++++ 10 files changed, 1176 insertions(+) create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/olm/catalog_operator/deployment_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/olm/catalog_operator/servicemonitor_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/olm/catalogs/component_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/olm/catalogs/deployment_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/olm/collect_profiles/component_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/olm/collect_profiles/cronjob_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/olm/component_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/olm/olm_operator/deployment_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/olm/olm_operator/servicemonitor_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/olm/packageserver/deployment_test.go diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/olm/catalog_operator/deployment_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/olm/catalog_operator/deployment_test.go new file mode 100644 index 00000000000..130d9a6e411 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/olm/catalog_operator/deployment_test.go @@ -0,0 +1,115 @@ +package catalogoperator + +import ( + "strings" + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + assets "github.com/openshift/hypershift/control-plane-operator/controllers/hostedcontrolplane/v2/assets" + component "github.com/openshift/hypershift/support/controlplane-component" + "github.com/openshift/hypershift/support/testutil" + "github.com/openshift/hypershift/support/util" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestAdaptDeployment(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + olmCatalogPlacement hyperv1.OLMCatalogPlacement + expectedNoProxyHosts []string + expectedOLMOperatorImage string + expectedOperatorRegistryImage string + expectedReleaseVersion string + }{ + { + name: "When OLMCatalogPlacement is Management, it should set NO_PROXY with catalog services", + olmCatalogPlacement: hyperv1.ManagementOLMCatalogPlacement, + expectedNoProxyHosts: []string{"kube-apiserver", "certified-operators", "community-operators", "redhat-operators", "redhat-marketplace"}, + expectedOLMOperatorImage: "test-olm-operator-image", + expectedOperatorRegistryImage: "test-operator-registry-image", + expectedReleaseVersion: "4.15.0", + }, + { + name: "When OLMCatalogPlacement is Guest, it should set NO_PROXY without catalog services", + olmCatalogPlacement: hyperv1.GuestOLMCatalogPlacement, + expectedNoProxyHosts: []string{"kube-apiserver"}, + expectedOLMOperatorImage: "test-olm-operator-image", + expectedOperatorRegistryImage: "test-operator-registry-image", + expectedReleaseVersion: "4.15.0", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + OLMCatalogPlacement: tc.olmCatalogPlacement, + }, + } + + releaseProvider := testutil.FakeImageProvider( + testutil.WithVersion(tc.expectedReleaseVersion), + testutil.WithImages(map[string]string{ + "operator-lifecycle-manager": tc.expectedOLMOperatorImage, + "operator-registry": tc.expectedOperatorRegistryImage, + }), + ) + + cpContext := component.WorkloadContext{ + HCP: hcp, + ReleaseImageProvider: releaseProvider, + UserReleaseImageProvider: releaseProvider, + } + + deployment, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + err = adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + // Verify environment variables + catalogOperatorContainer := util.FindContainer(ComponentName, deployment.Spec.Template.Spec.Containers) + g.Expect(catalogOperatorContainer).ToNot(BeNil()) + + // Check RELEASE_VERSION + g.Expect(catalogOperatorContainer.Env).To(ContainElement( + corev1.EnvVar{Name: "RELEASE_VERSION", Value: tc.expectedReleaseVersion}, + )) + + // Check OLM_OPERATOR_IMAGE + g.Expect(catalogOperatorContainer.Env).To(ContainElement( + corev1.EnvVar{Name: "OLM_OPERATOR_IMAGE", Value: tc.expectedOLMOperatorImage}, + )) + + // Check OPERATOR_REGISTRY_IMAGE + g.Expect(catalogOperatorContainer.Env).To(ContainElement( + corev1.EnvVar{Name: "OPERATOR_REGISTRY_IMAGE", Value: tc.expectedOperatorRegistryImage}, + )) + + // Check NO_PROXY contains expected hosts + var noProxyEnv *corev1.EnvVar + for i := range catalogOperatorContainer.Env { + if catalogOperatorContainer.Env[i].Name == "NO_PROXY" { + noProxyEnv = &catalogOperatorContainer.Env[i] + break + } + } + g.Expect(noProxyEnv).ToNot(BeNil()) + actualNoProxyHosts := strings.Split(noProxyEnv.Value, ",") + g.Expect(actualNoProxyHosts).To(ConsistOf(tc.expectedNoProxyHosts)) + }) + } +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/olm/catalog_operator/servicemonitor_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/olm/catalog_operator/servicemonitor_test.go new file mode 100644 index 00000000000..ae7d8788aaa --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/olm/catalog_operator/servicemonitor_test.go @@ -0,0 +1,97 @@ +package catalogoperator + +import ( + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + component "github.com/openshift/hypershift/support/controlplane-component" + "github.com/openshift/hypershift/support/metrics" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + prometheusoperatorv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" +) + +func TestAdaptServiceMonitor(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + smNamespace string + clusterID string + metricsSet metrics.MetricsSet + expectedNamespaces []string + }{ + { + name: "When ServiceMonitor is adapted, it should configure namespace selector and metric relabel configs", + smNamespace: "test-namespace", + clusterID: "test-cluster-id", + metricsSet: metrics.MetricsSetTelemetry, + expectedNamespaces: []string{"test-namespace"}, + }, + { + name: "When ServiceMonitor has different namespace, it should use that namespace", + smNamespace: "another-namespace", + clusterID: "another-cluster-id", + metricsSet: metrics.MetricsSetAll, + expectedNamespaces: []string{"another-namespace"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: tc.smNamespace, + }, + Spec: hyperv1.HostedControlPlaneSpec{ + ClusterID: tc.clusterID, + }, + } + + cpContext := component.WorkloadContext{ + HCP: hcp, + MetricsSet: tc.metricsSet, + } + + sm := &prometheusoperatorv1.ServiceMonitor{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sm", + Namespace: tc.smNamespace, + }, + Spec: prometheusoperatorv1.ServiceMonitorSpec{ + Endpoints: []prometheusoperatorv1.Endpoint{ + {}, + }, + }, + } + + err := adaptServiceMonitor(cpContext, sm) + g.Expect(err).ToNot(HaveOccurred()) + + // Verify namespace selector + g.Expect(sm.Spec.NamespaceSelector.MatchNames).To(Equal(tc.expectedNamespaces)) + + // Verify metric relabel configs are set (not checking exact equality since they include cluster ID) + g.Expect(len(sm.Spec.Endpoints[0].MetricRelabelConfigs)).To(BeNumerically(">", 0)) + + // Verify cluster ID label is in relabel configs + var clusterIDLabelFound bool + for _, relabelConfig := range sm.Spec.Endpoints[0].MetricRelabelConfigs { + if relabelConfig.TargetLabel == "_id" { + g.Expect(relabelConfig.Replacement).ToNot(BeNil()) + g.Expect(*relabelConfig.Replacement).To(Equal(tc.clusterID)) + clusterIDLabelFound = true + break + } + } + g.Expect(clusterIDLabelFound).To(BeTrue(), "cluster ID label should be in metric relabel configs") + }) + } +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/olm/catalogs/component_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/olm/catalogs/component_test.go new file mode 100644 index 00000000000..05c33f7fedc --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/olm/catalogs/component_test.go @@ -0,0 +1,173 @@ +package catalogs + +import ( + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + component "github.com/openshift/hypershift/support/controlplane-component" + + configv1 "github.com/openshift/api/config/v1" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestCatalogsPredicate(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + olmCatalogPlacement hyperv1.OLMCatalogPlacement + disableAllDefaultSources bool + expectedResult bool + }{ + { + name: "When OLMCatalogPlacement is Management and default sources are not disabled, it should return true", + olmCatalogPlacement: hyperv1.ManagementOLMCatalogPlacement, + disableAllDefaultSources: false, + expectedResult: true, + }, + { + name: "When OLMCatalogPlacement is Guest, it should return false", + olmCatalogPlacement: hyperv1.GuestOLMCatalogPlacement, + disableAllDefaultSources: false, + expectedResult: false, + }, + { + name: "When default sources are disabled, it should return false regardless of placement", + olmCatalogPlacement: hyperv1.ManagementOLMCatalogPlacement, + disableAllDefaultSources: true, + expectedResult: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + OLMCatalogPlacement: tc.olmCatalogPlacement, + }, + } + + if tc.disableAllDefaultSources { + hcp.Spec.Configuration = &hyperv1.ClusterConfiguration{ + OperatorHub: &configv1.OperatorHubSpec{ + DisableAllDefaultSources: true, + }, + } + } + + cpContext := component.WorkloadContext{ + HCP: hcp, + } + + result, err := catalogsPredicate(cpContext) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(result).To(Equal(tc.expectedResult)) + }) + } +} + +func TestImageStreamPredicate(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + capabilityImageStream bool + annotationPresent bool + expectedResult bool + }{ + { + name: "When capabilityImageStream is false, it should return false", + capabilityImageStream: false, + annotationPresent: false, + expectedResult: false, + }, + { + name: "When annotation is present, it should return false", + capabilityImageStream: true, + annotationPresent: true, + expectedResult: false, + }, + { + name: "When capabilityImageStream is true and no annotation, it should return true", + capabilityImageStream: true, + annotationPresent: false, + expectedResult: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + } + + if tc.annotationPresent { + hcp.Annotations = map[string]string{ + hyperv1.RedHatOperatorsCatalogImageAnnotation: "some-image", + } + } + + catalogOpts := &catalogOptions{ + capabilityImageStream: tc.capabilityImageStream, + } + + cpContext := component.WorkloadContext{ + HCP: hcp, + } + + result := catalogOpts.imageStreamPredicate(cpContext) + g.Expect(result).To(Equal(tc.expectedResult)) + }) + } +} + +func TestNewCatalogComponents(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + capabilityImageStream bool + expectedCount int + }{ + { + name: "When creating catalog components, it should return 4 components", + capabilityImageStream: false, + expectedCount: 4, + }, + { + name: "When creating catalog components with image stream capability, it should return 4 components", + capabilityImageStream: true, + expectedCount: 4, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + components := NewCatalogComponents(tc.capabilityImageStream) + + g.Expect(components).To(HaveLen(tc.expectedCount)) + for _, comp := range components { + g.Expect(comp).ToNot(BeNil()) + } + }) + } +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/olm/catalogs/deployment_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/olm/catalogs/deployment_test.go new file mode 100644 index 00000000000..830091c230c --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/olm/catalogs/deployment_test.go @@ -0,0 +1,167 @@ +package catalogs + +import ( + "errors" + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + component "github.com/openshift/hypershift/support/controlplane-component" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestCheckCatalogImageOverrides(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + images map[string]string + expectedOverride bool + expectedError error + }{ + { + name: "When all images are empty, it should return false with no error", + images: map[string]string{ + "redhat-operators": "", + "redhat-marketplace": "", + "community-operators": "", + "certified-operators": "", + }, + expectedOverride: false, + expectedError: nil, + }, + { + name: "When all images are provided with sha256, it should return true with no error", + images: map[string]string{ + "redhat-operators": "registry.io/repo@sha256:abc123", + "redhat-marketplace": "registry.io/repo@sha256:def456", + "community-operators": "registry.io/repo@sha256:ghi789", + "certified-operators": "registry.io/repo@sha256:jkl012", + }, + expectedOverride: true, + expectedError: nil, + }, + { + name: "When image is provided without sha256, it should return error", + images: map[string]string{ + "redhat-operators": "registry.io/repo:latest", + "redhat-marketplace": "", + "community-operators": "", + "certified-operators": "", + }, + expectedOverride: false, + expectedError: errors.New("images for OLM catalogs should be referenced only by digest"), + }, + { + name: "When some images are missing, it should return error", + images: map[string]string{ + "redhat-operators": "registry.io/repo@sha256:abc123", + "redhat-marketplace": "registry.io/repo@sha256:def456", + "community-operators": "", + "certified-operators": "", + }, + expectedOverride: false, + expectedError: errors.New("if OLM catalog images are overridden, all the values for the 4 default catalogs should be provided"), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + override, err := checkCatalogImageOverides(tc.images) + + g.Expect(override).To(Equal(tc.expectedOverride)) + if tc.expectedError != nil { + g.Expect(err).To(MatchError(tc.expectedError.Error())) + } else { + g.Expect(err).ToNot(HaveOccurred()) + } + }) + } +} + +func TestGetCatalogImagesOverrides(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + annotations map[string]string + capabilityImageStream bool + validate func(g Gomega, result map[string]string, err error) + }{ + { + name: "When all catalog annotations are set with sha256, it should return overrides", + annotations: map[string]string{ + hyperv1.RedHatOperatorsCatalogImageAnnotation: "registry.io/redhat@sha256:abc", + hyperv1.RedHatMarketplaceCatalogImageAnnotation: "registry.io/marketplace@sha256:def", + hyperv1.CommunityOperatorsCatalogImageAnnotation: "registry.io/community@sha256:ghi", + hyperv1.CertifiedOperatorsCatalogImageAnnotation: "registry.io/certified@sha256:jkl", + }, + capabilityImageStream: false, + validate: func(g Gomega, result map[string]string, err error) { + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(result).To(HaveKeyWithValue("redhat-operators", "registry.io/redhat@sha256:abc")) + g.Expect(result).To(HaveKeyWithValue("redhat-marketplace", "registry.io/marketplace@sha256:def")) + g.Expect(result).To(HaveKeyWithValue("community-operators", "registry.io/community@sha256:ghi")) + g.Expect(result).To(HaveKeyWithValue("certified-operators", "registry.io/certified@sha256:jkl")) + }, + }, + { + name: "When annotations are incomplete, it should return error", + annotations: map[string]string{ + hyperv1.RedHatOperatorsCatalogImageAnnotation: "registry.io/redhat@sha256:abc", + }, + capabilityImageStream: false, + validate: func(g Gomega, result map[string]string, err error) { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("if OLM catalog images are overridden")) + }, + }, + { + name: "When annotations use tags instead of digests, it should return error", + annotations: map[string]string{ + hyperv1.RedHatOperatorsCatalogImageAnnotation: "registry.io/redhat:latest", + }, + capabilityImageStream: false, + validate: func(g Gomega, result map[string]string, err error) { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("should be referenced only by digest")) + }, + }, + { + name: "When no annotations are set and capabilityImageStream is true, it should return nil", + annotations: map[string]string{}, + capabilityImageStream: true, + validate: func(g Gomega, result map[string]string, err error) { + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(result).To(BeNil()) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + Annotations: tc.annotations, + }, + } + + cpContext := component.WorkloadContext{ + HCP: hcp, + } + + result, err := getCatalogImagesOverrides(cpContext, tc.capabilityImageStream) + tc.validate(g, result, err) + }) + } +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/olm/collect_profiles/component_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/olm/collect_profiles/component_test.go new file mode 100644 index 00000000000..216f6405be5 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/olm/collect_profiles/component_test.go @@ -0,0 +1,75 @@ +package collectprofiles + +import ( + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + component "github.com/openshift/hypershift/support/controlplane-component" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestPredicate(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + platformType hyperv1.PlatformType + expectedResult bool + }{ + { + name: "When platform is AWS, it should return true", + platformType: hyperv1.AWSPlatform, + expectedResult: true, + }, + { + name: "When platform is Azure, it should return true", + platformType: hyperv1.AzurePlatform, + expectedResult: true, + }, + { + name: "When platform is IBMCloud, it should return false", + platformType: hyperv1.IBMCloudPlatform, + expectedResult: false, + }, + { + name: "When platform is KubeVirt, it should return true", + platformType: hyperv1.KubevirtPlatform, + expectedResult: true, + }, + { + name: "When platform is None, it should return true", + platformType: hyperv1.NonePlatform, + expectedResult: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Platform: hyperv1.PlatformSpec{ + Type: tc.platformType, + }, + }, + } + + cpContext := component.WorkloadContext{ + HCP: hcp, + } + + result, err := predicate(cpContext) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(result).To(Equal(tc.expectedResult)) + }) + } +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/olm/collect_profiles/cronjob_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/olm/collect_profiles/cronjob_test.go new file mode 100644 index 00000000000..20df88258ba --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/olm/collect_profiles/cronjob_test.go @@ -0,0 +1,157 @@ +package collectprofiles + +import ( + "fmt" + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + assets "github.com/openshift/hypershift/control-plane-operator/controllers/hostedcontrolplane/v2/assets" + component "github.com/openshift/hypershift/support/controlplane-component" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestAdaptCronJob(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + namespace string + expectedSchedule string + }{ + { + name: "When namespace is test-namespace, it should generate consistent schedule", + namespace: "test-namespace", + expectedSchedule: "9 21 * * *", // Based on modular calculation of "test-namespace" + }, + { + name: "When namespace is different, it should generate different schedule", + namespace: "another-namespace", + expectedSchedule: "29 5 * * *", // Based on modular calculation of "another-namespace" + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: tc.namespace, + }, + } + + cpContext := component.WorkloadContext{ + HCP: hcp, + } + + cronJob, err := assets.LoadCronJobManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + cronJob.Namespace = tc.namespace + + err = adaptCronJob(cpContext, cronJob) + g.Expect(err).ToNot(HaveOccurred()) + + g.Expect(cronJob.Spec.Schedule).To(Equal(tc.expectedSchedule)) + }) + } +} + +func TestGenerateModularDailyCronSchedule(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + input []byte + expectedSchedule string + }{ + { + name: "When input is empty, it should return 0 0 * * *", + input: []byte{}, + expectedSchedule: "0 0 * * *", + }, + { + name: "When input is single byte, it should calculate modulo correctly", + input: []byte{65}, // ASCII 'A' + expectedSchedule: "5 17 * * *", + }, + { + name: "When input is test-namespace, it should return deterministic schedule", + input: []byte("test-namespace"), + expectedSchedule: "9 21 * * *", + }, + { + name: "When input is another-namespace, it should return different schedule", + input: []byte("another-namespace"), + expectedSchedule: "29 5 * * *", + }, + { + name: "When input is very-long-namespace-name, it should handle large values", + input: []byte("very-long-namespace-name-with-many-characters"), + expectedSchedule: "11 11 * * *", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + schedule := generateModularDailyCronSchedule(tc.input) + g.Expect(schedule).To(Equal(tc.expectedSchedule)) + }) + } +} + +func TestGenerateModularDailyCronScheduleProperties(t *testing.T) { + t.Parallel() + + t.Run("When generating schedules, minute should be within 0-59", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + for i := 0; i < 1000; i++ { + input := []byte{byte(i), byte(i >> 8)} + schedule := generateModularDailyCronSchedule(input) + + // Parse the schedule + var minute, hour int + _, err := fmt.Sscanf(schedule, "%d %d * * *", &minute, &hour) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(minute).To(BeNumerically(">=", 0)) + g.Expect(minute).To(BeNumerically("<", 60)) + } + }) + + t.Run("When generating schedules, hour should be within 0-23", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + for i := 0; i < 1000; i++ { + input := []byte{byte(i), byte(i >> 8)} + schedule := generateModularDailyCronSchedule(input) + + // Parse the schedule + var minute, hour int + _, err := fmt.Sscanf(schedule, "%d %d * * *", &minute, &hour) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(hour).To(BeNumerically(">=", 0)) + g.Expect(hour).To(BeNumerically("<", 24)) + } + }) + + t.Run("When input is same, it should return same schedule", func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + input := []byte("consistent-namespace") + schedule1 := generateModularDailyCronSchedule(input) + schedule2 := generateModularDailyCronSchedule(input) + + g.Expect(schedule1).To(Equal(schedule2)) + }) +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/olm/component_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/olm/component_test.go new file mode 100644 index 00000000000..f0df2c9821e --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/olm/component_test.go @@ -0,0 +1,42 @@ +package olm + +import ( + "testing" + + . "github.com/onsi/gomega" +) + +func TestNewComponents(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + capabilityImageStream bool + expectedCount int + }{ + { + name: "When capabilityImageStream is false, it should return 8 components", + capabilityImageStream: false, + expectedCount: 8, + }, + { + name: "When capabilityImageStream is true, it should return 8 components", + capabilityImageStream: true, + expectedCount: 8, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + components := NewComponents(tc.capabilityImageStream) + + g.Expect(components).To(HaveLen(tc.expectedCount)) + for _, comp := range components { + g.Expect(comp).ToNot(BeNil()) + } + }) + } +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/olm/olm_operator/deployment_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/olm/olm_operator/deployment_test.go new file mode 100644 index 00000000000..aa465d5dc67 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/olm/olm_operator/deployment_test.go @@ -0,0 +1,92 @@ +package olmoperator + +import ( + "strings" + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + assets "github.com/openshift/hypershift/control-plane-operator/controllers/hostedcontrolplane/v2/assets" + component "github.com/openshift/hypershift/support/controlplane-component" + "github.com/openshift/hypershift/support/testutil" + "github.com/openshift/hypershift/support/util" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestAdaptDeployment(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + olmCatalogPlacement hyperv1.OLMCatalogPlacement + expectedNoProxyHosts []string + expectedReleaseVersion string + }{ + { + name: "When OLMCatalogPlacement is Management, it should set NO_PROXY with catalog services", + olmCatalogPlacement: hyperv1.ManagementOLMCatalogPlacement, + expectedNoProxyHosts: []string{"kube-apiserver", "certified-operators", "community-operators", "redhat-operators", "redhat-marketplace"}, + expectedReleaseVersion: "4.15.0", + }, + { + name: "When OLMCatalogPlacement is Guest, it should set NO_PROXY without catalog services", + olmCatalogPlacement: hyperv1.GuestOLMCatalogPlacement, + expectedNoProxyHosts: []string{"kube-apiserver"}, + expectedReleaseVersion: "4.15.0", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + OLMCatalogPlacement: tc.olmCatalogPlacement, + }, + } + + releaseProvider := testutil.FakeImageProvider(testutil.WithVersion(tc.expectedReleaseVersion)) + + cpContext := component.WorkloadContext{ + HCP: hcp, + UserReleaseImageProvider: releaseProvider, + } + + deployment, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + err = adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + // Verify environment variables + olmOperatorContainer := util.FindContainer(ComponentName, deployment.Spec.Template.Spec.Containers) + g.Expect(olmOperatorContainer).ToNot(BeNil()) + + // Check RELEASE_VERSION + g.Expect(olmOperatorContainer.Env).To(ContainElement( + corev1.EnvVar{Name: "RELEASE_VERSION", Value: tc.expectedReleaseVersion}, + )) + + // Check NO_PROXY contains expected hosts + var noProxyEnv *corev1.EnvVar + for i := range olmOperatorContainer.Env { + if olmOperatorContainer.Env[i].Name == "NO_PROXY" { + noProxyEnv = &olmOperatorContainer.Env[i] + break + } + } + g.Expect(noProxyEnv).ToNot(BeNil()) + actualNoProxyHosts := strings.Split(noProxyEnv.Value, ",") + g.Expect(actualNoProxyHosts).To(ConsistOf(tc.expectedNoProxyHosts)) + }) + } +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/olm/olm_operator/servicemonitor_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/olm/olm_operator/servicemonitor_test.go new file mode 100644 index 00000000000..e074644818d --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/olm/olm_operator/servicemonitor_test.go @@ -0,0 +1,108 @@ +package olmoperator + +import ( + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + component "github.com/openshift/hypershift/support/controlplane-component" + "github.com/openshift/hypershift/support/metrics" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + prometheusoperatorv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1" +) + +func TestAdaptServiceMonitor(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + hcpNamespace string + smNamespace string + clusterID string + metricsSet metrics.MetricsSet + expectedNamespaces []string + }{ + { + name: "When ServiceMonitor is adapted, it should configure namespace selector and metric relabel configs", + hcpNamespace: "hcp-namespace", + smNamespace: "test-namespace", + clusterID: "test-cluster-id", + metricsSet: metrics.MetricsSetTelemetry, + expectedNamespaces: []string{"test-namespace"}, + }, + { + name: "When ServiceMonitor has different namespace, it should use that namespace", + hcpNamespace: "hcp-namespace", + smNamespace: "another-namespace", + clusterID: "another-cluster-id", + metricsSet: metrics.MetricsSetAll, + expectedNamespaces: []string{"another-namespace"}, + }, + { + name: "When metrics set is SRE, it should still configure namespace selector with SM namespace", + hcpNamespace: "sre-hcp-namespace", + smNamespace: "sre-namespace", + clusterID: "sre-cluster-id", + metricsSet: metrics.MetricsSetSRE, + expectedNamespaces: []string{"sre-namespace"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: tc.hcpNamespace, + }, + Spec: hyperv1.HostedControlPlaneSpec{ + ClusterID: tc.clusterID, + }, + } + + cpContext := component.WorkloadContext{ + HCP: hcp, + MetricsSet: tc.metricsSet, + } + + sm := &prometheusoperatorv1.ServiceMonitor{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sm", + Namespace: tc.smNamespace, + }, + Spec: prometheusoperatorv1.ServiceMonitorSpec{ + Endpoints: []prometheusoperatorv1.Endpoint{ + {}, + }, + }, + } + + err := adaptServiceMonitor(cpContext, sm) + g.Expect(err).ToNot(HaveOccurred()) + + // Verify namespace selector + g.Expect(sm.Spec.NamespaceSelector.MatchNames).To(Equal(tc.expectedNamespaces)) + + // Verify metric relabel configs are set (not checking exact equality since they include cluster ID) + g.Expect(len(sm.Spec.Endpoints[0].MetricRelabelConfigs)).To(BeNumerically(">", 0)) + + // Verify cluster ID label is in metric relabel configs + var clusterIDLabelFound bool + for _, relabelConfig := range sm.Spec.Endpoints[0].MetricRelabelConfigs { + if relabelConfig.TargetLabel == "_id" { + g.Expect(relabelConfig.Replacement).ToNot(BeNil()) + g.Expect(*relabelConfig.Replacement).To(Equal(tc.clusterID)) + clusterIDLabelFound = true + break + } + } + g.Expect(clusterIDLabelFound).To(BeTrue(), "cluster ID label should be in metric relabel configs") + }) + } +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/olm/packageserver/deployment_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/olm/packageserver/deployment_test.go new file mode 100644 index 00000000000..71abd9aa7fa --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/olm/packageserver/deployment_test.go @@ -0,0 +1,150 @@ +package packageserver + +import ( + "strings" + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + assets "github.com/openshift/hypershift/control-plane-operator/controllers/hostedcontrolplane/v2/assets" + component "github.com/openshift/hypershift/support/controlplane-component" + "github.com/openshift/hypershift/support/testutil" + "github.com/openshift/hypershift/support/util" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestAdaptDeployment(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + platformType hyperv1.PlatformType + olmCatalogPlacement hyperv1.OLMCatalogPlacement + controllerAvailabilityPolicy hyperv1.AvailabilityPolicy + expectedNoProxyHosts []string + expectedReleaseVersion string + expectedReplicas *int32 + expectedKASReadinessCheck bool + }{ + { + name: "When OLMCatalogPlacement is Management, it should set NO_PROXY with catalog services", + platformType: hyperv1.AWSPlatform, + olmCatalogPlacement: hyperv1.ManagementOLMCatalogPlacement, + controllerAvailabilityPolicy: hyperv1.SingleReplica, + expectedNoProxyHosts: []string{"kube-apiserver", "certified-operators", "community-operators", "redhat-operators", "redhat-marketplace"}, + expectedReleaseVersion: "4.15.0", + expectedReplicas: nil, + expectedKASReadinessCheck: true, + }, + { + name: "When OLMCatalogPlacement is Guest, it should set NO_PROXY without catalog services", + platformType: hyperv1.AWSPlatform, + olmCatalogPlacement: hyperv1.GuestOLMCatalogPlacement, + controllerAvailabilityPolicy: hyperv1.SingleReplica, + expectedNoProxyHosts: []string{"kube-apiserver"}, + expectedReleaseVersion: "4.15.0", + expectedReplicas: nil, + expectedKASReadinessCheck: true, + }, + { + name: "When platform is IBMCloud with HighlyAvailable, it should set replicas to 2", + platformType: hyperv1.IBMCloudPlatform, + olmCatalogPlacement: hyperv1.GuestOLMCatalogPlacement, + controllerAvailabilityPolicy: hyperv1.HighlyAvailable, + expectedNoProxyHosts: []string{"kube-apiserver"}, + expectedReleaseVersion: "4.15.0", + expectedReplicas: func() *int32 { r := int32(2); return &r }(), + expectedKASReadinessCheck: true, + }, + { + name: "When platform is IBMCloud with SingleReplica, it should not override replicas", + platformType: hyperv1.IBMCloudPlatform, + olmCatalogPlacement: hyperv1.GuestOLMCatalogPlacement, + controllerAvailabilityPolicy: hyperv1.SingleReplica, + expectedNoProxyHosts: []string{"kube-apiserver"}, + expectedReleaseVersion: "4.15.0", + expectedReplicas: nil, + expectedKASReadinessCheck: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Platform: hyperv1.PlatformSpec{ + Type: tc.platformType, + }, + OLMCatalogPlacement: tc.olmCatalogPlacement, + ControllerAvailabilityPolicy: tc.controllerAvailabilityPolicy, + }, + } + + releaseProvider := testutil.FakeImageProvider(testutil.WithVersion(tc.expectedReleaseVersion)) + + cpContext := component.WorkloadContext{ + HCP: hcp, + UserReleaseImageProvider: releaseProvider, + } + + deployment, err := assets.LoadDeploymentManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + err = adaptDeployment(cpContext, deployment) + g.Expect(err).ToNot(HaveOccurred()) + + // Verify environment variables + packageServerContainer := util.FindContainer(ComponentName, deployment.Spec.Template.Spec.Containers) + g.Expect(packageServerContainer).ToNot(BeNil()) + + // Check RELEASE_VERSION + g.Expect(packageServerContainer.Env).To(ContainElement( + corev1.EnvVar{Name: "RELEASE_VERSION", Value: tc.expectedReleaseVersion}, + )) + + // Check NO_PROXY contains expected hosts + var noProxyEnv *corev1.EnvVar + for i := range packageServerContainer.Env { + if packageServerContainer.Env[i].Name == "NO_PROXY" { + noProxyEnv = &packageServerContainer.Env[i] + break + } + } + g.Expect(noProxyEnv).ToNot(BeNil()) + actualNoProxyHosts := strings.Split(noProxyEnv.Value, ",") + g.Expect(actualNoProxyHosts).To(ConsistOf(tc.expectedNoProxyHosts)) + + // Verify replicas + if tc.expectedReplicas != nil { + g.Expect(deployment.Spec.Replicas).ToNot(BeNil()) + g.Expect(*deployment.Spec.Replicas).To(Equal(*tc.expectedReplicas)) + } else { + // Verify replicas were not overridden from the manifest default + originalDeployment, loadErr := assets.LoadDeploymentManifest(ComponentName) + g.Expect(loadErr).ToNot(HaveOccurred()) + if originalDeployment.Spec.Replicas != nil { + g.Expect(deployment.Spec.Replicas).ToNot(BeNil()) + g.Expect(*deployment.Spec.Replicas).To(Equal(*originalDeployment.Spec.Replicas)) + } else { + g.Expect(deployment.Spec.Replicas).To(BeNil()) + } + } + + // Verify KAS readiness check container is added + if tc.expectedKASReadinessCheck { + kasReadinessContainer := util.FindContainer("kas-readiness-check", deployment.Spec.Template.Spec.Containers) + g.Expect(kasReadinessContainer).ToNot(BeNil(), "KAS readiness check container should be present") + } + }) + } +} From 5b7f6084a16f094aabd8ce53ae35b823a0ed7553 Mon Sep 17 00:00:00 2001 From: Bryan Cox Date: Mon, 13 Apr 2026 08:35:09 -0400 Subject: [PATCH 7/7] test: Add unit tests for v2 infrastructure controller packages Add behavior-driven unit tests for the assets and feature gate v2 controller packages covering manifest loading, deserialization, iteration, and feature gate job adaptation. Co-Authored-By: Claude Opus 4.6 --- .../v2/assets/assets_test.go | 385 ++++++++++++++++++ .../v2/fg/component_test.go | 34 ++ .../hostedcontrolplane/v2/fg/job_test.go | 204 ++++++++++ 3 files changed, 623 insertions(+) create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/assets/assets_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/fg/component_test.go create mode 100644 control-plane-operator/controllers/hostedcontrolplane/v2/fg/job_test.go diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/assets/assets_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/assets/assets_test.go new file mode 100644 index 00000000000..2b9996c4a16 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/assets/assets_test.go @@ -0,0 +1,385 @@ +package assets + +import ( + "testing" + + . "github.com/onsi/gomega" + + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestLoadDeploymentManifest(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + componentName string + validate func(g Gomega, deployment *appsv1.Deployment, err error) + }{ + { + name: "When loading a valid deployment manifest, it should decode successfully", + componentName: "aws-cloud-controller-manager", + validate: func(g Gomega, deployment *appsv1.Deployment, err error) { + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(deployment).ToNot(BeNil()) + g.Expect(deployment.Kind).To(Equal("Deployment")) + g.Expect(deployment.Name).To(Equal("cloud-controller-manager")) + }, + }, + { + name: "When component name does not exist, it should return an error", + componentName: "nonexistent-component", + validate: func(g Gomega, deployment *appsv1.Deployment, err error) { + g.Expect(err).To(HaveOccurred()) + g.Expect(deployment).To(BeNil()) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + deployment, err := LoadDeploymentManifest(tc.componentName) + tc.validate(g, deployment, err) + }) + } +} + +func TestLoadStatefulSetManifest(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + componentName string + validate func(g Gomega, sts *appsv1.StatefulSet, err error) + }{ + { + name: "When loading a valid statefulset manifest, it should decode successfully", + componentName: "etcd", + validate: func(g Gomega, sts *appsv1.StatefulSet, err error) { + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(sts).ToNot(BeNil()) + g.Expect(sts.Kind).To(Equal("StatefulSet")) + g.Expect(sts.Name).To(Equal("etcd")) + }, + }, + { + name: "When component name does not exist, it should return an error", + componentName: "nonexistent-component", + validate: func(g Gomega, sts *appsv1.StatefulSet, err error) { + g.Expect(err).To(HaveOccurred()) + g.Expect(sts).To(BeNil()) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + sts, err := LoadStatefulSetManifest(tc.componentName) + tc.validate(g, sts, err) + }) + } +} + +func TestLoadCronJobManifest(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + componentName string + validate func(g Gomega, cronJob *batchv1.CronJob, err error) + }{ + { + name: "When loading a valid cronjob manifest, it should decode successfully", + componentName: "olm-collect-profiles", + validate: func(g Gomega, cronJob *batchv1.CronJob, err error) { + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(cronJob).ToNot(BeNil()) + g.Expect(cronJob.Kind).To(Equal("CronJob")) + g.Expect(cronJob.Name).To(Equal("olm-collect-profiles")) + }, + }, + { + name: "When component name does not exist, it should return an error", + componentName: "nonexistent-component", + validate: func(g Gomega, cronJob *batchv1.CronJob, err error) { + g.Expect(err).To(HaveOccurred()) + g.Expect(cronJob).To(BeNil()) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + cronJob, err := LoadCronJobManifest(tc.componentName) + tc.validate(g, cronJob, err) + }) + } +} + +func TestLoadJobManifest(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + componentName string + validate func(g Gomega, job *batchv1.Job, err error) + }{ + { + name: "When loading a valid job manifest, it should decode successfully", + componentName: "featuregate-generator", + validate: func(g Gomega, job *batchv1.Job, err error) { + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(job).ToNot(BeNil()) + g.Expect(job.Kind).To(Equal("Job")) + g.Expect(job.Name).To(Equal("featuregate-generator")) + }, + }, + { + name: "When component name does not exist, it should return an error", + componentName: "nonexistent-component", + validate: func(g Gomega, job *batchv1.Job, err error) { + g.Expect(err).To(HaveOccurred()) + g.Expect(job).To(BeNil()) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + job, err := LoadJobManifest(tc.componentName) + tc.validate(g, job, err) + }) + } +} + +func TestLoadManifest(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + componentName string + fileName string + validate func(g Gomega, obj client.Object, gvk *schema.GroupVersionKind, err error) + }{ + { + name: "When loading a service manifest, it should decode successfully", + componentName: "cluster-autoscaler", + fileName: "serviceaccount.yaml", + validate: func(g Gomega, obj client.Object, gvk *schema.GroupVersionKind, err error) { + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(obj).ToNot(BeNil()) + g.Expect(gvk).ToNot(BeNil()) + g.Expect(gvk.Kind).To(Equal("ServiceAccount")) + sa, ok := obj.(*corev1.ServiceAccount) + g.Expect(ok).To(BeTrue()) + g.Expect(sa.Name).To(Equal("cluster-autoscaler")) + }, + }, + { + name: "When loading a role manifest, it should decode successfully", + componentName: "cluster-autoscaler", + fileName: "role.yaml", + validate: func(g Gomega, obj client.Object, gvk *schema.GroupVersionKind, err error) { + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(obj).ToNot(BeNil()) + g.Expect(gvk).ToNot(BeNil()) + g.Expect(gvk.Kind).To(Equal("Role")) + }, + }, + { + name: "When file does not exist, it should return an error", + componentName: "cluster-autoscaler", + fileName: "nonexistent.yaml", + validate: func(g Gomega, obj client.Object, gvk *schema.GroupVersionKind, err error) { + g.Expect(err).To(HaveOccurred()) + g.Expect(obj).To(BeNil()) + g.Expect(gvk).To(BeNil()) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + obj, gvk, err := LoadManifest(tc.componentName, tc.fileName) + tc.validate(g, obj, gvk, err) + }) + } +} + +func TestLoadManifestInto(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + componentName string + fileName string + into client.Object + validate func(g Gomega, obj client.Object, gvk *schema.GroupVersionKind, err error) + }{ + { + name: "When loading into a pre-allocated ServiceAccount, it should populate the object", + componentName: "cluster-autoscaler", + fileName: "serviceaccount.yaml", + into: &corev1.ServiceAccount{}, + validate: func(g Gomega, obj client.Object, gvk *schema.GroupVersionKind, err error) { + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(obj).ToNot(BeNil()) + g.Expect(gvk).ToNot(BeNil()) + g.Expect(gvk.Kind).To(Equal("ServiceAccount")) + sa, ok := obj.(*corev1.ServiceAccount) + g.Expect(ok).To(BeTrue()) + g.Expect(sa.Name).To(Equal("cluster-autoscaler")) + }, + }, + { + name: "When loading into nil, it should create a new object", + componentName: "cluster-autoscaler", + fileName: "serviceaccount.yaml", + into: nil, + validate: func(g Gomega, obj client.Object, gvk *schema.GroupVersionKind, err error) { + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(obj).ToNot(BeNil()) + g.Expect(gvk).ToNot(BeNil()) + g.Expect(gvk.Kind).To(Equal("ServiceAccount")) + }, + }, + { + name: "When file does not exist, it should return an error", + componentName: "cluster-autoscaler", + fileName: "nonexistent.yaml", + into: &corev1.ServiceAccount{}, + validate: func(g Gomega, obj client.Object, gvk *schema.GroupVersionKind, err error) { + g.Expect(err).To(HaveOccurred()) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + obj, gvk, err := LoadManifestInto(tc.componentName, tc.fileName, tc.into) + tc.validate(g, obj, gvk, err) + }) + } +} + +func TestForEachManifest(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + componentName string + validate func(g Gomega, manifestNames []string, err error) + }{ + { + name: "When iterating over cluster-autoscaler manifests, it should skip deployment and call action for others", + componentName: "cluster-autoscaler", + validate: func(g Gomega, manifestNames []string, err error) { + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(manifestNames).To(ContainElement("serviceaccount.yaml")) + g.Expect(manifestNames).To(ContainElement("role.yaml")) + g.Expect(manifestNames).To(ContainElement("rolebinding.yaml")) + g.Expect(manifestNames).To(ContainElement("podmonitor.yaml")) + g.Expect(manifestNames).ToNot(ContainElement("deployment.yaml")) + }, + }, + { + name: "When iterating over etcd manifests, it should skip statefulset and call action for others", + componentName: "etcd", + validate: func(g Gomega, manifestNames []string, err error) { + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(manifestNames).To(ContainElement("service.yaml")) + g.Expect(manifestNames).To(ContainElement("discovery-service.yaml")) + g.Expect(manifestNames).ToNot(ContainElement("statefulset.yaml")) + }, + }, + { + name: "When iterating over openshift-controller-manager manifests, it should skip deployment and call action for others", + componentName: "openshift-controller-manager", + validate: func(g Gomega, manifestNames []string, err error) { + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(manifestNames).To(ContainElement("config.yaml")) + g.Expect(manifestNames).To(ContainElement("service.yaml")) + g.Expect(manifestNames).ToNot(ContainElement("deployment.yaml")) + }, + }, + { + name: "When iterating over featuregate-generator manifests, it should skip job and call action for others", + componentName: "featuregate-generator", + validate: func(g Gomega, manifestNames []string, err error) { + g.Expect(err).ToNot(HaveOccurred()) + // featuregate-generator only has job.yaml, so no other manifests + g.Expect(manifestNames).To(BeEmpty()) + }, + }, + { + name: "When component does not exist, it should return an error", + componentName: "nonexistent-component", + validate: func(g Gomega, manifestNames []string, err error) { + g.Expect(err).To(HaveOccurred()) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + var manifestNames []string + err := ForEachManifest(tc.componentName, func(manifestName string) error { + manifestNames = append(manifestNames, manifestName) + return nil + }) + + tc.validate(g, manifestNames, err) + }) + } +} + +func TestForEachManifestWithActionError(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + expectedErr := &testError{msg: "action failed"} + + err := ForEachManifest("cluster-autoscaler", func(manifestName string) error { + if manifestName == "role.yaml" { + return expectedErr + } + return nil + }) + + g.Expect(err).To(HaveOccurred()) + g.Expect(err).To(Equal(expectedErr)) +} + +type testError struct { + msg string +} + +func (e *testError) Error() string { + return e.msg +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/fg/component_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/fg/component_test.go new file mode 100644 index 00000000000..930d00f6397 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/fg/component_test.go @@ -0,0 +1,34 @@ +package fg + +import ( + "testing" + + . "github.com/onsi/gomega" +) + +func TestIsRequestServing(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + fgg := &FeatureGateGenerator{} + + g.Expect(fgg.IsRequestServing()).To(BeFalse()) +} + +func TestMultiZoneSpread(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + fgg := &FeatureGateGenerator{} + + g.Expect(fgg.MultiZoneSpread()).To(BeFalse()) +} + +func TestNeedsManagementKASAccess(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + fgg := &FeatureGateGenerator{} + + g.Expect(fgg.NeedsManagementKASAccess()).To(BeTrue()) +} diff --git a/control-plane-operator/controllers/hostedcontrolplane/v2/fg/job_test.go b/control-plane-operator/controllers/hostedcontrolplane/v2/fg/job_test.go new file mode 100644 index 00000000000..f0e96bfb7d7 --- /dev/null +++ b/control-plane-operator/controllers/hostedcontrolplane/v2/fg/job_test.go @@ -0,0 +1,204 @@ +package fg + +import ( + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + assets "github.com/openshift/hypershift/control-plane-operator/controllers/hostedcontrolplane/v2/assets" + component "github.com/openshift/hypershift/support/controlplane-component" + "github.com/openshift/hypershift/support/testutil" + "github.com/openshift/hypershift/support/util" + + configv1 "github.com/openshift/api/config/v1" + + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestAdaptJob(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + hcp *hyperv1.HostedControlPlane + validate func(g Gomega, job *batchv1.Job, err error) + }{ + { + name: "When HCP has no feature gate configuration, it should set default environment variables", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Configuration: nil, + }, + }, + validate: func(g Gomega, job *batchv1.Job, err error) { + g.Expect(err).ToNot(HaveOccurred()) + + // Check render-feature-gates init container + renderContainer := util.FindContainer("render-feature-gates", job.Spec.Template.Spec.InitContainers) + g.Expect(renderContainer).ToNot(BeNil()) + + payloadVersionEnv := util.FindEnvVar("PAYLOAD_VERSION", renderContainer.Env) + g.Expect(payloadVersionEnv).ToNot(BeNil()) + g.Expect(payloadVersionEnv.Value).To(Equal(testutil.FakeImageProvider().Version())) + + featureGateEnv := util.FindEnvVar("FEATURE_GATE_YAML", renderContainer.Env) + g.Expect(featureGateEnv).ToNot(BeNil()) + g.Expect(featureGateEnv.Value).To(ContainSubstring("kind: FeatureGate")) + g.Expect(featureGateEnv.Value).To(ContainSubstring("name: cluster")) + + // Check apply container + applyContainer := util.FindContainer("apply", job.Spec.Template.Spec.Containers) + g.Expect(applyContainer).ToNot(BeNil()) + + applyPayloadVersionEnv := util.FindEnvVar("PAYLOAD_VERSION", applyContainer.Env) + g.Expect(applyPayloadVersionEnv).ToNot(BeNil()) + g.Expect(applyPayloadVersionEnv.Value).To(Equal(testutil.FakeImageProvider().Version())) + }, + }, + { + name: "When HCP has feature gate configuration, it should include feature gate spec in YAML", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Configuration: &hyperv1.ClusterConfiguration{ + FeatureGate: &configv1.FeatureGateSpec{ + FeatureGateSelection: configv1.FeatureGateSelection{ + FeatureSet: configv1.TechPreviewNoUpgrade, + }, + }, + }, + }, + }, + validate: func(g Gomega, job *batchv1.Job, err error) { + g.Expect(err).ToNot(HaveOccurred()) + + renderContainer := util.FindContainer("render-feature-gates", job.Spec.Template.Spec.InitContainers) + g.Expect(renderContainer).ToNot(BeNil()) + + featureGateEnv := util.FindEnvVar("FEATURE_GATE_YAML", renderContainer.Env) + g.Expect(featureGateEnv).ToNot(BeNil()) + g.Expect(featureGateEnv.Value).To(ContainSubstring("featureSet: TechPreviewNoUpgrade")) + }, + }, + { + name: "When HCP has custom feature gates, it should include custom feature gates in YAML", + hcp: &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Configuration: &hyperv1.ClusterConfiguration{ + FeatureGate: &configv1.FeatureGateSpec{ + FeatureGateSelection: configv1.FeatureGateSelection{ + FeatureSet: configv1.CustomNoUpgrade, + CustomNoUpgrade: &configv1.CustomFeatureGates{ + Enabled: []configv1.FeatureGateName{ + "CustomFeature1", + "CustomFeature2", + }, + Disabled: []configv1.FeatureGateName{ + "DisabledFeature1", + }, + }, + }, + }, + }, + }, + }, + validate: func(g Gomega, job *batchv1.Job, err error) { + g.Expect(err).ToNot(HaveOccurred()) + + renderContainer := util.FindContainer("render-feature-gates", job.Spec.Template.Spec.InitContainers) + g.Expect(renderContainer).ToNot(BeNil()) + + featureGateEnv := util.FindEnvVar("FEATURE_GATE_YAML", renderContainer.Env) + g.Expect(featureGateEnv).ToNot(BeNil()) + g.Expect(featureGateEnv.Value).To(ContainSubstring("featureSet: CustomNoUpgrade")) + g.Expect(featureGateEnv.Value).To(ContainSubstring("CustomFeature1")) + g.Expect(featureGateEnv.Value).To(ContainSubstring("CustomFeature2")) + g.Expect(featureGateEnv.Value).To(ContainSubstring("DisabledFeature1")) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + job, err := assets.LoadJobManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + cpContext := component.WorkloadContext{ + Context: t.Context(), + HCP: tc.hcp, + UserReleaseImageProvider: testutil.FakeImageProvider(), + } + + err = adaptJob(cpContext, job) + tc.validate(g, job, err) + }) + } +} + +func TestAdaptJobPreservesExistingEnvVars(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + hcp := &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hcp", + Namespace: "test-namespace", + }, + Spec: hyperv1.HostedControlPlaneSpec{}, + } + + job, err := assets.LoadJobManifest(ComponentName) + g.Expect(err).ToNot(HaveOccurred()) + + // Add existing env vars to containers + renderContainer := util.FindContainer("render-feature-gates", job.Spec.Template.Spec.InitContainers) + g.Expect(renderContainer).ToNot(BeNil()) + renderContainer.Env = append(renderContainer.Env, corev1.EnvVar{ + Name: "EXISTING_VAR", + Value: "existing-value", + }) + + applyContainer := util.FindContainer("apply", job.Spec.Template.Spec.Containers) + g.Expect(applyContainer).ToNot(BeNil()) + applyContainer.Env = append(applyContainer.Env, corev1.EnvVar{ + Name: "ANOTHER_EXISTING_VAR", + Value: "another-existing-value", + }) + + cpContext := component.WorkloadContext{ + Context: t.Context(), + HCP: hcp, + UserReleaseImageProvider: testutil.FakeImageProvider(), + } + + err = adaptJob(cpContext, job) + g.Expect(err).ToNot(HaveOccurred()) + + // Check that existing env vars are preserved + renderContainer = util.FindContainer("render-feature-gates", job.Spec.Template.Spec.InitContainers) + existingVar := util.FindEnvVar("EXISTING_VAR", renderContainer.Env) + g.Expect(existingVar).ToNot(BeNil()) + g.Expect(existingVar.Value).To(Equal("existing-value")) + + applyContainer = util.FindContainer("apply", job.Spec.Template.Spec.Containers) + anotherExistingVar := util.FindEnvVar("ANOTHER_EXISTING_VAR", applyContainer.Env) + g.Expect(anotherExistingVar).ToNot(BeNil()) + g.Expect(anotherExistingVar.Value).To(Equal("another-existing-value")) +}