Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
199 changes: 199 additions & 0 deletions test/openshift/e2e/ginkgo/fixture/argocdclient/fixture.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
/*
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 (
"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"
)

// 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 ExecTerminal(endpoint, token string, app *v1alpha1.Application, namespace, podName, container string) (*TerminalClient, error) {
u := &url.URL{
Scheme: "wss",
Host: 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()

dialer := websocket.Dialer{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true, // #nosec G402
},
}

headers := http.Header{}
headers.Set("Cookie", fmt.Sprintf("argocd.token=%s", token))

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,
}

go session.readOutput()

return session, 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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -624,6 +624,12 @@ func deployPrincipal(ctx context.Context, k8sClient client.Client, registerClean
waitForLoadBalancer = false
}

if enableServerRoute {
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")
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than using a personal image, lets wait for ArgoCDAgentPrincipalDefaultImageName to be updated in upstream before we merge.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, since 0.8.x release out, now we have agent image available having fix for OCP. I raised upstream PR, will wait for it to be merged first argoproj-labs/argocd-operator#2157

Image: "quay.io/jparsai/argocd-agent:1.20.1",
Namespace: &argov1beta1api.PrincipalNamespaceSpec{
AllowedNamespaces: []string{
managedAgentClusterName,
Expand Down Expand Up @@ -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",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above

Client: &argov1beta1api.AgentClientSpec{
PrincipalServerAddress: "", // will be set in the test
PrincipalServerPort: "443",
Expand Down
Loading
Loading