Skip to content

Commit dd74960

Browse files
authored
chore: add e2e tests for argocd-agent web-based terminal feature (#1107)
* update argocd-operator version Signed-off-by: Jayendra Parsai <jparsai@redhat.com> * test: update e2e implementation Signed-off-by: Jayendra Parsai <jparsai@redhat.com> --------- Signed-off-by: Jayendra Parsai <jparsai@redhat.com>
1 parent e0b400e commit dd74960

File tree

4 files changed

+550
-4
lines changed

4 files changed

+550
-4
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ require (
1111
github.com/go-logr/logr v1.4.3
1212
github.com/google/go-cmp v0.7.0
1313
github.com/google/uuid v1.6.1-0.20241114170450-2d3c2a9cc518
14+
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
1415
github.com/hashicorp/go-version v1.7.0
1516
github.com/onsi/ginkgo/v2 v2.28.1
1617
github.com/onsi/gomega v1.39.1
@@ -98,7 +99,6 @@ require (
9899
github.com/google/go-github/v75 v75.0.0 // indirect
99100
github.com/google/go-querystring v1.1.0 // indirect
100101
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect
101-
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
102102
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect
103103
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect
104104
github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
/*
2+
Copyright 2026.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package argocdclient
18+
19+
import (
20+
"crypto/tls"
21+
"encoding/json"
22+
"errors"
23+
"fmt"
24+
"io"
25+
"net/http"
26+
"net/url"
27+
"strings"
28+
"sync"
29+
"time"
30+
31+
"github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1"
32+
"github.com/gorilla/websocket"
33+
)
34+
35+
// TerminalClient represents a test client for terminal WebSocket connections.
36+
type TerminalClient struct {
37+
wsConn *websocket.Conn
38+
mu sync.Mutex
39+
closed bool
40+
output strings.Builder
41+
outputMu sync.Mutex
42+
}
43+
44+
// ExecTerminal opens a terminal session to a pod via WebSocket.
45+
// This replicates the behavior of the ArgoCD UI when a user opens a terminal session to an application.
46+
// ArgoCD decides which shell to use based on the configured allowed shells.
47+
func ExecTerminal(endpoint, token string, app *v1alpha1.Application, namespace, podName, container string) (*TerminalClient, error) {
48+
u := &url.URL{
49+
Scheme: "wss",
50+
Host: endpoint,
51+
Path: "/terminal",
52+
}
53+
54+
q := u.Query()
55+
q.Set("pod", podName)
56+
q.Set("container", container)
57+
q.Set("appName", app.Name)
58+
q.Set("appNamespace", app.Namespace)
59+
q.Set("projectName", app.Spec.Project)
60+
q.Set("namespace", namespace)
61+
u.RawQuery = q.Encode()
62+
63+
dialer := websocket.Dialer{
64+
TLSClientConfig: &tls.Config{
65+
InsecureSkipVerify: true, // #nosec G402
66+
},
67+
}
68+
69+
headers := http.Header{}
70+
headers.Set("Cookie", fmt.Sprintf("argocd.token=%s", token))
71+
72+
wsConn, resp, err := dialer.Dial(u.String(), headers)
73+
if err != nil {
74+
if resp != nil {
75+
defer resp.Body.Close()
76+
body, _ := io.ReadAll(resp.Body)
77+
return nil, fmt.Errorf("failed to connect to terminal WebSocket: %w (status: %d, body: %s)", err, resp.StatusCode, string(body))
78+
}
79+
return nil, fmt.Errorf("failed to connect to terminal WebSocket: %w", err)
80+
}
81+
82+
session := &TerminalClient{
83+
wsConn: wsConn,
84+
}
85+
86+
go session.readOutput()
87+
88+
return session, nil
89+
}
90+
91+
// terminalMessage is the JSON message format used by ArgoCD terminal WebSocket
92+
type terminalMessage struct {
93+
Operation string `json:"operation"`
94+
Data string `json:"data"`
95+
Rows uint16 `json:"rows"`
96+
Cols uint16 `json:"cols"`
97+
}
98+
99+
// readOutput continuously reads output from the WebSocket connection
100+
func (s *TerminalClient) readOutput() {
101+
for {
102+
_, message, err := s.wsConn.ReadMessage()
103+
if err != nil {
104+
// Connection closed or error
105+
return
106+
}
107+
108+
if len(message) < 1 {
109+
continue
110+
}
111+
112+
// Parse JSON message
113+
var msg terminalMessage
114+
if err := json.Unmarshal(message, &msg); err != nil {
115+
continue
116+
}
117+
118+
switch msg.Operation {
119+
case "stdout":
120+
s.outputMu.Lock()
121+
s.output.WriteString(msg.Data)
122+
s.outputMu.Unlock()
123+
}
124+
}
125+
}
126+
127+
// SendInput sends input to the terminal session
128+
func (s *TerminalClient) SendInput(input string) error {
129+
s.mu.Lock()
130+
defer s.mu.Unlock()
131+
132+
if s.closed {
133+
return errors.New("session is closed")
134+
}
135+
136+
// ArgoCD terminal uses JSON messages (includes rows/cols like the UI)
137+
msg, err := json.Marshal(terminalMessage{
138+
Operation: "stdin",
139+
Data: input,
140+
Rows: 24,
141+
Cols: 80,
142+
})
143+
if err != nil {
144+
return err
145+
}
146+
return s.wsConn.WriteMessage(websocket.TextMessage, msg)
147+
}
148+
149+
// SendResize sends a terminal resize message
150+
func (s *TerminalClient) SendResize(cols, rows uint16) error {
151+
s.mu.Lock()
152+
defer s.mu.Unlock()
153+
154+
if s.closed {
155+
return errors.New("session is closed")
156+
}
157+
158+
// ArgoCD terminal uses JSON messages
159+
msg, err := json.Marshal(terminalMessage{
160+
Operation: "resize",
161+
Cols: cols,
162+
Rows: rows,
163+
})
164+
if err != nil {
165+
return err
166+
}
167+
return s.wsConn.WriteMessage(websocket.TextMessage, msg)
168+
}
169+
170+
// GetOutput returns all captured output so far
171+
func (s *TerminalClient) GetOutput() string {
172+
s.outputMu.Lock()
173+
defer s.outputMu.Unlock()
174+
return s.output.String()
175+
}
176+
177+
// WaitForOutput waits until the output contains the expected string or timeout
178+
func (s *TerminalClient) WaitForOutput(expected string, timeout time.Duration) bool {
179+
deadline := time.Now().Add(timeout)
180+
for time.Now().Before(deadline) {
181+
if strings.Contains(s.GetOutput(), expected) {
182+
return true
183+
}
184+
time.Sleep(100 * time.Millisecond)
185+
}
186+
return false
187+
}
188+
189+
// Close closes the terminal session
190+
func (s *TerminalClient) Close() error {
191+
s.mu.Lock()
192+
defer s.mu.Unlock()
193+
194+
if s.closed {
195+
return nil
196+
}
197+
s.closed = true
198+
return s.wsConn.Close()
199+
}

test/openshift/e2e/ginkgo/sequential/1-053_validate_argocd_agent_principal_connected_test.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -516,7 +516,7 @@ var _ = Describe("GitOps Operator Sequential E2E Tests", func() {
516516
It("Should deploy ArgoCD principal and agent instances in both modes and verify they are working as expected", func() {
517517

518518
By("Deploy principal and verify it starts successfully")
519-
deployPrincipal(ctx, k8sClient, registerCleanup)
519+
deployPrincipal(ctx, k8sClient, registerCleanup, false)
520520

521521
By("Deploy managed agent and verify it starts successfully")
522522
deployAgent(ctx, k8sClient, registerCleanup, argov1beta1api.AgentModeManaged)
@@ -609,7 +609,7 @@ var _ = Describe("GitOps Operator Sequential E2E Tests", func() {
609609
// This function deploys the principal ArgoCD instance and waits for it to be ready.
610610
// It creates the required secrets for the principal and verifies that the principal deployment is in Ready state.
611611
// It also verifies that the principal logs contain the expected messages.
612-
func deployPrincipal(ctx context.Context, k8sClient client.Client, registerCleanup func(func())) {
612+
func deployPrincipal(ctx context.Context, k8sClient client.Client, registerCleanup func(func()), enableServerRoute bool) {
613613
GinkgoHelper()
614614

615615
nsPrincipal, cleanup := fixture.CreateNamespaceWithCleanupFunc(namespaceAgentPrincipal)
@@ -624,6 +624,12 @@ func deployPrincipal(ctx context.Context, k8sClient client.Client, registerClean
624624
waitForLoadBalancer = false
625625
}
626626

627+
if enableServerRoute {
628+
argoCDInstance.Spec.Server.Route = argov1beta1api.ArgoCDRouteSpec{
629+
Enabled: true,
630+
}
631+
}
632+
627633
Expect(k8sClient.Create(ctx, argoCDInstance)).To(Succeed())
628634

629635
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
678684

679685
Eventually(&appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{
680686
Name: deploymentNameAgentPrincipal,
681-
Namespace: nsPrincipal.Name}}, "120s", "5s").Should(deploymentFixture.HaveReadyReplicas(1))
687+
Namespace: nsPrincipal.Name}}, "240s", "5s").Should(deploymentFixture.HaveReadyReplicas(1))
682688

683689
By("Verify principal logs contain expected messages")
684690

0 commit comments

Comments
 (0)