diff --git a/cmd/cluster-authentication-operator-tests-ext/main.go b/cmd/cluster-authentication-operator-tests-ext/main.go index 98af196201..4ec324dde4 100644 --- a/cmd/cluster-authentication-operator-tests-ext/main.go +++ b/cmd/cluster-authentication-operator-tests-ext/main.go @@ -13,6 +13,7 @@ import ( "github.com/openshift/cluster-authentication-operator/pkg/version" _ "github.com/openshift/cluster-authentication-operator/test/e2e" + _ "github.com/openshift/cluster-authentication-operator/test/e2e-component-proxy" _ "github.com/openshift/cluster-authentication-operator/test/e2e-encryption" _ "github.com/openshift/cluster-authentication-operator/test/e2e-encryption-kms" _ "github.com/openshift/cluster-authentication-operator/test/e2e-encryption-perf" @@ -81,7 +82,18 @@ func prepareOperatorTestsRegistry() (*oteextension.Registry, error) { Name: "openshift/cluster-authentication-operator/operator/serial", Parallelism: 1, Qualifiers: []string{ - `name.contains("[Serial]") && (name.contains("[Operator]") || name.contains("[OIDC]") || name.contains("[Templates]") || name.contains("[Tokens]"))`, + `name.contains("[Serial]") && !name.contains("[ComponentProxy]") && (name.contains("[Operator]") || name.contains("[OIDC]") || name.contains("[Templates]") || name.contains("[Tokens]"))`, + }, + }) + + // ClusterStability set to Disruptive: component-proxy tests intentionally + // degrade the authentication operator to validate error handling. + extension.AddSuite(oteextension.Suite{ + Name: "openshift/cluster-authentication-operator/component-proxy/disruptive", + Parallelism: 1, + ClusterStability: oteextension.ClusterStabilityDisruptive, + Qualifiers: []string{ + `name.contains("[ComponentProxy]")`, }, }) diff --git a/pkg/controllers/configobservation/oauth/observe_proxy_trusted_ca_test.go b/pkg/controllers/configobservation/oauth/observe_proxy_trusted_ca_test.go index 1a56abf05a..720d3501b4 100644 --- a/pkg/controllers/configobservation/oauth/observe_proxy_trusted_ca_test.go +++ b/pkg/controllers/configobservation/oauth/observe_proxy_trusted_ca_test.go @@ -9,7 +9,6 @@ import ( "golang.org/x/net/http/httpproxy" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" clocktesting "k8s.io/utils/clock/testing" "github.com/openshift/library-go/pkg/operator/events" @@ -91,7 +90,7 @@ func TestObserveComponentProxyTrustedCA(t *testing.T) { existingConfig: map[string]interface{}{ "oauthConfig": "not-a-map", }, - expected: map[string]interface{}{"oauthConfig": "not-a-map"}, + expected: map[string]interface{}{}, expectErrorContains: "accessor error", }, } @@ -113,9 +112,7 @@ func TestObserveComponentProxyTrustedCA(t *testing.T) { require.Empty(t, errs) } - observedValue, _, _ := unstructured.NestedString(observed, "oauthConfig", "proxyTrustedCA") - expectedValue, _, _ := unstructured.NestedString(tt.expected, "oauthConfig", "proxyTrustedCA") - require.Equal(t, expectedValue, observedValue) + require.Equal(t, tt.expected, observed) recordedEvents := recorder.Events() if tt.expectEvent { diff --git a/pkg/controllers/proxyconfig/proxyconfig_controller.go b/pkg/controllers/proxyconfig/proxyconfig_controller.go index 69c22fa94c..ed820a8c28 100644 --- a/pkg/controllers/proxyconfig/proxyconfig_controller.go +++ b/pkg/controllers/proxyconfig/proxyconfig_controller.go @@ -134,6 +134,7 @@ func (p *proxyConfigChecker) validateIdPConnectivity(ctx context.Context, record idpURLs := extractIdPURLs(oauthConfig) if len(idpURLs) == 0 { + p.lastIdPValidationHash = "" return } diff --git a/pkg/controllers/proxyconfig/proxyconfig_controller_test.go b/pkg/controllers/proxyconfig/proxyconfig_controller_test.go index 1440e6a9aa..714f727b91 100644 --- a/pkg/controllers/proxyconfig/proxyconfig_controller_test.go +++ b/pkg/controllers/proxyconfig/proxyconfig_controller_test.go @@ -350,6 +350,41 @@ func Test_validateIdPConnectivity_hashDedup(t *testing.T) { } }) + t.Run("removing all IdPs clears hash so re-adding triggers validation", func(t *testing.T) { + recorder := events.NewInMemoryRecorder(t.Name(), clocktesting.NewFakePassiveClock(time.Now())) + p := &proxyConfigChecker{ + oauthLister: configv1listers.NewOAuthLister(indexer), + } + + reachable := &http.Client{Transport: &workingHTTPRoundTripper{}} + p.validateIdPConnectivity(context.Background(), recorder, reachable, "http://proxy:3128", "", "") + if p.lastIdPValidationHash == "" { + t.Fatal("hash should be set after successful validation") + } + + noIdPConfig := &configv1.OAuth{ + ObjectMeta: metav1.ObjectMeta{Name: "cluster"}, + Spec: configv1.OAuthSpec{}, + } + noIdPIndexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{}) + if err := noIdPIndexer.Add(noIdPConfig); err != nil { + t.Fatal(err) + } + p.oauthLister = configv1listers.NewOAuthLister(noIdPIndexer) + p.validateIdPConnectivity(context.Background(), recorder, reachable, "http://proxy:3128", "", "") + if p.lastIdPValidationHash != "" { + t.Fatal("hash should be cleared when no IdPs are configured") + } + + p.oauthLister = configv1listers.NewOAuthLister(indexer) + unreachable := &http.Client{Transport: &faultyHTTPRoundTripper{}} + followupRecorder := events.NewInMemoryRecorder(t.Name(), clocktesting.NewFakePassiveClock(time.Now())) + p.validateIdPConnectivity(context.Background(), followupRecorder, unreachable, "http://proxy:3128", "", "") + if len(followupRecorder.Events()) != 1 { + t.Fatalf("expected validation to run after IdPs re-added, got %d events", len(followupRecorder.Events())) + } + }) + t.Run("hash not saved on failure allows retry", func(t *testing.T) { recorder := events.NewInMemoryRecorder(t.Name(), clocktesting.NewFakePassiveClock(time.Now())) p := &proxyConfigChecker{ diff --git a/test/e2e-component-proxy/component_proxy.go b/test/e2e-component-proxy/component_proxy.go new file mode 100644 index 0000000000..4262edc9a0 --- /dev/null +++ b/test/e2e-component-proxy/component_proxy.go @@ -0,0 +1,387 @@ +package component_proxy + +import ( + "context" + "fmt" + "time" + + g "github.com/onsi/ginkgo/v2" + o "github.com/onsi/gomega" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + + configv1 "github.com/openshift/api/config/v1" + "github.com/openshift/api/features" + operatorv1 "github.com/openshift/api/operator/v1" + "github.com/openshift/library-go/pkg/operator/v1helpers" + + test "github.com/openshift/cluster-authentication-operator/test/library" +) + +var _ = g.Describe("[sig-auth] authentication operator", func() { + g.It("[Serial][Operator][ComponentProxy] should validate OIDC IdP through component proxy", func() { + testOIDCIdPThroughComponentProxy(false) + }) + g.It("[Serial][Operator][ComponentProxy] should validate OIDC IdP through component proxy with trustedCA", func() { + testOIDCIdPThroughComponentProxy(true) + }) + g.It("[Serial][Operator][ComponentProxy] should fall back on spec.proxy removal", func() { + testFallbackOnProxyRemoval() + }) + g.It("[Serial][Operator][ComponentProxy] should set Degraded when spec.proxy points to an unreachable proxy", func() { + testDegradedOnBadProxyURL() + }) + g.It("[Serial][Operator][ComponentProxy] should emit IdPEndpointUnreachable warning when IdP is unreachable through proxy", func() { + testWarningOnUnreachableIdP() + }) +}) + +func testOIDCIdPThroughComponentProxy(withTrustedCA bool) { + ctx := context.Background() + t := g.GinkgoTB() + kubeConfig := test.NewClientConfigForTest(t) + + g.By("Creating test clients") + clients := test.NewTestClients(t) + + test.CheckFeatureGateEnabledOrSkip(t, clients.ConfigClient, features.FeatureGateAuthenticationComponentProxy) + + g.By("Waiting for authentication operator to be stable before test") + err := test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Deploying Squid forward proxy") + httpProxyURL, httpsProxyURL, caCertPEM, proxyNamespace, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) + g.DeferCleanup(proxyCleanup) + + var proxyURL string + const trustedCAConfigMapName = "e2e-proxy-ca" + if withTrustedCA { + proxyURL = httpsProxyURL + + g.By("Creating trustedCA ConfigMap in openshift-config") + _, err = clients.KubeClient.CoreV1().ConfigMaps("openshift-config").Create(ctx, &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: trustedCAConfigMapName, + Labels: test.CAOE2ETestLabels(), + }, + Data: map[string]string{ + "ca-bundle.crt": string(caCertPEM), + }, + }, metav1.CreateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + g.DeferCleanup(func() { + g.GinkgoWriter.Println("cleaning up: removing trustedCA ConfigMap") + _ = clients.KubeClient.CoreV1().ConfigMaps("openshift-config").Delete(ctx, trustedCAConfigMapName, metav1.DeleteOptions{}) + }) + } else { + proxyURL = httpProxyURL + } + g.GinkgoWriter.Printf("Squid proxy URL: %s\n", proxyURL) + + g.By("Deploying Keycloak (without registering IdP yet)") + kcSetup := test.DeployKeycloak(t, kubeConfig) + g.DeferCleanup(test.IDPCleanupWrapper(func() { + g.GinkgoWriter.Println("cleaning up: removing Keycloak") + for _, cleanup := range kcSetup.Cleanups { + cleanup() + } + })) + g.GinkgoWriter.Printf("Keycloak issuer URL: %s\n", kcSetup.IssuerURL) + g.GinkgoWriter.Printf("Keycloak namespace: %s\n", kcSetup.Namespace) + + g.By("Deploying NetworkPolicy to restrict Keycloak ingress to proxy namespace only") + networkPolicyCleanup := test.DeployProxyNetworkPolicies(t, clients.KubeClient, proxyNamespace, kcSetup.Namespace) + g.DeferCleanup(func() { + g.GinkgoWriter.Println("cleaning up: removing proxy NetworkPolicies") + networkPolicyCleanup() + }) + + g.By("Setting component-scoped proxy") + operatorAuth, proxyRestore := test.SaveAndRestoreProxyConfig(t, clients.OperatorClient, clients.ConfigClient) + g.DeferCleanup(proxyRestore) + + operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{ + HTTPSProxy: proxyURL, + } + if withTrustedCA { + operatorAuth.Spec.Proxy.TrustedCA = operatorv1.AuthenticationConfigMapReference{Name: trustedCAConfigMapName} + } + _, err = clients.OperatorClient.OperatorV1().Authentications().Update(ctx, operatorAuth, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Registering Keycloak as OIDC IdP (operator discovers it through the proxy)") + idpCleanups := test.AddKeycloakOIDCIdP(t, kubeConfig, kcSetup, false) + g.DeferCleanup(test.IDPCleanupWrapper(func() { + g.GinkgoWriter.Println("cleaning up: removing OIDC IdP") + for _, cleanup := range idpCleanups { + cleanup() + } + })) + + g.By("Verifying operator is Available and not Degraded") + err = test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Verifying OAuth server deployment has proxy env vars and trustedCA volume/mount") + test.VerifyOAuthServerDeploymentProxyConfig(t, clients.KubeClient, "", proxyURL, ".cluster.local,.svc,127.0.0.1,localhost", withTrustedCA) + + if withTrustedCA { + g.By("Verifying trustedCA ConfigMap was synced to openshift-authentication") + test.VerifyTrustedCAConfigMapSynced(t, clients.KubeClient, trustedCAConfigMapName) + } + + g.By("Verifying traffic went through the Squid proxy") + err = test.WaitForSquidProxyTraffic(t, clients.KubeClient, proxyNamespace, 5*time.Minute) + o.Expect(err).NotTo(o.HaveOccurred()) +} + +// No NetworkPolicy is deployed here intentionally: after proxy removal the +// operator must fall back to direct connectivity, so Keycloak must remain +// reachable without a proxy. +func testFallbackOnProxyRemoval() { + ctx := context.Background() + t := g.GinkgoTB() + kubeConfig := test.NewClientConfigForTest(t) + + g.By("Creating test clients") + clients := test.NewTestClients(t) + + test.CheckFeatureGateEnabledOrSkip(t, clients.ConfigClient, features.FeatureGateAuthenticationComponentProxy) + + g.By("Waiting for authentication operator to be stable before test") + err := test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Deploying Squid forward proxy") + httpProxyURL, _, _, _, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) + g.DeferCleanup(proxyCleanup) + g.GinkgoWriter.Printf("Squid proxy URL: %s\n", httpProxyURL) + + g.By("Saving original proxy config and setting component-scoped proxy") + operatorAuth, proxyRestore := test.SaveAndRestoreProxyConfig(t, clients.OperatorClient, clients.ConfigClient) + g.DeferCleanup(proxyRestore) + + operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{ + HTTPSProxy: httpProxyURL, + } + _, err = clients.OperatorClient.OperatorV1().Authentications().Update(ctx, operatorAuth, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Deploying Keycloak and adding OIDC IdP") + _, _, keycloakCleanups := test.AddKeycloakIDP(t, kubeConfig, false) + g.DeferCleanup(test.IDPCleanupWrapper(func() { + g.GinkgoWriter.Println("cleaning up: removing Keycloak and IdP") + for _, cleanup := range keycloakCleanups { + cleanup() + } + })) + + g.By("Verifying operator is stable with proxy configured") + err = test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + // Removing spec.proxy causes the operator to contact Keycloak again. + g.By("Removing spec.proxy from Authentication CR") + operatorAuth, err = clients.OperatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{} + _, err = clients.OperatorClient.OperatorV1().Authentications().Update(ctx, operatorAuth, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Waiting for operator to pick up proxy removal and stabilize") + err = test.WaitForOperatorToPickUpChanges(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Verifying proxy env vars are no longer set on OAuth server deployment") + test.VerifyOAuthServerDeploymentProxyConfig(t, clients.KubeClient, "", "", "", false) +} + +func testDegradedOnBadProxyURL() { + ctx := context.Background() + t := g.GinkgoTB() + kubeConfig := test.NewClientConfigForTest(t) + + g.By("Creating test clients") + clients := test.NewTestClients(t) + + test.CheckFeatureGateEnabledOrSkip(t, clients.ConfigClient, features.FeatureGateAuthenticationComponentProxy) + + g.By("Waiting for authentication operator to be stable before test") + err := test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Deploying Squid forward proxy") + httpProxyURL, _, _, _, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) + g.DeferCleanup(proxyCleanup) + + g.By("Saving original proxy config and setting a working proxy") + operatorAuth, proxyRestore := test.SaveAndRestoreProxyConfig(t, clients.OperatorClient, clients.ConfigClient) + g.DeferCleanup(proxyRestore) + + operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{ + HTTPSProxy: httpProxyURL, + } + _, err = clients.OperatorClient.OperatorV1().Authentications().Update(ctx, operatorAuth, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Deploying Keycloak and adding OIDC IdP") + _, _, keycloakCleanups := test.AddKeycloakIDP(t, kubeConfig, false) + g.DeferCleanup(test.IDPCleanupWrapper(func() { + g.GinkgoWriter.Println("cleaning up: removing Keycloak and IdP") + for _, cleanup := range keycloakCleanups { + cleanup() + } + })) + + g.By("Verifying operator is stable with working proxy") + err = test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Setting spec.proxy.httpsProxy to an unreachable host") + operatorAuth, err = clients.OperatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{ + HTTPSProxy: "http://does-not-exist.invalid:3128", + } + _, err = clients.OperatorClient.OperatorV1().Authentications().Update(ctx, operatorAuth, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Waiting for ProxyConfigControllerDegraded=True on the operator CR") + var lastCondition *operatorv1.OperatorCondition + err = wait.PollUntilContextTimeout(ctx, 10*time.Second, 10*time.Minute, true, func(ctx context.Context) (bool, error) { + config, err := clients.OperatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + if err != nil { + g.GinkgoWriter.Printf("failed to get operator auth: %v\n", err) + return false, nil + } + lastCondition = v1helpers.FindOperatorCondition(config.Status.Conditions, "ProxyConfigControllerDegraded") + return lastCondition != nil && lastCondition.Status == operatorv1.ConditionTrue, nil + }) + o.Expect(err).NotTo(o.HaveOccurred(), "ProxyConfigControllerDegraded never became True") + g.GinkgoWriter.Printf("ProxyConfigControllerDegraded: status=%s reason=%s message=%s\n", lastCondition.Status, lastCondition.Reason, lastCondition.Message) + + g.By("Verifying ClusterOperator authentication is Degraded") + err = test.WaitForClusterOperatorDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) +} + +func testWarningOnUnreachableIdP() { + ctx := context.Background() + t := g.GinkgoTB() + + g.By("Creating test clients") + clients := test.NewTestClients(t) + + test.CheckFeatureGateEnabledOrSkip(t, clients.ConfigClient, features.FeatureGateAuthenticationComponentProxy) + + g.By("Waiting for authentication operator to be stable before test") + err := test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Deploying Squid forward proxy") + httpProxyURL, _, _, proxyNamespace, squidCleanup := test.DeploySquidProxy(t, clients.KubeClient) + g.DeferCleanup(squidCleanup) + proxyURL := httpProxyURL + g.GinkgoWriter.Printf("Squid proxy URL: %s\n", proxyURL) + + g.By("Saving original proxy config for cleanup") + operatorAuth, proxyRestore := test.SaveAndRestoreProxyConfig(t, clients.OperatorClient, clients.ConfigClient) + + const ( + fakeIDPName = "e2e-unreachable-idp" + fakeIDPSecretName = "e2e-unreachable-idp-secret" + ) + + g.DeferCleanup(func() { + g.GinkgoWriter.Println("cleaning up: removing fake IdP from OAuth config") + test.CleanIDPConfigByName(t, clients.ConfigClient.ConfigV1().OAuths(), fakeIDPName) + + g.GinkgoWriter.Println("cleaning up: deleting fake IdP secret") + _ = clients.KubeClient.CoreV1().Secrets("openshift-config").Delete(ctx, fakeIDPSecretName, metav1.DeleteOptions{}) + + proxyRestore() + }) + + g.By("Creating fake IdP client secret in openshift-config") + _, err = clients.KubeClient.CoreV1().Secrets("openshift-config").Create(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: fakeIDPSecretName, + Labels: test.CAOE2ETestLabels(), + }, + Data: map[string][]byte{ + "clientSecret": []byte("fake-secret"), + }, + }, metav1.CreateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Adding fake OpenID IdP to OAuth config") + oauthConfig, err := clients.ConfigClient.ConfigV1().OAuths().Get(ctx, "cluster", metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + oauthCopy := oauthConfig.DeepCopy() + oauthCopy.Spec.IdentityProviders = append(oauthCopy.Spec.IdentityProviders, configv1.IdentityProvider{ + Name: fakeIDPName, + MappingMethod: configv1.MappingMethodClaim, + IdentityProviderConfig: configv1.IdentityProviderConfig{ + Type: configv1.IdentityProviderTypeOpenID, + OpenID: &configv1.OpenIDIdentityProvider{ + ClientID: "fake-client", + ClientSecret: configv1.SecretNameReference{ + Name: fakeIDPSecretName, + }, + Issuer: "https://unreachable-idp.invalid", + Claims: configv1.OpenIDClaims{ + PreferredUsername: []string{"preferred_username"}, + }, + }, + }, + }) + _, err = clients.ConfigClient.ConfigV1().OAuths().Update(ctx, oauthCopy, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Setting component-scoped proxy pointing to the Squid instance") + startTime := time.Now() + operatorAuth, err = clients.OperatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{ + HTTPSProxy: proxyURL, + } + _, err = clients.OperatorClient.OperatorV1().Authentications().Update(ctx, operatorAuth, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Waiting for IdPEndpointUnreachable warning event") + err = wait.PollUntilContextTimeout(ctx, 10*time.Second, 10*time.Minute, true, func(ctx context.Context) (bool, error) { + events, err := clients.KubeClient.CoreV1().Events("openshift-authentication-operator").List(ctx, metav1.ListOptions{ + FieldSelector: "reason=IdPEndpointUnreachable", + }) + if err != nil { + g.GinkgoWriter.Printf("failed to list events: %v\n", err) + return false, nil + } + for _, event := range events.Items { + eventTime := event.LastTimestamp.Time + if eventTime.IsZero() { + eventTime = event.EventTime.Time + } + if event.Type == "Warning" && eventTime.After(startTime) { + g.GinkgoWriter.Printf("found IdPEndpointUnreachable event: %s\n", event.Message) + return true, nil + } + } + return false, nil + }) + o.Expect(err).NotTo(o.HaveOccurred(), "IdPEndpointUnreachable warning event was not emitted") + + g.By("Verifying the request went through the Squid proxy") + err = test.WaitForSquidProxyTraffic(t, clients.KubeClient, proxyNamespace, 2*time.Minute) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Verifying operator is NOT Degraded") + ok, conditions, checkErr := test.CheckClusterOperatorStatus(t, ctx, clients.ConfigClient.ConfigV1(), "authentication", + configv1.ClusterOperatorStatusCondition{Type: configv1.OperatorDegraded, Status: configv1.ConditionFalse}, + ) + o.Expect(checkErr).NotTo(o.HaveOccurred()) + o.Expect(ok).To(o.BeTrue(), fmt.Sprintf("operator should NOT be degraded, conditions: %v", conditions)) +} diff --git a/test/library/client.go b/test/library/client.go index 2397cb2313..c23a41db37 100644 --- a/test/library/client.go +++ b/test/library/client.go @@ -40,6 +40,10 @@ func NewClientConfigForTest(t testing.TB) *rest.Config { } require.NoError(t, err) + + config.QPS = 40 + config.Burst = 60 + return config } diff --git a/test/library/idpdeployment.go b/test/library/idpdeployment.go index 7c6e082b4d..53369425a1 100644 --- a/test/library/idpdeployment.go +++ b/test/library/idpdeployment.go @@ -307,6 +307,15 @@ func addOIDCIDentityProvider( directExternalOIDC bool) ([]func(), error) { var cleanups []func() + success := false + defer func() { + if !success { + for _, c := range cleanups { + c() + } + } + }() + secretName := idpName + "-secret" _, err := kubeClients.CoreV1().Secrets("openshift-config").Create(context.TODO(), &corev1.Secret{ @@ -321,7 +330,7 @@ func addOIDCIDentityProvider( metav1.CreateOptions{}, ) if err != nil { - return cleanups, fmt.Errorf("failed to create keycloak client secret: %v", err) + return nil, fmt.Errorf("failed to create keycloak client secret: %v", err) } cleanups = append(cleanups, func() { if err := kubeClients.CoreV1().Secrets("openshift-config").Delete(context.TODO(), secretName, metav1.DeleteOptions{}); err != nil { @@ -330,7 +339,6 @@ func addOIDCIDentityProvider( }) caCMName := idpName + "-ca" - // configure the default ingress CA as the CA for the IdP in the openshift-config NS cleanups = append(cleanups, SyncDefaultIngressCAToConfig(t, kubeClients.CoreV1(), caCMName)) if !directExternalOIDC { @@ -354,14 +362,14 @@ func addOIDCIDentityProvider( }, }, }) + cleanups = append(cleanups, idpClean...) if err != nil { - return cleanups, fmt.Errorf("failed to add identity provider to oauth server: %v", err) + return nil, fmt.Errorf("failed to add identity provider to oauth server: %v", err) } - - cleanups = append(cleanups, idpClean...) } - return cleanups, err + success = true + return cleanups, nil } func addIdentityProvider(t testing.TB, configClient *configv1client.ConfigV1Client, idp *configv1.IdentityProvider) ([]func(), error) { diff --git a/test/library/keycloakidp.go b/test/library/keycloakidp.go index 8d3b254879..418a14167e 100644 --- a/test/library/keycloakidp.go +++ b/test/library/keycloakidp.go @@ -27,20 +27,28 @@ import ( routev1client "github.com/openshift/client-go/route/clientset/versioned/typed/route/v1" ) -func AddKeycloakIDP( - t testing.TB, - kubeconfig *rest.Config, - directOIDC bool, -) (kcClient *KeycloakClient, idpName string, cleanups []func()) { +// KeycloakSetup holds the results of deploying Keycloak, before the IdP is +// registered in OpenShift. Use AddKeycloakOIDCIdP to register the IdP. +type KeycloakSetup struct { + Client *KeycloakClient + IDPName string + Namespace string + ClientID string + ClientSecret string + IssuerURL string + Cleanups []func() +} + +// DeployKeycloak deploys Keycloak in a test namespace, configures a client and +// group mapper, and returns a KeycloakSetup. The IdP is NOT registered in +// OpenShift — call AddKeycloakOIDCIdP separately when ready. +func DeployKeycloak(t testing.TB, kubeconfig *rest.Config) *KeycloakSetup { kubeClients, err := kubernetes.NewForConfig(kubeconfig) require.NoError(t, err) routeClient, err := routev1client.NewForConfig(kubeconfig) require.NoError(t, err) - configClient, err := configv1client.NewForConfig(kubeconfig) - require.NoError(t, err) - readinessProbe := corev1.Probe{ ProbeHandler: corev1.ProbeHandler{ HTTPGet: &corev1.HTTPGetAction{ @@ -66,7 +74,6 @@ func AddKeycloakIDP( "keycloak", "quay.io/keycloak/keycloak:25.0", []corev1.EnvVar{ - // configure password for Keycloak root user {Name: "KEYCLOAK_ADMIN", Value: "admin"}, {Name: "KEYCLOAK_ADMIN_PASSWORD", Value: "password"}, {Name: "KC_HEALTH_ENABLED", Value: "true"}, @@ -105,10 +112,15 @@ func AddKeycloakIDP( true, "/opt/keycloak/bin/kc.sh", "start-dev", ) - cleanups = []func(){cleanup} + + setup := &KeycloakSetup{ + IDPName: fmt.Sprintf("keycloak-test-%s", nsName), + Namespace: nsName, + Cleanups: []func(){cleanup}, + } defer func() { if err != nil { - for _, c := range cleanups { + for _, c := range setup.Cleanups { c() } } @@ -119,19 +131,13 @@ func AddKeycloakIDP( transport, err := rest.TransportFor(kubeconfig) require.NoError(t, err) - openshiftIDPName := fmt.Sprintf("keycloak-test-%s", nsName) - keycloakURL := keycloakBaseURL + "/realms/master" + setup.IssuerURL = keycloakURL - // create a keycloak REST client and authenticate to the API - kcClient = KeycloakClientFor(t, transport, keycloakURL, "master") + setup.Client = KeycloakClientFor(t, transport, keycloakURL, "master") - // even though configured via env vars and even though we checked Keycloak reports - // ready on /health/ready, it still appears that we may need some time to log in properly - // In resource-constrained CI environments with parallel test execution, Keycloak can take - // 40-60+ seconds to fully initialize its admin API even after passing readiness probes err = wait.PollUntilContextTimeout(context.Background(), 5*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) { - err := kcClient.AuthenticatePassword("admin-cli", "", "admin", "password") + err := setup.Client.AuthenticatePassword("admin-cli", "", "admin", "password") if err != nil { t.Logf("failed to authenticate to Keycloak: %v", err) return false, nil @@ -140,17 +146,16 @@ func AddKeycloakIDP( }) require.NoError(t, err) - clientList, err := kcClient.ListClients() + clientList, err := setup.Client.ListClients() require.NoError(t, err) - var adminClientId, passwdClientId, passwdClientClientId string + var adminClientId, passwdClientId string for _, c := range clientList { if clientID := c["clientId"].(string); clientID == "admin-cli" { adminClientId = c["id"].(string) } else if len(c["redirectUris"].([]interface{})) > 0 { - // just reuse one other client that's already there passwdClientId = c["id"].(string) - passwdClientClientId = clientID + setup.ClientID = clientID } if len(passwdClientId) > 0 && len(adminClientId) > 0 { @@ -158,14 +163,11 @@ func AddKeycloakIDP( } } - // change the client's access token timeout just in case we need it for the test - // Wrap in retry logic as Keycloak may still be unstable after initial authentication err = wait.PollUntilContextTimeout(context.Background(), 5*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) { - err := kcClient.UpdateClientAccessTokenTimeout(adminClientId, 60*30) + err := setup.Client.UpdateClientAccessTokenTimeout(adminClientId, 60*30) if err != nil { t.Logf("failed to update client access token timeout: %v, retrying", err) - // Re-authenticate in case the connection was dropped - if authErr := kcClient.AuthenticatePassword("admin-cli", "", "admin", "password"); authErr != nil { + if authErr := setup.Client.AuthenticatePassword("admin-cli", "", "admin", "password"); authErr != nil { t.Logf("failed to re-authenticate: %v", authErr) } return false, nil @@ -174,19 +176,15 @@ func AddKeycloakIDP( }) require.NoError(t, err) - // reauthenticate for a new, longer-lived token - err = kcClient.AuthenticatePassword("admin-cli", "", "admin", "password") + err = setup.Client.AuthenticatePassword("admin-cli", "", "admin", "password") require.NoError(t, err) - // Regenerate client secret with retry logic for Keycloak stability - var clientSecret string err = wait.PollUntilContextTimeout(context.Background(), 5*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) { var err error - clientSecret, err = kcClient.RegenerateClientSecret(passwdClientId) + setup.ClientSecret, err = setup.Client.RegenerateClientSecret(passwdClientId) if err != nil { t.Logf("failed to regenerate client secret: %v, retrying", err) - // Re-authenticate in case the connection was dropped - if authErr := kcClient.AuthenticatePassword("admin-cli", "", "admin", "password"); authErr != nil { + if authErr := setup.Client.AuthenticatePassword("admin-cli", "", "admin", "password"); authErr != nil { t.Logf("failed to re-authenticate: %v", authErr) } return false, nil @@ -195,14 +193,12 @@ func AddKeycloakIDP( }) require.NoError(t, err) - // Create client group mapper with retry logic const groupsClaimName = "groups" err = wait.PollUntilContextTimeout(context.Background(), 5*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) { - err := kcClient.CreateClientGroupMapper(passwdClientId, "test-groups-mapper", groupsClaimName) + err := setup.Client.CreateClientGroupMapper(passwdClientId, "test-groups-mapper", groupsClaimName) if err != nil { t.Logf("failed to create client group mapper: %v, retrying", err) - // Re-authenticate in case the connection was dropped - if authErr := kcClient.AuthenticatePassword("admin-cli", "", "admin", "password"); authErr != nil { + if authErr := setup.Client.AuthenticatePassword("admin-cli", "", "admin", "password"); authErr != nil { t.Logf("failed to re-authenticate: %v", authErr) } return false, nil @@ -211,22 +207,50 @@ func AddKeycloakIDP( }) require.NoError(t, err) + return setup +} + +// AddKeycloakOIDCIdP registers the Keycloak instance from a KeycloakSetup as an +// OIDC identity provider in OpenShift. When directOIDC is true, secrets and CA +// are created but the IdP is not added to the OAuth config. +func AddKeycloakOIDCIdP(t testing.TB, kubeconfig *rest.Config, setup *KeycloakSetup, directOIDC bool) []func() { + kubeClients, err := kubernetes.NewForConfig(kubeconfig) + require.NoError(t, err) + + configClient, err := configv1client.NewForConfig(kubeconfig) + require.NoError(t, err) + idpCleans, err := addOIDCIDentityProvider(t, kubeClients, configClient, - passwdClientClientId, clientSecret, - openshiftIDPName, - keycloakURL, + setup.ClientID, setup.ClientSecret, + setup.IDPName, + setup.IssuerURL, configv1.OpenIDClaims{ PreferredUsername: []string{"preferred_username"}, - Groups: []configv1.OpenIDClaim{groupsClaimName}, + Groups: []configv1.OpenIDClaim{"groups"}, }, directOIDC, ) - cleanups = append(cleanups, idpCleans...) require.NoError(t, err, "failed to configure the identity provider") - return kcClient, openshiftIDPName, cleanups + return idpCleans +} + +// AddKeycloakIDP deploys Keycloak and registers it as an OIDC IdP in one call. +// This is a convenience wrapper around DeployKeycloak + AddKeycloakOIDCIdP. +func AddKeycloakIDP( + t testing.TB, + kubeconfig *rest.Config, + directOIDC bool, +) (kcClient *KeycloakClient, idpName string, cleanups []func()) { + setup := DeployKeycloak(t, kubeconfig) + cleanups = setup.Cleanups + + idpCleans := AddKeycloakOIDCIdP(t, kubeconfig, setup, directOIDC) + cleanups = append(cleanups, idpCleans...) + + return setup.Client, setup.IDPName, cleanups } type KeycloakClient struct { diff --git a/test/library/proxy.go b/test/library/proxy.go new file mode 100644 index 0000000000..7dee4077a0 --- /dev/null +++ b/test/library/proxy.go @@ -0,0 +1,515 @@ +package library + +import ( + "context" + "crypto/x509" + "encoding/pem" + "fmt" + "reflect" + "strings" + "sync" + "testing" + "time" + + g "github.com/onsi/ginkgo/v2" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/cache" + watchtools "k8s.io/client-go/tools/watch" + "k8s.io/utils/ptr" + + configv1 "github.com/openshift/api/config/v1" + operatorv1 "github.com/openshift/api/operator/v1" + configclient "github.com/openshift/client-go/config/clientset/versioned" + operatorclient "github.com/openshift/client-go/operator/clientset/versioned" +) + +const ( + squidImage = "registry.redhat.io/rhel10/squid:10.2-1784702318" + squidHTTPPort = int32(3128) + squidHTTPSPort = int32(3129) + squidServiceName = "squid-proxy" + + componentProxyCAConfigMapName = "v4-0-config-system-auth-proxy-ca" +) + +// SaveAndRestoreProxyConfig snapshots the current spec.proxy on the operator +// Authentication CR and returns a cleanup function that restores it and waits +// for the operator to reconcile. +func SaveAndRestoreProxyConfig(t testing.TB, operatorClient *operatorclient.Clientset, configClient *configclient.Clientset) (operatorAuth *operatorv1.Authentication, cleanup func()) { + ctx := context.TODO() + + auth, err := operatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + if err != nil { + t.Fatalf("failed to get operator authentication CR: %v", err) + } + originalProxy := auth.Spec.Proxy.DeepCopy() + + return auth, sync.OnceFunc(func() { + t.Log("cleaning up: restoring original proxy config") + var changed bool + err := wait.PollUntilContextTimeout(ctx, 1*time.Second, 30*time.Second, true, func(ctx context.Context) (bool, error) { + fresh, err := operatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + if err != nil { + t.Logf("cleanup: failed to get operator auth: %v", err) + return false, nil + } + target := operatorv1.AuthenticationProxyConfig{} + if originalProxy != nil { + target = *originalProxy + } + if reflect.DeepEqual(fresh.Spec.Proxy, target) { + t.Log("cleanup: proxy config already matches original, no update needed") + return true, nil + } + fresh.Spec.Proxy = target + if _, err := operatorClient.OperatorV1().Authentications().Update(ctx, fresh, metav1.UpdateOptions{}); err != nil { + t.Logf("cleanup: failed to update operator auth (will retry): %v", err) + return false, nil + } + changed = true + return true, nil + }) + if err != nil { + t.Errorf("cleanup: failed to restore proxy config: %v", err) + return + } + if !changed { + return + } + t.Log("cleanup: waiting for operator to pick up changes and stabilize") + if err := WaitForOperatorToPickUpChanges(t, configClient.ConfigV1(), "authentication"); err != nil { + t.Errorf("cleanup: operator did not recover: %v", err) + } + }) +} + +// DeploySquidProxy deploys a Squid forward proxy that listens on both plain +// HTTP (port 3128) and HTTPS (port 3129). It generates a self-signed CA and +// serving certificate internally. Returns the HTTP and HTTPS proxy URLs, +// the PEM-encoded CA certificate (for trustedCA ConfigMaps when using https), +// the namespace name, and a cleanup function. +func DeploySquidProxy(t testing.TB, kubeClient kubernetes.Interface) (httpProxyURL, httpsProxyURL string, caCertPEM []byte, namespace string, cleanup func()) { + ctx := context.TODO() + + namespace = NewTestNamespaceBuilder("e2e-proxy-"). + WithBaselinePSaEnforcement(). + WithLabels(CAOE2ETestLabels()). + Create(t, kubeClient.CoreV1().Namespaces()) + + cleanup = sync.OnceFunc(func() { + g.GinkgoWriter.Println("cleaning up: removing Squid proxy") + if err := kubeClient.CoreV1().Namespaces().Delete(ctx, namespace, metav1.DeleteOptions{}); err != nil { + t.Logf("error cleaning up proxy namespace %q: %v", namespace, err) + } + }) + + success := false + defer func() { + if !success { + cleanup() + } + }() + + ca := NewCertificateAuthorityCertificate(t, nil) + serviceDNS := fmt.Sprintf("%s.%s.svc.cluster.local", squidServiceName, namespace) + serverCert := NewServerCertificate(t, ca, serviceDNS) + + caCertPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ca.Certificate.Raw}) + serverCertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: serverCert.Certificate.Raw}) + + serverKeyDER, err := x509.MarshalPKCS8PrivateKey(serverCert.PrivateKey) + if err != nil { + t.Fatalf("failed to marshal server private key: %v", err) + } + serverKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: serverKeyDER}) + + squidConfig := fmt.Sprintf(`http_port %d +https_port %d tls-cert=/etc/squid/tls/tls.crt tls-key=/etc/squid/tls/tls.key +pid_filename /tmp/squid.pid +acl all src all +http_access allow all +access_log stdio:/dev/stdout +cache_log stdio:/dev/stderr +cache deny all +buffered_logs off +`, squidHTTPPort, squidHTTPSPort) + + _, err = kubeClient.CoreV1().ConfigMaps(namespace).Create(ctx, &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "squid-config"}, + Data: map[string]string{"squid.conf": squidConfig}, + }, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("failed to create squid config: %v", err) + } + + _, err = kubeClient.CoreV1().Secrets(namespace).Create(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "squid-tls"}, + Data: map[string][]byte{ + "tls.crt": serverCertPEM, + "tls.key": serverKeyPEM, + }, + }, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("failed to create squid TLS secret: %v", err) + } + + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: squidServiceName, + Labels: map[string]string{"app": squidServiceName}, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: ptr.To(int32(1)), + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": squidServiceName}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": squidServiceName}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "squid", + Image: squidImage, + Ports: []corev1.ContainerPort{ + {ContainerPort: squidHTTPPort, Protocol: corev1.ProtocolTCP}, + {ContainerPort: squidHTTPSPort, Protocol: corev1.ProtocolTCP}, + }, + VolumeMounts: []corev1.VolumeMount{ + { + Name: "squid-config", + MountPath: "/etc/squid/squid.conf", + SubPath: "squid.conf", + }, + { + Name: "squid-tls", + MountPath: "/etc/squid/tls", + ReadOnly: true, + }, + }, + ReadinessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + TCPSocket: &corev1.TCPSocketAction{ + Port: intstr.FromInt32(squidHTTPPort), + }, + }, + InitialDelaySeconds: 5, + PeriodSeconds: 5, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: "squid-config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "squid-config", + }, + }, + }, + }, + { + Name: "squid-tls", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: "squid-tls", + }, + }, + }, + }, + }, + }, + }, + } + + _, err = kubeClient.AppsV1().Deployments(namespace).Create(ctx, deployment, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("failed to create squid deployment: %v", err) + } + + _, err = kubeClient.CoreV1().Services(namespace).Create(ctx, &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: squidServiceName, + Labels: map[string]string{"app": squidServiceName}, + }, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"app": squidServiceName}, + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: squidHTTPPort, + TargetPort: intstr.FromInt32(squidHTTPPort), + Protocol: corev1.ProtocolTCP, + }, + { + Name: "https", + Port: squidHTTPSPort, + TargetPort: intstr.FromInt32(squidHTTPSPort), + Protocol: corev1.ProtocolTCP, + }, + }, + }, + }, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("failed to create squid service: %v", err) + } + + t.Logf("waiting for squid proxy deployment in %s to be ready", namespace) + timeLimitedCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + _, err = watchtools.UntilWithSync(timeLimitedCtx, + cache.NewListWatchFromClient( + kubeClient.AppsV1().RESTClient(), "deployments", namespace, + fields.OneTermEqualSelector("metadata.name", squidServiceName)), + &appsv1.Deployment{}, + nil, + func(event watch.Event) (bool, error) { + d := event.Object.(*appsv1.Deployment) + return d.Status.ReadyReplicas > 0, nil + }, + ) + if err != nil { + t.Fatalf("squid proxy deployment did not become ready: %v", err) + } + + success = true + + serviceHost := fmt.Sprintf("%s.%s.svc.cluster.local", squidServiceName, namespace) + httpProxyURL = fmt.Sprintf("http://%s:%d", serviceHost, squidHTTPPort) + httpsProxyURL = fmt.Sprintf("https://%s:%d", serviceHost, squidHTTPSPort) + success = true + t.Logf("squid proxy deployed: http=%s https=%s", httpProxyURL, httpsProxyURL) + return httpProxyURL, httpsProxyURL, caCertPEM, namespace, cleanup +} + +// DeployProxyNetworkPolicies creates a NetworkPolicy on the Keycloak namespace +// that restricts ingress to only the proxy namespace. This ensures auth +// components can only reach Keycloak through the proxy. +// +// Note: egress policies on auth namespaces are not created because the +// operator-managed NetworkPolicies already have allow-all egress rules that +// cannot be overridden additively. +func DeployProxyNetworkPolicies(t testing.TB, kubeClient kubernetes.Interface, proxyNamespace, keycloakNamespace string) func() { + ctx := context.TODO() + + keycloakPolicy := &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "proxy-e2e-allow-only-from-proxy", + Namespace: keycloakNamespace, + Labels: CAOE2ETestLabels(), + }, + Spec: networkingv1.NetworkPolicySpec{ + PodSelector: metav1.LabelSelector{}, + PolicyTypes: []networkingv1.PolicyType{networkingv1.PolicyTypeIngress}, + Ingress: []networkingv1.NetworkPolicyIngressRule{ + { + From: []networkingv1.NetworkPolicyPeer{ + { + NamespaceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "kubernetes.io/metadata.name": proxyNamespace, + }, + }, + }, + { + NamespaceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "policy-group.network.openshift.io/ingress": "", + }, + }, + }, + }, + }, + }, + }, + } + + _, err := kubeClient.NetworkingV1().NetworkPolicies(keycloakNamespace).Create(ctx, keycloakPolicy, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("failed to create NetworkPolicy in %s: %v", keycloakNamespace, err) + } + t.Logf("created NetworkPolicy proxy-e2e-allow-only-from-proxy in %s", keycloakNamespace) + + return func() { + if err := kubeClient.NetworkingV1().NetworkPolicies(keycloakNamespace).Delete(ctx, "proxy-e2e-allow-only-from-proxy", metav1.DeleteOptions{}); err != nil { + t.Logf("error cleaning up NetworkPolicy in %s: %v", keycloakNamespace, err) + } + } +} + +// GetSquidProxyLogs reads all Squid access log entries from the proxy pod. +func GetSquidProxyLogs(kubeClient kubernetes.Interface, namespace string) (string, error) { + return GetSquidProxyLogsSince(kubeClient, namespace, time.Time{}) +} + +// GetSquidProxyLogsSince reads the Squid access log from the proxy pod, +// returning only lines with a timestamp not before since. +func GetSquidProxyLogsSince(kubeClient kubernetes.Interface, namespace string, since time.Time) (string, error) { + ctx := context.TODO() + + pods, err := kubeClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: fmt.Sprintf("app=%s", squidServiceName), + }) + if err != nil { + return "", fmt.Errorf("failed to list squid pods in %s: %w", namespace, err) + } + if len(pods.Items) == 0 { + return "", fmt.Errorf("no squid proxy pods found in namespace %s", namespace) + } + + logOpts := &corev1.PodLogOptions{Container: "squid"} + if !since.IsZero() { + t := metav1.NewTime(since) + logOpts.SinceTime = &t + } + logBytes, err := kubeClient.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, logOpts).DoRaw(ctx) + if err != nil { + return "", fmt.Errorf("failed to get logs from squid container: %w", err) + } + + return string(logBytes), nil +} + +// WaitForSquidProxyTraffic polls the Squid proxy logs until it sees CONNECT or +// TCP_ entries, indicating traffic went through the proxy. +func WaitForSquidProxyTraffic(t testing.TB, kubeClient kubernetes.Interface, namespace string, timeout time.Duration) error { + t.Logf("waiting up to %s for traffic in squid proxy logs", timeout) + return wait.PollUntilContextTimeout(context.TODO(), 10*time.Second, timeout, true, func(ctx context.Context) (bool, error) { + logs, err := GetSquidProxyLogs(kubeClient, namespace) + if err != nil { + t.Logf("failed to read squid logs: %v", err) + return false, nil + } + if strings.Contains(logs, "CONNECT") || strings.Contains(logs, "TCP_") { + t.Logf("detected proxy traffic in squid logs") + return true, nil + } + return false, nil + }) +} + +// VerifyOAuthServerDeploymentProxyConfig asserts that the OAuth server +// deployment has the expected proxy env var values and trustedCA volume/mount. +// Proxy env vars are always set; pass empty string to assert an unset proxy. +// When expectTrustedCAVolume is true, the v4-0-config-system-auth-proxy-ca +// volume and mount must exist; when false, they must be absent. +func VerifyOAuthServerDeploymentProxyConfig(t testing.TB, kubeClient kubernetes.Interface, expectedHTTPProxy, expectedHTTPSProxy, expectedNoProxy string, expectTrustedCAVolume bool) { + ctx := context.TODO() + + var deployment *appsv1.Deployment + err := wait.PollUntilContextTimeout(ctx, 10*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) { + var err error + deployment, err = kubeClient.AppsV1().Deployments("openshift-authentication").Get(ctx, "oauth-openshift", metav1.GetOptions{}) + if err != nil { + t.Logf("failed to get oauth-openshift deployment: %v", err) + return false, nil + } + + envVars := make(map[string]string) + for _, container := range deployment.Spec.Template.Spec.Containers { + for _, env := range container.Env { + switch env.Name { + case "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY": + envVars[env.Name] = env.Value + } + } + } + + if envVars["HTTP_PROXY"] != expectedHTTPProxy || envVars["HTTPS_PROXY"] != expectedHTTPSProxy { + return false, nil + } + // Use superset check: the operator may add extra entries to NO_PROXY beyond + // what the caller specifies (e.g. the kubernetes service IP for KUBERNETES_SERVICE_HOST). + actualNoProxy := sets.New[string](strings.Split(envVars["NO_PROXY"], ",")...) + expectedNoProxyEntries := sets.New[string](strings.Split(expectedNoProxy, ",")...) + if !actualNoProxy.IsSuperset(expectedNoProxyEntries) { + return false, nil + } + + if matchTrustedCAVolume(deployment, expectTrustedCAVolume) { + return true, nil + } + return false, nil + }) + if err != nil { + t.Fatalf("OAuth server deployment proxy config did not match expected values within timeout") + } +} + +func matchTrustedCAVolume(deployment *appsv1.Deployment, expectPresent bool) bool { + foundVolume := false + for _, vol := range deployment.Spec.Template.Spec.Volumes { + if vol.ConfigMap != nil && vol.ConfigMap.Name == componentProxyCAConfigMapName { + foundVolume = true + break + } + } + + foundMount := false + for _, container := range deployment.Spec.Template.Spec.Containers { + for _, mount := range container.VolumeMounts { + if mount.Name == componentProxyCAConfigMapName { + foundMount = true + break + } + } + } + + if expectPresent { + return foundVolume && foundMount + } + return !foundVolume && !foundMount +} + +// VerifyTrustedCAConfigMapSynced checks that the trustedCA ConfigMap has been +// synced to the openshift-authentication namespace under the operator's +// hardcoded name (v4-0-config-system-auth-proxy-ca). +func VerifyTrustedCAConfigMapSynced(t testing.TB, kubeClient kubernetes.Interface, configMapName string) { + ctx := context.TODO() + + err := wait.PollUntilContextTimeout(ctx, 10*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) { + cm, err := kubeClient.CoreV1().ConfigMaps("openshift-authentication").Get(ctx, componentProxyCAConfigMapName, metav1.GetOptions{}) + if err != nil { + return false, nil + } + return len(cm.Data) > 0, nil + }) + if err != nil { + t.Fatalf("trustedCA ConfigMap %s was not synced to openshift-authentication as %s within timeout", configMapName, componentProxyCAConfigMapName) + } +} + +// CheckFeatureGateEnabledOrSkip skips the test if the given feature gate is not enabled. +func CheckFeatureGateEnabledOrSkip(t testing.TB, configClient *configclient.Clientset, featureGateName configv1.FeatureGateName) { + ctx := context.TODO() + + featureGates, err := configClient.ConfigV1().FeatureGates().Get(ctx, "cluster", metav1.GetOptions{}) + if err != nil { + t.Fatalf("failed to get feature gates: %v", err) + } + + if len(featureGates.Status.FeatureGates) != 1 { + t.Fatalf("multiple feature gate versions detected — cluster may be upgrading") + } + + for _, gate := range featureGates.Status.FeatureGates[0].Enabled { + if gate.Name == featureGateName { + t.Logf("feature gate %s is enabled", featureGateName) + return + } + } + + t.Skipf("skipping: feature gate %s is not enabled", featureGateName) +}