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
10 changes: 10 additions & 0 deletions test/openshift/e2e/ginkgo/fixture/agent/fixture.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
}
283 changes: 283 additions & 0 deletions test/openshift/e2e/ginkgo/fixture/argocdclient/fixture.go
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
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 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")
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
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",
Client: &argov1beta1api.AgentClientSpec{
PrincipalServerAddress: "", // will be set in the test
PrincipalServerPort: "443",
Expand Down
Loading
Loading