From 9ea022c940285a6c7b3bb7ccd91035def09a58e0 Mon Sep 17 00:00:00 2001 From: Jayendra Parsai Date: Fri, 27 Mar 2026 12:02:20 +0530 Subject: [PATCH 1/3] chore: add e2e tests for argocd-agent web-based terminal feature Assisted by: Cursor Signed-off-by: Jayendra Parsai --- go.mod | 2 +- .../e2e/ginkgo/fixture/agent/fixture.go | 10 + .../ginkgo/fixture/argocdclient/fixture.go | 283 +++++++++++++++ ...e_argocd_agent_principal_connected_test.go | 16 +- ...te_argocd_agent_terminal_streaming_test.go | 339 ++++++++++++++++++ 5 files changed, 645 insertions(+), 5 deletions(-) create mode 100644 test/openshift/e2e/ginkgo/fixture/argocdclient/fixture.go create mode 100644 test/openshift/e2e/ginkgo/sequential/1-054_validate_argocd_agent_terminal_streaming_test.go diff --git a/go.mod b/go.mod index f8c7f516d7f..827bbd6591b 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/go-logr/logr v1.4.3 github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.1-0.20241114170450-2d3c2a9cc518 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/hashicorp/go-version v1.7.0 github.com/onsi/ginkgo/v2 v2.28.1 github.com/onsi/gomega v1.39.1 @@ -98,7 +99,6 @@ require ( github.com/google/go-github/v75 v75.0.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect - github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect diff --git a/test/openshift/e2e/ginkgo/fixture/agent/fixture.go b/test/openshift/e2e/ginkgo/fixture/agent/fixture.go index 60a417231fc..7a3b988bf53 100644 --- a/test/openshift/e2e/ginkgo/fixture/agent/fixture.go +++ b/test/openshift/e2e/ginkgo/fixture/agent/fixture.go @@ -568,3 +568,13 @@ func buildDefaultSANs(serviceName, namespace string) []string { fmt.Sprintf("%s.%s.svc.cluster.local", serviceName, namespace), } } + +// GetInitialAdminSecretPassword reads the admin password from the ArgoCD instance's cluster secret +func GetInitialAdminSecretPassword(argocdCRName, secretNS string, k8sClient client.Client) string { + secret := &corev1.Secret{} + Expect(k8sClient.Get(context.Background(), types.NamespacedName{ + Name: fmt.Sprintf("%s-cluster", argocdCRName), + Namespace: secretNS, + }, secret)).To(Succeed()) + return string(secret.Data["admin.password"]) +} diff --git a/test/openshift/e2e/ginkgo/fixture/argocdclient/fixture.go b/test/openshift/e2e/ginkgo/fixture/argocdclient/fixture.go new file mode 100644 index 00000000000..98101a39d2e --- /dev/null +++ b/test/openshift/e2e/ginkgo/fixture/argocdclient/fixture.go @@ -0,0 +1,283 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package argocdclient + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1" + "github.com/gorilla/websocket" +) + +type ArgoRestClient struct { + endpoint string + username string + password string + token string + client *http.Client +} + +// NewArgoClient returns a new client for Argo CD's REST API +func NewArgoClient(endpoint, username, password string) *ArgoRestClient { + ac := &ArgoRestClient{ + endpoint: endpoint, + username: username, + password: password, + client: &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, // #nosec G402 + }, + }, + }, + } + return ac +} + +// Login creates a new Argo CD session +func (c *ArgoRestClient) Login() error { + // Get session token from API + authStr := fmt.Sprintf(`{"username": "%s", "password": "%s"}`, c.username, c.password) + payload := io.NopCloser(bytes.NewReader([]byte(authStr))) + res, err := c.client.Do(&http.Request{ + Method: http.MethodPost, + URL: &url.URL{Scheme: "https", Host: c.endpoint, Path: "/api/v1/session"}, + Body: payload, + Header: http.Header{"Content-Type": []string{"application/json"}}, + ContentLength: int64(len(authStr)), + }) + if err != nil { + return err + } + defer func() { + _ = res.Body.Close() + }() + if res.StatusCode != 200 { + return fmt.Errorf("expected HTTP 200, got %d", res.StatusCode) + } + body, err := io.ReadAll(res.Body) + if err != nil { + return err + } + + type tokenResponse struct { + Token string `json:"token"` + } + token := &tokenResponse{} + err = json.Unmarshal(body, token) + if err != nil { + return err + } + if token.Token == "" { + return errors.New("empty token received") + } + c.token = token.Token + return nil +} + +// TerminalClient represents a test client for terminal WebSocket connections. +type TerminalClient struct { + wsConn *websocket.Conn + mu sync.Mutex + closed bool + output strings.Builder + outputMu sync.Mutex +} + +// ExecTerminal opens a terminal session to a pod via WebSocket. +// This replicates the behavior of the ArgoCD UI when a user opens a terminal session to an application. +// ArgoCD decides which shell to use based on the configured allowed shells. +func (c *ArgoRestClient) ExecTerminal(app *v1alpha1.Application, namespace, podName, container string) (*TerminalClient, error) { + if err := c.ensureToken(); err != nil { + return nil, err + } + + // Build the exec URL + u := &url.URL{ + Scheme: "wss", + Host: c.endpoint, + Path: "/terminal", + } + + q := u.Query() + q.Set("pod", podName) + q.Set("container", container) + q.Set("appName", app.Name) + q.Set("appNamespace", app.Namespace) + q.Set("projectName", app.Spec.Project) + q.Set("namespace", namespace) + u.RawQuery = q.Encode() + + // Create WebSocket dialer with TLS config + dialer := websocket.Dialer{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, // #nosec G402 + }, + } + + // Set token as cookie - ArgoCD expects auth token in argocd.token cookie + headers := http.Header{} + headers.Set("Cookie", fmt.Sprintf("argocd.token=%s", c.token)) + + // Connect to WebSocket + wsConn, resp, err := dialer.Dial(u.String(), headers) + if err != nil { + if resp != nil { + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("failed to connect to terminal WebSocket: %w (status: %d, body: %s)", err, resp.StatusCode, string(body)) + } + return nil, fmt.Errorf("failed to connect to terminal WebSocket: %w", err) + } + + session := &TerminalClient{ + wsConn: wsConn, + } + + // Start reading output in background + go session.readOutput() + + return session, nil +} + +// ensureToken makes sure we have a valid authentication token +func (c *ArgoRestClient) ensureToken() error { + if c.token == "" { + return c.Login() + } + return nil +} + +// terminalMessage is the JSON message format used by ArgoCD terminal WebSocket +type terminalMessage struct { + Operation string `json:"operation"` + Data string `json:"data"` + Rows uint16 `json:"rows"` + Cols uint16 `json:"cols"` +} + +// readOutput continuously reads output from the WebSocket connection +func (s *TerminalClient) readOutput() { + for { + _, message, err := s.wsConn.ReadMessage() + if err != nil { + // Connection closed or error + return + } + + if len(message) < 1 { + continue + } + + // Parse JSON message + var msg terminalMessage + if err := json.Unmarshal(message, &msg); err != nil { + continue + } + + switch msg.Operation { + case "stdout": + s.outputMu.Lock() + s.output.WriteString(msg.Data) + s.outputMu.Unlock() + } + } +} + +// SendInput sends input to the terminal session +func (s *TerminalClient) SendInput(input string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.closed { + return errors.New("session is closed") + } + + // ArgoCD terminal uses JSON messages (includes rows/cols like the UI) + msg, err := json.Marshal(terminalMessage{ + Operation: "stdin", + Data: input, + Rows: 24, + Cols: 80, + }) + if err != nil { + return err + } + return s.wsConn.WriteMessage(websocket.TextMessage, msg) +} + +// SendResize sends a terminal resize message +func (s *TerminalClient) SendResize(cols, rows uint16) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.closed { + return errors.New("session is closed") + } + + // ArgoCD terminal uses JSON messages + msg, err := json.Marshal(terminalMessage{ + Operation: "resize", + Cols: cols, + Rows: rows, + }) + if err != nil { + return err + } + return s.wsConn.WriteMessage(websocket.TextMessage, msg) +} + +// GetOutput returns all captured output so far +func (s *TerminalClient) GetOutput() string { + s.outputMu.Lock() + defer s.outputMu.Unlock() + return s.output.String() +} + +// WaitForOutput waits until the output contains the expected string or timeout +func (s *TerminalClient) WaitForOutput(expected string, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if strings.Contains(s.GetOutput(), expected) { + return true + } + time.Sleep(100 * time.Millisecond) + } + return false +} + +// Close closes the terminal session +func (s *TerminalClient) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.closed { + return nil + } + s.closed = true + return s.wsConn.Close() +} diff --git a/test/openshift/e2e/ginkgo/sequential/1-053_validate_argocd_agent_principal_connected_test.go b/test/openshift/e2e/ginkgo/sequential/1-053_validate_argocd_agent_principal_connected_test.go index 2a0990e0391..fe560bca2cb 100644 --- a/test/openshift/e2e/ginkgo/sequential/1-053_validate_argocd_agent_principal_connected_test.go +++ b/test/openshift/e2e/ginkgo/sequential/1-053_validate_argocd_agent_principal_connected_test.go @@ -609,7 +609,7 @@ var _ = Describe("GitOps Operator Sequential E2E Tests", func() { // This function deploys the principal ArgoCD instance and waits for it to be ready. // It creates the required secrets for the principal and verifies that the principal deployment is in Ready state. // It also verifies that the principal logs contain the expected messages. -func deployPrincipal(ctx context.Context, k8sClient client.Client, registerCleanup func(func())) { +func deployPrincipal(ctx context.Context, k8sClient client.Client, registerCleanup func(func()), enableServerRoute ...bool) { GinkgoHelper() nsPrincipal, cleanup := fixture.CreateNamespaceWithCleanupFunc(namespaceAgentPrincipal) @@ -624,6 +624,12 @@ func deployPrincipal(ctx context.Context, k8sClient client.Client, registerClean waitForLoadBalancer = false } + if len(enableServerRoute) > 0 && enableServerRoute[0] { + argoCDInstance.Spec.Server.Route = argov1beta1api.ArgoCDRouteSpec{ + Enabled: true, + } + } + Expect(k8sClient.Create(ctx, argoCDInstance)).To(Succeed()) By("Wait for principal service to be ready and use LoadBalancer hostname/IP when available") @@ -678,7 +684,7 @@ func deployPrincipal(ctx context.Context, k8sClient client.Client, registerClean Eventually(&appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{ Name: deploymentNameAgentPrincipal, - Namespace: nsPrincipal.Name}}, "120s", "5s").Should(deploymentFixture.HaveReadyReplicas(1)) + Namespace: nsPrincipal.Name}}, "240s", "5s").Should(deploymentFixture.HaveReadyReplicas(1)) By("Verify principal logs contain expected messages") @@ -770,7 +776,8 @@ func buildArgoCDResource(argoCDName string, componentType argov1beta1api.AgentCo Enabled: ptr.To(true), Auth: "mtls:CN=([^,]+)", LogLevel: "info", - Image: common.ArgoCDAgentPrincipalDefaultImageName, + // TODO: Use the argocd-agent image once it is released + Image: "quay.io/jparsai/argocd-agent:1.20.1", Namespace: &argov1beta1api.PrincipalNamespaceSpec{ AllowedNamespaces: []string{ managedAgentClusterName, @@ -816,7 +823,8 @@ func buildArgoCDResource(argoCDName string, componentType argov1beta1api.AgentCo Enabled: ptr.To(true), Creds: "mtls:any", LogLevel: "info", - Image: common.ArgoCDAgentAgentDefaultImageName, + // TODO: Use the argocd-agent image once it is released + Image: "quay.io/jparsai/argocd-agent:1.20.1", Client: &argov1beta1api.AgentClientSpec{ PrincipalServerAddress: "", // will be set in the test PrincipalServerPort: "443", diff --git a/test/openshift/e2e/ginkgo/sequential/1-054_validate_argocd_agent_terminal_streaming_test.go b/test/openshift/e2e/ginkgo/sequential/1-054_validate_argocd_agent_terminal_streaming_test.go new file mode 100644 index 00000000000..fa998d629fa --- /dev/null +++ b/test/openshift/e2e/ginkgo/sequential/1-054_validate_argocd_agent_terminal_streaming_test.go @@ -0,0 +1,339 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package sequential + +import ( + "context" + "fmt" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/redhat-developer/gitops-operator/test/openshift/e2e/ginkgo/fixture" + agentFixture "github.com/redhat-developer/gitops-operator/test/openshift/e2e/ginkgo/fixture/agent" + appFixture "github.com/redhat-developer/gitops-operator/test/openshift/e2e/ginkgo/fixture/application" + argocdClient "github.com/redhat-developer/gitops-operator/test/openshift/e2e/ginkgo/fixture/argocdclient" + deploymentFixture "github.com/redhat-developer/gitops-operator/test/openshift/e2e/ginkgo/fixture/deployment" + k8sFixture "github.com/redhat-developer/gitops-operator/test/openshift/e2e/ginkgo/fixture/k8s" + routeFixture "github.com/redhat-developer/gitops-operator/test/openshift/e2e/ginkgo/fixture/route" + fixtureUtils "github.com/redhat-developer/gitops-operator/test/openshift/e2e/ginkgo/fixture/utils" + + argocdv1alpha1 "github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1" + "github.com/argoproj/gitops-engine/pkg/health" + + argov1beta1api "github.com/argoproj-labs/argocd-operator/api/v1beta1" + + routev1 "github.com/openshift/api/route/v1" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// This test validates that terminal streaming works through the ArgoCD agent architecture +// on OpenShift. It exercises the full terminal flow: +var _ = Describe("GitOps Operator Sequential E2E Tests", func() { + Context("1-054_validate_argocd_agent_terminal_streaming", func() { + var ( + k8sClient client.Client + ctx context.Context + cleanupFuncs []func() + registerCleanup func(func()) + + clusterRolePrincipal *rbacv1.ClusterRole + clusterRoleBindingPrincipal *rbacv1.ClusterRoleBinding + clusterRoleManagedAgent *rbacv1.ClusterRole + clusterRoleBindingManagedAgent *rbacv1.ClusterRoleBinding + adminCRBManagedAgent *rbacv1.ClusterRoleBinding + adminCRBAgentAgent *rbacv1.ClusterRoleBinding + ) + + BeforeEach(func() { + if !fixture.EnvLocalRun() { + fixture.EnsureSequentialCleanSlate() + fixture.SetEnvInOperatorSubscriptionOrDeployment("ARGOCD_CLUSTER_CONFIG_NAMESPACES", + "openshift-gitops, "+namespaceAgentPrincipal+", "+namespaceManagedAgent) + } + + k8sClient, _ = fixtureUtils.GetE2ETestKubeClient() + ctx = context.Background() + cleanupFuncs = nil + registerCleanup = func(fn func()) { + if fn != nil { + cleanupFuncs = append(cleanupFuncs, fn) + } + } + + // create required cluster roles and cluster role bindings for the test + adminCRBManagedAgent = &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-admin-crb", namespaceManagedAgent), + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: rbacv1.GroupName, + Kind: "ClusterRole", + Name: "admin", + }, + Subjects: []rbacv1.Subject{ + { + Kind: rbacv1.ServiceAccountKind, + Name: fmt.Sprintf("%s-argocd-application-controller", argoCDAgentInstanceNameAgent), + Namespace: namespaceManagedAgent, + }, + }, + } + Expect(k8sClient.Create(ctx, adminCRBManagedAgent)).To(Succeed()) + + adminCRBAgentAgent = &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-agent-agent-admin-crb", namespaceManagedAgent), + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: rbacv1.GroupName, + Kind: "ClusterRole", + Name: "admin", + }, + Subjects: []rbacv1.Subject{ + { + Kind: rbacv1.ServiceAccountKind, + Name: fmt.Sprintf("%s-agent-agent", argoCDAgentInstanceNameAgent), + Namespace: namespaceManagedAgent, + }, + }, + } + Expect(k8sClient.Create(ctx, adminCRBAgentAgent)).To(Succeed()) + + clusterRolePrincipal = &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-%s-agent-principal", argoCDAgentInstanceNamePrincipal, namespaceAgentPrincipal), + }, + } + + clusterRoleBindingPrincipal = &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-%s-agent-principal", argoCDAgentInstanceNamePrincipal, namespaceAgentPrincipal), + }, + } + + clusterRoleManagedAgent = &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-%s-agent-agent", argoCDAgentInstanceNameAgent, namespaceManagedAgent), + }, + } + clusterRoleBindingManagedAgent = &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-%s-agent-agent", argoCDAgentInstanceNameAgent, namespaceManagedAgent), + }, + } + + // create required namespaces for the test + _, cleanupFuncClusterManaged := fixture.CreateNamespaceWithCleanupFunc(managedAgentClusterName) + registerCleanup(cleanupFuncClusterManaged) + + _, cleanupFuncClusterAutonomous := fixture.CreateNamespaceWithCleanupFunc(autonomousAgentClusterName) + registerCleanup(cleanupFuncClusterAutonomous) + + _, cleanupFuncManagedApplication := fixture.CreateClusterScopedManagedNamespaceWithCleanupFunc( + managedAgentApplicationNamespace, argoCDAgentInstanceNameAgent) + registerCleanup(cleanupFuncManagedApplication) + }) + + It("Should open a terminal session to a pod deployed via ArgoCD agent and execute commands", func() { + + By("Deploy principal with server route enabled and verify it starts successfully") + deployPrincipal(ctx, k8sClient, registerCleanup, true) + + By("Enable exec feature in ArgoCD server configuration") + enableExecInArgoCD(ctx, k8sClient, argoCDAgentInstanceNamePrincipal, namespaceAgentPrincipal) + + By("Wait for ArgoCD server to restart with exec enabled") + Eventually(&appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-server", argoCDAgentInstanceNamePrincipal), + Namespace: namespaceAgentPrincipal, + }}, "120s", "5s").Should(deploymentFixture.HaveReadyReplicas(1)) + + By("Deploy managed agent and verify it starts successfully") + deployAgent(ctx, k8sClient, registerCleanup, argov1beta1api.AgentModeManaged) + + By("Wait for agent repo-server to be ready before creating applications") + Eventually(&appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-repo-server", argoCDAgentInstanceNameAgent), + Namespace: namespaceManagedAgent, + }}, "180s", "5s").Should(deploymentFixture.HaveReadyReplicas(1)) + + By("Verify principal is connected to managed agent") + agentFixture.VerifyLogs(deploymentNameAgentPrincipal, namespaceAgentPrincipal, []string{ + fmt.Sprintf("Mapped cluster %s to agent %s", managedAgentClusterName, managedAgentClusterName), + fmt.Sprintf("Updated connection status to 'Successful' in Cluster: '%s' mapped with Agent: '%s'", + managedAgentClusterName, managedAgentClusterName), + }) + + By("Create AppProject for managed agent in " + namespaceAgentPrincipal) + Expect(k8sClient.Create(ctx, buildAppProjectResource(namespaceAgentPrincipal, argov1beta1api.AgentModeManaged))).To(Succeed()) + + application := buildApplicationResource("terminal-app", + managedAgentClusterName, managedAgentClusterName, argoCDAgentInstanceNameAgent, argov1beta1api.AgentModeManaged) + + By("Deploy application for terminal testing") + Expect(k8sClient.Create(ctx, application)).To(Succeed()) + + By("Verify application is synced and healthy") + Eventually(application, "180s", "5s").Should(appFixture.HaveSyncStatusCode(argocdv1alpha1.SyncStatusCodeSynced), + "Application should be synced") + Eventually(application, "180s", "5s").Should(appFixture.HaveHealthStatusCode(health.HealthStatusHealthy), + "Application should be healthy") + + By("Wait for ArgoCD server Route to be created") + serverRoute := &routev1.Route{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-server", argoCDAgentInstanceNamePrincipal), + Namespace: namespaceAgentPrincipal, + }, + } + Eventually(serverRoute, "120s", "5s").Should(k8sFixture.ExistByName()) + Eventually(serverRoute, "120s", "5s").Should(routeFixture.HaveAdmittedIngress()) + + // Create ArgoCD client using the ArgoCD server Route and admin password. + // ArgoCD Client is acting as a browser and trying to open a terminal session + // to the application in the managed-cluster. + By("Get ArgoCD admin password and login via Route") + argoEndpoint := serverRoute.Spec.Host + GinkgoWriter.Printf("ArgoCD server Route host: %s\n", argoEndpoint) + + password := agentFixture.GetInitialAdminSecretPassword(argoCDAgentInstanceNamePrincipal, namespaceAgentPrincipal, k8sClient) + argoClient := argocdClient.NewArgoClient(argoEndpoint, "admin", password) + Expect(argoClient.Login()).To(Succeed()) + + // Find the application pod which we want to open a terminal session for. + By("Find a running pod in the application namespace") + var podName, containerName string + Eventually(func() bool { + pods := &corev1.PodList{} + if err := k8sClient.List(ctx, pods, client.InNamespace(managedAgentApplicationNamespace)); err != nil { + GinkgoWriter.Println("Failed to list pods:", err) + return false + } + for _, p := range pods.Items { + if strings.Contains(p.Name, "spring-petclinic") && + p.Status.Phase == corev1.PodRunning && len(p.Spec.Containers) > 0 { + podName = p.Name + containerName = p.Spec.Containers[0].Name + return true + } + } + return false + }, "60s", "5s").Should(BeTrue(), "expected a running spring-petclinic pod in %s", managedAgentApplicationNamespace) + + // Open a terminal session with ArgoCD Server API. + // This replicates the behavior of the ArgoCD UI when a user opens a terminal + // session to an application. This is done by sending a resize message to the + // shell and then sending commands to the shell. The shell will execute the + // command and stream the output back to the principal. The principal will then + // stream the output back to the UI. + GinkgoWriter.Printf("Opening terminal session to pod %s, container %s\n", podName, containerName) + + // We use WebSoket for Test to ArgoCD Server communication. + // Then internally agent will first try Web-socket to pods/exec + // and if that fails, it will fallback to SPDY. + By("Open terminal session via ArgoCD WebSocket API") + var session *argocdClient.TerminalClient + Eventually(func() error { + var err error + session, err = argoClient.ExecTerminal(application, managedAgentApplicationNamespace, podName, containerName) + return err + }, "30s", "5s").Should(Succeed(), "failed to open terminal session") + defer session.Close() + + // Send a resize message first, this is required by the shell to render the + // output content accordingly. + err := session.SendResize(80, 24) + Expect(err).ToNot(HaveOccurred(), "failed to send resize") + + // Wait for shell to initialize by checking for any output + Eventually(func() bool { + return len(session.GetOutput()) > 0 + }, 10*time.Second, 1*time.Second).Should(BeTrue(), "shell did not initialize") + + // Test 1: Run 'pwd' command + err = session.SendInput("pwd; echo PWD_DONE\n") + Expect(err).ToNot(HaveOccurred(), "failed to send pwd command") + found := session.WaitForOutput("PWD_DONE", 10*time.Second) + Expect(found).To(BeTrue(), "expected to find 'PWD_DONE' in pwd output, got: %s", session.GetOutput()) + GinkgoWriter.Println("Test 1 passed: pwd command executed successfully") + + // Test 2: Run 'whoami' command + err = session.SendInput("whoami; echo WHOAMI_DONE\n") + Expect(err).ToNot(HaveOccurred(), "failed to send whoami command") + found = session.WaitForOutput("whoami", 10*time.Second) + Expect(found).To(BeTrue(), "expected whoami output in terminal, got: %s", session.GetOutput()) + GinkgoWriter.Println("Test 2 passed: whoami command executed successfully") + + // Test 3: Run 'ls' command - should list files + err = session.SendInput("ls; echo LS_DONE\n") + Expect(err).ToNot(HaveOccurred(), "failed to send ls command") + found = session.WaitForOutput("LS_DONE", 10*time.Second) + Expect(found).To(BeTrue(), "expected to find 'LS_DONE' in ls output, got: %s", session.GetOutput()) + GinkgoWriter.Println("Test 3 passed: ls command executed successfully") + + GinkgoWriter.Println("All terminal commands executed successfully.") + }) + + AfterEach(func() { + fixture.OutputDebugOnFail(namespaceAgentPrincipal, namespaceManagedAgent, managedAgentClusterName, managedAgentApplicationNamespace) + + By("Cleanup cluster-scoped resources") + _ = k8sClient.Delete(ctx, clusterRolePrincipal) + _ = k8sClient.Delete(ctx, clusterRoleBindingPrincipal) + _ = k8sClient.Delete(ctx, clusterRoleManagedAgent) + _ = k8sClient.Delete(ctx, clusterRoleBindingManagedAgent) + _ = k8sClient.Delete(ctx, adminCRBManagedAgent) + _ = k8sClient.Delete(ctx, adminCRBAgentAgent) + + By("Cleanup namespaces created in this test") + for i := len(cleanupFuncs) - 1; i >= 0; i-- { + cleanupFuncs[i]() + } + }) + }) +}) + +// enableExecInArgoCD configures the ArgoCD CR to enable the web-based terminal. +// through spec.extraConfig and grant the admin role exec permission via spec.rbac.policy. +func enableExecInArgoCD(ctx context.Context, k8sClient client.Client, argocdName, namespace string) { + GinkgoHelper() + + argoCD := &argov1beta1api.ArgoCD{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: argocdName, + Namespace: namespace, + }, argoCD)).To(Succeed()) + + if argoCD.Spec.ExtraConfig == nil { + argoCD.Spec.ExtraConfig = map[string]string{} + } + argoCD.Spec.ExtraConfig["exec.enabled"] = "true" + argoCD.Spec.ExtraConfig["exec.shells"] = "bash,sh,ash,/bin/bash,/bin/sh,/bin/ash" + + execPolicy := "p, role:admin, exec, create, */*, allow" + argoCD.Spec.RBAC.Policy = &execPolicy + + Expect(k8sClient.Update(ctx, argoCD)).To(Succeed()) +} From a7e622a7645fa8ac0e7fd0247e55d4412fc8f526 Mon Sep 17 00:00:00 2001 From: Jayendra Parsai Date: Mon, 30 Mar 2026 22:28:32 +0530 Subject: [PATCH 2/3] chore: increase test timeout Signed-off-by: Jayendra Parsai --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 4e8cf4cf108..3ba34266384 100644 --- a/Makefile +++ b/Makefile @@ -157,7 +157,7 @@ e2e-tests-ginkgo: e2e-tests-sequential-ginkgo e2e-tests-parallel-ginkgo ## Runs .PHONY: e2e-tests-sequential-ginkgo e2e-tests-sequential-ginkgo: ginkgo ## Runs kuttl e2e sequential tests @echo "Running GitOps Operator sequential Ginkgo E2E tests..." - $(GINKGO_CLI) -v --trace --timeout 180m -r ./test/openshift/e2e/ginkgo/sequential + $(GINKGO_CLI) -v --trace --timeout 210m -r ./test/openshift/e2e/ginkgo/sequential .PHONY: e2e-tests-parallel-ginkgo ## Runs kuttl e2e parallel tests, (Defaults to 5 runs at a time) e2e-tests-parallel-ginkgo: ginkgo From df449425ee7737ad8585ac89397cb1270ad24af9 Mon Sep 17 00:00:00 2001 From: Jayendra Parsai Date: Thu, 9 Apr 2026 12:30:17 +0530 Subject: [PATCH 3/3] test: update e2e implementation Signed-off-by: Jayendra Parsai --- .../e2e/ginkgo/fixture/agent/fixture.go | 10 --- .../ginkgo/fixture/argocdclient/fixture.go | 90 +------------------ ...e_argocd_agent_principal_connected_test.go | 6 +- ...e_argocd_agent_terminal_streaming_test.go} | 14 +-- 4 files changed, 14 insertions(+), 106 deletions(-) rename test/openshift/e2e/ginkgo/sequential/{1-054_validate_argocd_agent_terminal_streaming_test.go => 1-059_validate_argocd_agent_terminal_streaming_test.go} (95%) diff --git a/test/openshift/e2e/ginkgo/fixture/agent/fixture.go b/test/openshift/e2e/ginkgo/fixture/agent/fixture.go index 7a3b988bf53..60a417231fc 100644 --- a/test/openshift/e2e/ginkgo/fixture/agent/fixture.go +++ b/test/openshift/e2e/ginkgo/fixture/agent/fixture.go @@ -568,13 +568,3 @@ func buildDefaultSANs(serviceName, namespace string) []string { fmt.Sprintf("%s.%s.svc.cluster.local", serviceName, namespace), } } - -// GetInitialAdminSecretPassword reads the admin password from the ArgoCD instance's cluster secret -func GetInitialAdminSecretPassword(argocdCRName, secretNS string, k8sClient client.Client) string { - secret := &corev1.Secret{} - Expect(k8sClient.Get(context.Background(), types.NamespacedName{ - Name: fmt.Sprintf("%s-cluster", argocdCRName), - Namespace: secretNS, - }, secret)).To(Succeed()) - return string(secret.Data["admin.password"]) -} diff --git a/test/openshift/e2e/ginkgo/fixture/argocdclient/fixture.go b/test/openshift/e2e/ginkgo/fixture/argocdclient/fixture.go index 98101a39d2e..188f2b4210e 100644 --- a/test/openshift/e2e/ginkgo/fixture/argocdclient/fixture.go +++ b/test/openshift/e2e/ginkgo/fixture/argocdclient/fixture.go @@ -17,7 +17,6 @@ limitations under the License. package argocdclient import ( - "bytes" "crypto/tls" "encoding/json" "errors" @@ -33,72 +32,6 @@ import ( "github.com/gorilla/websocket" ) -type ArgoRestClient struct { - endpoint string - username string - password string - token string - client *http.Client -} - -// NewArgoClient returns a new client for Argo CD's REST API -func NewArgoClient(endpoint, username, password string) *ArgoRestClient { - ac := &ArgoRestClient{ - endpoint: endpoint, - username: username, - password: password, - client: &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, // #nosec G402 - }, - }, - }, - } - return ac -} - -// Login creates a new Argo CD session -func (c *ArgoRestClient) Login() error { - // Get session token from API - authStr := fmt.Sprintf(`{"username": "%s", "password": "%s"}`, c.username, c.password) - payload := io.NopCloser(bytes.NewReader([]byte(authStr))) - res, err := c.client.Do(&http.Request{ - Method: http.MethodPost, - URL: &url.URL{Scheme: "https", Host: c.endpoint, Path: "/api/v1/session"}, - Body: payload, - Header: http.Header{"Content-Type": []string{"application/json"}}, - ContentLength: int64(len(authStr)), - }) - if err != nil { - return err - } - defer func() { - _ = res.Body.Close() - }() - if res.StatusCode != 200 { - return fmt.Errorf("expected HTTP 200, got %d", res.StatusCode) - } - body, err := io.ReadAll(res.Body) - if err != nil { - return err - } - - type tokenResponse struct { - Token string `json:"token"` - } - token := &tokenResponse{} - err = json.Unmarshal(body, token) - if err != nil { - return err - } - if token.Token == "" { - return errors.New("empty token received") - } - c.token = token.Token - return nil -} - // TerminalClient represents a test client for terminal WebSocket connections. type TerminalClient struct { wsConn *websocket.Conn @@ -111,15 +44,10 @@ type TerminalClient struct { // ExecTerminal opens a terminal session to a pod via WebSocket. // This replicates the behavior of the ArgoCD UI when a user opens a terminal session to an application. // ArgoCD decides which shell to use based on the configured allowed shells. -func (c *ArgoRestClient) ExecTerminal(app *v1alpha1.Application, namespace, podName, container string) (*TerminalClient, error) { - if err := c.ensureToken(); err != nil { - return nil, err - } - - // Build the exec URL +func ExecTerminal(endpoint, token string, app *v1alpha1.Application, namespace, podName, container string) (*TerminalClient, error) { u := &url.URL{ Scheme: "wss", - Host: c.endpoint, + Host: endpoint, Path: "/terminal", } @@ -132,18 +60,15 @@ func (c *ArgoRestClient) ExecTerminal(app *v1alpha1.Application, namespace, podN q.Set("namespace", namespace) u.RawQuery = q.Encode() - // Create WebSocket dialer with TLS config dialer := websocket.Dialer{ TLSClientConfig: &tls.Config{ InsecureSkipVerify: true, // #nosec G402 }, } - // Set token as cookie - ArgoCD expects auth token in argocd.token cookie headers := http.Header{} - headers.Set("Cookie", fmt.Sprintf("argocd.token=%s", c.token)) + headers.Set("Cookie", fmt.Sprintf("argocd.token=%s", token)) - // Connect to WebSocket wsConn, resp, err := dialer.Dial(u.String(), headers) if err != nil { if resp != nil { @@ -158,20 +83,11 @@ func (c *ArgoRestClient) ExecTerminal(app *v1alpha1.Application, namespace, podN wsConn: wsConn, } - // Start reading output in background go session.readOutput() return session, nil } -// ensureToken makes sure we have a valid authentication token -func (c *ArgoRestClient) ensureToken() error { - if c.token == "" { - return c.Login() - } - return nil -} - // terminalMessage is the JSON message format used by ArgoCD terminal WebSocket type terminalMessage struct { Operation string `json:"operation"` diff --git a/test/openshift/e2e/ginkgo/sequential/1-053_validate_argocd_agent_principal_connected_test.go b/test/openshift/e2e/ginkgo/sequential/1-053_validate_argocd_agent_principal_connected_test.go index fe560bca2cb..3341850dc29 100644 --- a/test/openshift/e2e/ginkgo/sequential/1-053_validate_argocd_agent_principal_connected_test.go +++ b/test/openshift/e2e/ginkgo/sequential/1-053_validate_argocd_agent_principal_connected_test.go @@ -516,7 +516,7 @@ var _ = Describe("GitOps Operator Sequential E2E Tests", func() { It("Should deploy ArgoCD principal and agent instances in both modes and verify they are working as expected", func() { By("Deploy principal and verify it starts successfully") - deployPrincipal(ctx, k8sClient, registerCleanup) + deployPrincipal(ctx, k8sClient, registerCleanup, false) By("Deploy managed agent and verify it starts successfully") deployAgent(ctx, k8sClient, registerCleanup, argov1beta1api.AgentModeManaged) @@ -609,7 +609,7 @@ var _ = Describe("GitOps Operator Sequential E2E Tests", func() { // This function deploys the principal ArgoCD instance and waits for it to be ready. // It creates the required secrets for the principal and verifies that the principal deployment is in Ready state. // It also verifies that the principal logs contain the expected messages. -func deployPrincipal(ctx context.Context, k8sClient client.Client, registerCleanup func(func()), enableServerRoute ...bool) { +func deployPrincipal(ctx context.Context, k8sClient client.Client, registerCleanup func(func()), enableServerRoute bool) { GinkgoHelper() nsPrincipal, cleanup := fixture.CreateNamespaceWithCleanupFunc(namespaceAgentPrincipal) @@ -624,7 +624,7 @@ func deployPrincipal(ctx context.Context, k8sClient client.Client, registerClean waitForLoadBalancer = false } - if len(enableServerRoute) > 0 && enableServerRoute[0] { + if enableServerRoute { argoCDInstance.Spec.Server.Route = argov1beta1api.ArgoCDRouteSpec{ Enabled: true, } diff --git a/test/openshift/e2e/ginkgo/sequential/1-054_validate_argocd_agent_terminal_streaming_test.go b/test/openshift/e2e/ginkgo/sequential/1-059_validate_argocd_agent_terminal_streaming_test.go similarity index 95% rename from test/openshift/e2e/ginkgo/sequential/1-054_validate_argocd_agent_terminal_streaming_test.go rename to test/openshift/e2e/ginkgo/sequential/1-059_validate_argocd_agent_terminal_streaming_test.go index fa998d629fa..0fbdd45c352 100644 --- a/test/openshift/e2e/ginkgo/sequential/1-054_validate_argocd_agent_terminal_streaming_test.go +++ b/test/openshift/e2e/ginkgo/sequential/1-059_validate_argocd_agent_terminal_streaming_test.go @@ -37,6 +37,7 @@ import ( "github.com/argoproj/gitops-engine/pkg/health" argov1beta1api "github.com/argoproj-labs/argocd-operator/api/v1beta1" + argocdFixture "github.com/argoproj-labs/argocd-operator/tests/ginkgo/fixture/argocd" routev1 "github.com/openshift/api/route/v1" @@ -51,7 +52,7 @@ import ( // This test validates that terminal streaming works through the ArgoCD agent architecture // on OpenShift. It exercises the full terminal flow: var _ = Describe("GitOps Operator Sequential E2E Tests", func() { - Context("1-054_validate_argocd_agent_terminal_streaming", func() { + Context("1-059_validate_argocd_agent_terminal_streaming", func() { var ( k8sClient client.Client ctx context.Context @@ -218,9 +219,10 @@ var _ = Describe("GitOps Operator Sequential E2E Tests", func() { argoEndpoint := serverRoute.Spec.Host GinkgoWriter.Printf("ArgoCD server Route host: %s\n", argoEndpoint) - password := agentFixture.GetInitialAdminSecretPassword(argoCDAgentInstanceNamePrincipal, namespaceAgentPrincipal, k8sClient) - argoClient := argocdClient.NewArgoClient(argoEndpoint, "admin", password) - Expect(argoClient.Login()).To(Succeed()) + password := argocdFixture.GetInitialAdminSecretPassword(argoCDAgentInstanceNamePrincipal, namespaceAgentPrincipal, k8sClient) + _, sessionToken, closer, err := argocdFixture.CreateArgoCDAPIClient(ctx, argoEndpoint, password) + Expect(err).ToNot(HaveOccurred()) + defer closer.Close() // Find the application pod which we want to open a terminal session for. By("Find a running pod in the application namespace") @@ -257,14 +259,14 @@ var _ = Describe("GitOps Operator Sequential E2E Tests", func() { var session *argocdClient.TerminalClient Eventually(func() error { var err error - session, err = argoClient.ExecTerminal(application, managedAgentApplicationNamespace, podName, containerName) + session, err = argocdClient.ExecTerminal(argoEndpoint, sessionToken, application, managedAgentApplicationNamespace, podName, containerName) return err }, "30s", "5s").Should(Succeed(), "failed to open terminal session") defer session.Close() // Send a resize message first, this is required by the shell to render the // output content accordingly. - err := session.SendResize(80, 24) + err = session.SendResize(80, 24) Expect(err).ToNot(HaveOccurred(), "failed to send resize") // Wait for shell to initialize by checking for any output