diff --git a/bundle/manifests/gitops-operator.clusterserviceversion.yaml b/bundle/manifests/gitops-operator.clusterserviceversion.yaml index e23fba14211..e9b780dd6cb 100644 --- a/bundle/manifests/gitops-operator.clusterserviceversion.yaml +++ b/bundle/manifests/gitops-operator.clusterserviceversion.yaml @@ -190,7 +190,7 @@ metadata: capabilities: Deep Insights console.openshift.io/plugins: '["gitops-plugin"]' containerImage: quay.io/redhat-developer/gitops-operator - createdAt: "2026-08-13T17:59:24Z" + createdAt: "2026-08-27T13:35:19Z" description: Enables teams to adopt GitOps principles for managing cluster configurations and application delivery across hybrid multi-cluster Kubernetes environments. features.operators.openshift.io/disconnected: "true" diff --git a/go.mod b/go.mod index 73e186f9b1c..bbe86be9c92 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,10 @@ go 1.26.3 require ( github.com/argoproj-labs/argo-rollouts-manager v0.0.9-0.20260505092152-3e07addcb2cb github.com/argoproj-labs/argocd-image-updater v1.3.0 - github.com/argoproj-labs/argocd-operator v0.19.0-rc1.0.20260731050707-513e881fcd8e + github.com/argoproj-labs/argocd-operator v0.19.0-rc1.0.20260805141109-c13365e8f41d github.com/argoproj/argo-cd/gitops-engine v0.7.1-0.20250908182407-97ad5b59a627 github.com/argoproj/argo-cd/v3 v3.5.1 + github.com/cert-manager/cert-manager v1.20.3 github.com/go-logr/logr v1.4.4 github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.1-0.20241114170450-2d3c2a9cc518 @@ -21,6 +22,7 @@ require ( github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring v0.74.0 github.com/stretchr/testify v1.11.1 go.uber.org/zap v1.28.0 + golang.org/x/crypto v0.54.0 golang.org/x/mod v0.38.0 gopkg.in/yaml.v3 v3.0.1 gotest.tools v2.2.0+incompatible @@ -57,7 +59,6 @@ require ( github.com/casbin/casbin/v2 v2.135.0 // indirect github.com/casbin/govaluate v1.10.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect - github.com/cert-manager/cert-manager v1.20.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chai2010/gettext-go v1.0.3 // indirect github.com/chainguard-dev/git-urls v1.0.2 // indirect @@ -176,7 +177,6 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.54.0 // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/net v0.57.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect diff --git a/go.sum b/go.sum index cb306936715..a5e5b96dd66 100644 --- a/go.sum +++ b/go.sum @@ -43,8 +43,8 @@ github.com/argoproj-labs/argo-rollouts-manager v0.0.9-0.20260505092152-3e07addcb github.com/argoproj-labs/argo-rollouts-manager v0.0.9-0.20260505092152-3e07addcb2cb/go.mod h1:Ouqjtkj48SPJhW6r00CYqJ4uM7QDy3D4tinKIK9Y69Q= github.com/argoproj-labs/argocd-image-updater v1.3.0 h1:MFqUKURoh14wV1ansPGFnzLIvUGhzWOMU8L2x96041U= github.com/argoproj-labs/argocd-image-updater v1.3.0/go.mod h1:7h1LqHoKavqo8rofiSPnzMK55n77WrnwCLxtgOb3AtI= -github.com/argoproj-labs/argocd-operator v0.19.0-rc1.0.20260731050707-513e881fcd8e h1:TocoyPvGB+Hf0pWLqIUySr+LsXpK/IylmatMVDLEsBo= -github.com/argoproj-labs/argocd-operator v0.19.0-rc1.0.20260731050707-513e881fcd8e/go.mod h1:WlaJ1K/BaF0qhltQEoYv8PYwns9/F6/v+dJWMyOi+U0= +github.com/argoproj-labs/argocd-operator v0.19.0-rc1.0.20260805141109-c13365e8f41d h1:iYXtL9yPDgvXgMswd84SW9Ye9BuzZTrIAVxMoKe9d7Q= +github.com/argoproj-labs/argocd-operator v0.19.0-rc1.0.20260805141109-c13365e8f41d/go.mod h1:alt7YEhOA0G/am4PZnikVEaZ6m2Y1+zbLIVGKUwBesE= github.com/argoproj/argo-cd/gitops-engine v0.0.0-20260812112440-109ca7ca7113 h1:Datv/1guNla320d6ZaHTSaxB3bUVzO83NyeY6LaUjvM= github.com/argoproj/argo-cd/gitops-engine v0.0.0-20260812112440-109ca7ca7113/go.mod h1:RsOM4gdM/lsvAfIuzAhYrnHDTLA1AGooZRzVyxbVT3A= github.com/argoproj/argo-cd/v3 v3.5.1 h1:jtwPLEFX9mNj3jSq88ugFcScGnOZVs2DWaXFuZxrRT8= diff --git a/test/openshift/e2e/ginkgo/fixture/argocd/fixture.go b/test/openshift/e2e/ginkgo/fixture/argocd/fixture.go index 72c6fdbfccd..3ae28a2e8be 100644 --- a/test/openshift/e2e/ginkgo/fixture/argocd/fixture.go +++ b/test/openshift/e2e/ginkgo/fixture/argocd/fixture.go @@ -82,6 +82,7 @@ func HavePhase(expected string) matcher.GomegaMatcher { "Redis:", argocd.Status.Redis, "Repo:", argocd.Status.Repo, "Server: ", argocd.Status.Server, + "CommitServer:", argocd.Status.CommitServer, "ApplicationController:", argocd.Status.ApplicationController, "ApplicationSetController:", argocd.Status.ApplicationSetController, "NotificationsController:", argocd.Status.NotificationsController, @@ -119,6 +120,13 @@ func HaveServerStatus(status string) matcher.GomegaMatcher { }) } +func HaveCommitServerStatus(status string) matcher.GomegaMatcher { + return fetchArgoCD(func(argocd *argov1beta1api.ArgoCD) bool { + GinkgoWriter.Println("HaveCommitServerStatus:", "expected:", status, "/ actual:", argocd.Status.CommitServer) + return argocd.Status.CommitServer == status + }) +} + func HaveApplicationControllerStatus(status string) matcher.GomegaMatcher { return fetchArgoCD(func(argocd *argov1beta1api.ArgoCD) bool { GinkgoWriter.Println("HaveApplicationControllerStatus:", "expected:", status, "/ actual:", argocd.Status.ApplicationController) diff --git a/test/openshift/e2e/ginkgo/fixture/fixture.go b/test/openshift/e2e/ginkgo/fixture/fixture.go index 8b3f8c85447..71b3e078f03 100644 --- a/test/openshift/e2e/ginkgo/fixture/fixture.go +++ b/test/openshift/e2e/ginkgo/fixture/fixture.go @@ -897,7 +897,7 @@ func OutputDebug(namespaceParams ...any) { for _, namespace := range namespaces { - kubectlOutput, err := osFixture.ExecCommandWithOutputParam(false, true, "kubectl", "get", "all", "-n", namespace) + kubectlOutput, err := osFixture.ExecCommandWithOutputParam(false, true, "kubectl", "get", "all,serviceaccount", "-n", namespace) if err != nil { GinkgoWriter.Println("unable to list", namespace, err, kubectlOutput) continue diff --git a/test/openshift/e2e/ginkgo/fixture/gitserver/certs.go b/test/openshift/e2e/ginkgo/fixture/gitserver/certs.go new file mode 100644 index 00000000000..e1213eff063 --- /dev/null +++ b/test/openshift/e2e/ginkgo/fixture/gitserver/certs.go @@ -0,0 +1,79 @@ +package gitserver + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "fmt" + "strings" + + certmanagerv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" + . "github.com/onsi/gomega" + "golang.org/x/crypto/ssh" + + "github.com/argoproj-labs/argocd-operator/controllers/argoutil" +) + +type sshKeyPair struct { + privateKeyPEM []byte + publicKey string +} + +func generateSSHKeyPair() sshKeyPair { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + Expect(err).NotTo(HaveOccurred()) + + sshPublicKey, err := ssh.NewPublicKey(publicKey) + Expect(err).NotTo(HaveOccurred()) + + privateKeyBlock, err := ssh.MarshalPrivateKey(privateKey, "") + Expect(err).NotTo(HaveOccurred()) + + return sshKeyPair{ + privateKeyPEM: pem.EncodeToMemory(privateKeyBlock), + publicKey: string(ssh.MarshalAuthorizedKey(sshPublicKey)), + } +} + +func formatSSHKnownHosts(host string, port int32, publicKey ssh.PublicKey) string { + keyLine := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(publicKey))) + parts := strings.SplitN(keyLine, " ", 3) + Expect(len(parts)).To(BeNumerically(">=", 2)) + + hostPort := host + if port != 22 { + hostPort = fmt.Sprintf("[%s]:%d", host, port) + } + return fmt.Sprintf("%s %s %s\n", hostPort, parts[0], parts[1]) +} + +func generateTLSSecretData(domain string, podName string, namespace string) map[string][]byte { + key, err := argoutil.NewPrivateKey() + Expect(err).NotTo(HaveOccurred()) + + caKey, err := argoutil.NewPrivateKey() + Expect(err).NotTo(HaveOccurred()) + caCert, err := argoutil.NewSelfSignedCACertificate(domain, caKey) + Expect(err).NotTo(HaveOccurred()) + + certSpec := &certmanagerv1.CertificateSpec{ + CommonName: domain, + Subject: &certmanagerv1.X509Subject{ + Organizations: []string{domain}, + }, + } + dnsNames := []string{ + podName, + fmt.Sprintf("%s.%s", podName, namespace), + fmt.Sprintf("%s.%s.svc", podName, namespace), + fmt.Sprintf("%s.%s.svc.cluster.local", podName, namespace), + } + cert, err := argoutil.NewSignedCertificate(certSpec, dnsNames, key, caCert, caKey) + Expect(err).NotTo(HaveOccurred()) + + return map[string][]byte{ + "tls.crt": argoutil.EncodeCertificatePEM(cert), + "tls.key": argoutil.EncodePrivateKeyPEM(key), + "ca.crt": argoutil.EncodeCertificatePEM(caCert), + } +} diff --git a/test/openshift/e2e/ginkgo/fixture/gitserver/gitea.go b/test/openshift/e2e/ginkgo/fixture/gitserver/gitea.go new file mode 100644 index 00000000000..8a488133c05 --- /dev/null +++ b/test/openshift/e2e/ginkgo/fixture/gitserver/gitea.go @@ -0,0 +1,152 @@ +package gitserver + +import ( + "encoding/base64" + "fmt" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "golang.org/x/crypto/ssh" + + corev1 "k8s.io/api/core/v1" + + argocdutil "github.com/argoproj-labs/argocd-operator/controllers/argocd" + osFixture "github.com/argoproj-labs/argocd-operator/tests/ginkgo/fixture/os" +) + +const ( + giteaImage = "ghcr.io/go-gitea/gitea:1.26.4-rootless" + giteaCustomPath = "/data/gitea" +) + +func giteaEnvVars(domain, internalToken string) []corev1.EnvVar { + return []corev1.EnvVar{ + {Name: "USER_UID", Value: "1000"}, + {Name: "USER_GID", Value: "1000"}, + {Name: "GITEA_CUSTOM", Value: giteaCustomPath}, + {Name: "GITEA_APP_NAME", Value: "Gitea E2E Git Server"}, + {Name: "GITEA__run_mode", Value: "prod"}, + {Name: "GITEA__database__DB_TYPE", Value: "sqlite3"}, + {Name: "GITEA__database__PATH", Value: "/data/gitea/gitea.db"}, + {Name: "GITEA__repository__DEFAULT_BRANCH", Value: "main"}, + {Name: "GITEA__server__DOMAIN", Value: domain}, + {Name: "GITEA__server__SSH_DOMAIN", Value: domain}, + {Name: "GITEA__server__SSH_USER", Value: giteaSSHLogin}, + {Name: "GITEA__server__HTTP_PORT", Value: fmt.Sprintf("%d", httpPort)}, + {Name: "GITEA__server__ROOT_URL", Value: fmt.Sprintf("https://%s:%d/", domain, httpPort)}, + {Name: "GITEA__server__PROTOCOL", Value: "https"}, + {Name: "GITEA__server__CERT_FILE", Value: "/etc/gitea/certs/tls.crt"}, + {Name: "GITEA__server__KEY_FILE", Value: "/etc/gitea/certs/tls.key"}, + {Name: "GITEA__server__LOCAL_ROOT_URL", Value: fmt.Sprintf("https://127.0.0.1:%d/", httpPort)}, + {Name: "GITEA__server__START_SSH_SERVER", Value: "true"}, + {Name: "GITEA__server__SSH_PORT", Value: fmt.Sprintf("%d", sshServicePort)}, + {Name: "GITEA__server__SSH_LISTEN_PORT", Value: fmt.Sprintf("%d", sshPort)}, + {Name: "GITEA__server__DISABLE_SSH", Value: "false"}, + {Name: "GITEA__server__LFS_START_SERVER", Value: "false"}, + {Name: "GITEA__security__INSTALL_LOCK", Value: "true"}, + {Name: "GITEA__security__INTERNAL_TOKEN", Value: internalToken}, + {Name: "GITEA__security__SECRET_KEY", Value: argocdutil.GenerateRandomString(24)}, + {Name: "GITEA__oauth2__JWT_SECRET", Value: argocdutil.GenerateRandomString(24)}, + {Name: "GITEA__lfs__LFS_JWT_SECRET", Value: argocdutil.GenerateRandomString(24)}, + {Name: "GITEA__service__DISABLE_REGISTRATION", Value: "true"}, + {Name: "GITEA__service__REQUIRE_SIGNIN_VIEW", Value: "false"}, + {Name: "GITEA__mailer__ENABLED", Value: "false"}, + {Name: "GITEA__openid__ENABLE_OPENID_SIGNIN", Value: "false"}, + {Name: "GITEA__openid__ENABLE_OPENID_SIGNUP", Value: "false"}, + } +} + +func configureGiteaAdmin(server *Server) { + By("configuring Gitea admin user and SSH key") + + Eventually(func() error { + _, err := execInGiteaPod(server.namespace, "gitea", "admin", "user", "list") + return err + }, "30s", "5s").Should(Succeed()) + + Eventually(func() error { + out, err := execInGiteaPod(server.namespace, + "gitea", "admin", "user", "create", + "--username", gitUsername, + "--password", server.httpPassword, + "--email", "gituser@test.local", + "--admin", + "--must-change-password=false", + ) + if err != nil && (strings.Contains(out, "already exists") || strings.Contains(out, "user already")) { + return nil + } + if err != nil { + GinkgoWriter.Printf("gitea admin user create failed: %v: %s\n", err, out) + } + return err + }, "30s", "5s").Should(Succeed()) + + Eventually(func() error { + out, err := giteaAPIPost(server.namespace, server.httpPassword, + "/api/v1/user/keys", + fmt.Sprintf(`{"title":"e2e-git-ssh-key","key":"%s"}`, strings.TrimSpace(server.sshPublicKey)), + ) + if err != nil && strings.Contains(out, "already") { + return nil + } + if err != nil { + GinkgoWriter.Printf("gitea add SSH key failed: %v: %s\n", err, out) + } + return err + }, "30s", "5s").Should(Succeed()) +} + +// fetchSSHKnownHosts reads Gitea's generated SSH host public key from the pod and +// formats it for argocd-ssh-known-hosts-cm. +func fetchSSHKnownHosts(server *Server) string { + const hostPublicKeyPath = "/var/lib/gitea/ssh/gitea.rsa.pub" + + var knownHosts string + Eventually(func(g Gomega) { + out, err := execInGiteaPod(server.namespace, "cat", hostPublicKeyPath) + g.Expect(err).NotTo(HaveOccurred(), out) + + pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(strings.TrimSpace(out))) + g.Expect(err).NotTo(HaveOccurred(), "parse Gitea SSH host key from %q: %q", hostPublicKeyPath, out) + + knownHosts = formatSSHKnownHosts(server.clusterDomain, sshServicePort, pubKey) + g.Expect(knownHosts).NotTo(BeEmpty()) + }, "2m", "5s").Should(Succeed()) + + return knownHosts +} + +func execInGiteaPod(namespace string, args ...string) (string, error) { + execArgs := []string{"kubectl", "exec", "-n", namespace, "pod/" + serverName, "-c", serverName, "--"} + execArgs = append(execArgs, args...) + return osFixture.ExecCommandWithOutputParam(false, false, execArgs...) +} + +func giteaAPIPost(namespace, password, path, body string) (string, error) { + auth := base64.StdEncoding.EncodeToString([]byte(gitUsername + ":" + password)) + return execInGiteaPod(namespace, giteaWgetArgs("Basic "+auth, "POST", path, body)...) +} + +func giteaAPIGet(namespace, password, path string) (string, error) { + auth := base64.StdEncoding.EncodeToString([]byte(gitUsername + ":" + password)) + return execInGiteaPod(namespace, giteaWgetArgs("Basic "+auth, "GET", path, "")...) +} + +func giteaWgetArgs(auth, method, path, body string) []string { + args := []string{ + "wget", + "-q", "--no-check-certificate", + "--header=Authorization: " + auth, + "-O", "-", + } + if method == "POST" { + args = append(args, + "--header=Content-Type: application/json", + "--post-data="+body, + ) + } + args = append(args, fmt.Sprintf("https://127.0.0.1:%d%s", httpPort, path)) + return args +} diff --git a/test/openshift/e2e/ginkgo/fixture/gitserver/repo.go b/test/openshift/e2e/ginkgo/fixture/gitserver/repo.go new file mode 100644 index 00000000000..9d914a7cd9f --- /dev/null +++ b/test/openshift/e2e/ginkgo/fixture/gitserver/repo.go @@ -0,0 +1,228 @@ +package gitserver + +import ( + "fmt" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type Transport string + +const ( + TransportSSH Transport = "ssh" + TransportHTTPS Transport = "https" +) + +const defaultCommitMessage = "gitserver e2e commit" + +type Repo struct { + server *Server + repoName string + + cloneDir *os.Root + transport Transport +} + +// GetRepoHttpURL returns the HTTPS clone URL reachable from inside the cluster. +// Argo CD components use this URL; it matches the Git server TLS certificate SAN. +func (r Repo) GetRepoHttpURL() string { + return fmt.Sprintf("https://%s:%d/%s/%s.git", r.server.clusterDomain, httpPort, r.server.httpUsername, r.repoName) +} + +func (r Repo) getRepoHttpURLWithCredentials() string { + repoURL := &url.URL{ + Scheme: "https", + User: url.UserPassword(r.server.httpUsername, r.server.httpPassword), + Host: r.server.domain, + Path: fmt.Sprintf("/%s/%s.git", r.server.httpUsername, r.repoName), + } + return repoURL.String() +} + +// GetRepoSshURL returns the SSH clone URL reachable from inside the cluster. +func (r Repo) GetRepoSshURL() string { + return fmt.Sprintf("ssh://%s@%s:%d/%s/%s.git", giteaSSHLogin, r.server.clusterDomain, sshServicePort, r.server.httpUsername, r.repoName) +} + +func (r Repo) getRepoSshURLLocal() string { + return fmt.Sprintf("ssh://%s@127.0.0.1:%d/%s/%s.git", giteaSSHLogin, r.server.localSSHPort, r.server.httpUsername, r.repoName) +} + +func (r *Repo) Clone(t Transport) (cleanup func(), err error) { + r.transport = t + + if t == TransportSSH { + if _, err := r.server.getSSHKeyFile(); err != nil { + return nil, err + } + } + + fsDir, err := os.MkdirTemp("", "operator-gitserver-clone-*") + if err != nil { + return nil, fmt.Errorf("failed to create temporary directory: %w", err) + } + + r.cloneDir, err = os.OpenRoot(fsDir) + if err != nil { + _ = os.RemoveAll(fsDir) + return nil, fmt.Errorf("failed to open root: %w", err) + } + cleanup = func() { + _ = r.cloneDir.Close() + r.cloneDir = nil + _ = os.RemoveAll(fsDir) + } + + cloneURL := r.getRepoSshURLLocal() + if t == TransportHTTPS { + cloneURL = r.getRepoHttpURLWithCredentials() + } + + GinkgoWriter.Println("Cloning repo:", cloneURL) + + out, err := r.git("clone", cloneURL, ".") + if err != nil { + cleanup() + return nil, fmt.Errorf("failed to clone repo: %w: %s", err, out) + } + + if out, err := r.git("config", "user.name", "E2E test"); err != nil { + cleanup() + return nil, fmt.Errorf("failed to set git user.name: %w: %s", err, out) + } + if out, err := r.git("config", "user.email", "e2e-test@example.com"); err != nil { + cleanup() + return nil, fmt.Errorf("failed to set git user.email: %w: %s", err, out) + } + + return cleanup, nil +} + +func (r *Repo) fetch(branches ...string) error { + if r.cloneDir == nil { + return fmt.Errorf("repository has not been cloned") + } + + args := append([]string{"fetch", "origin"}, branches...) + if out, err := r.git(args...); err != nil { + if len(branches) == 0 { + return fmt.Errorf("failed to fetch from origin: %w: %s", err, out) + } + return fmt.Errorf("failed to fetch branches %v from origin: %w: %q", branches, err, out) + } + return nil +} + +// CheckoutBranch fetches and checks out a remote branch. +func (r *Repo) CheckoutBranch(branch string) error { + if branch == "" { + return fmt.Errorf("branch is required") + } + if err := r.fetch(branch); err != nil { + return err + } + if out, err := r.git("checkout", "-B", branch, "origin/"+branch); err != nil { + return fmt.Errorf("failed to checkout branch %q: %w: %s", branch, err, out) + } + return nil +} + +// ReadFile returns the contents of a file from the checked-out clone. +func (r *Repo) ReadFile(path string) (string, error) { + data, err := r.cloneDir.ReadFile(path) + if err != nil { + return "", err + } + return string(data), nil +} + +func (r *Repo) git(args ...string) (string, error) { + if r.cloneDir == nil { + return "", fmt.Errorf("repository has not been cloned") + } + + cmd := exec.Command("git", args...) // #nosec G204 // Binary is specified by literal + cmd.Dir = r.cloneDir.Name() + if r.transport == TransportHTTPS { + cmd.Env = append(os.Environ(), "GIT_SSL_NO_VERIFY=true") + } else { + sshKeyFile, err := r.server.getSSHKeyFile() + if err != nil { + return "", err + } + cmd.Env = append(os.Environ(), + "GIT_SSH_COMMAND=ssh -i "+sshKeyFile+ + " -o IdentitiesOnly=yes -o IdentityAgent=none"+ + " -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null", + ) + } + + output, err := cmd.CombinedOutput() + return string(output), err +} + +func (r *Repo) CommitAndPush(commit Commit) error { + if r.cloneDir == nil { + return fmt.Errorf("repository has not been cloned") + } + + out, err := r.git("status", "--porcelain") + if err != nil { + return fmt.Errorf("failed to check repository status: %w: %s", err, out) + } + if strings.TrimSpace(out) != "" { + return fmt.Errorf("repository has uncommitted changes: %s", strings.TrimSpace(out)) + } + + if err := commit.applyChange(r); err != nil { + return err + } + + if commit.Branch != "" { + if out, err := r.git("checkout", "-B", commit.Branch); err != nil { + return fmt.Errorf("failed to checkout branch %q: %w: %s", commit.Branch, err, out) + } + } + + if out, err = r.git("add", "-A"); err != nil { + return fmt.Errorf("failed to add changes: %w: %s", err, out) + } + if out, err = r.git("commit", "-m", defaultCommitMessage); err != nil { + return fmt.Errorf("failed to commit changes: %w: %s", err, out) + } + + pushArgs := []string{"push"} + if commit.Branch != "" { + pushArgs = append(pushArgs, "-u", "origin", commit.Branch) + } + if out, err = r.git(pushArgs...); err != nil { + return fmt.Errorf("failed to push changes: %w: %s", err, out) + } + + return nil +} + +type Commit struct { + Branch string + Files map[string]string +} + +func (c Commit) applyChange(repo *Repo) error { + Expect(c.Files).NotTo(BeEmpty(), "commit must have at least one file") + + for path, content := range c.Files { + if err := repo.cloneDir.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return err + } + if err := repo.cloneDir.WriteFile(path, []byte(content), 0o600); err != nil { + return err + } + } + return nil +} diff --git a/test/openshift/e2e/ginkgo/fixture/gitserver/server.go b/test/openshift/e2e/ginkgo/fixture/gitserver/server.go new file mode 100644 index 00000000000..e29e58f9aef --- /dev/null +++ b/test/openshift/e2e/ginkgo/fixture/gitserver/server.go @@ -0,0 +1,534 @@ +package gitserver + +import ( + "bufio" + "context" + "fmt" + "io" + "net" + "os" + "os/exec" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/argoproj-labs/argocd-operator/common" + argocdutil "github.com/argoproj-labs/argocd-operator/controllers/argocd" + "github.com/argoproj-labs/argocd-operator/tests/ginkgo/fixture" + podFixture "github.com/argoproj-labs/argocd-operator/tests/ginkgo/fixture/pod" + routev1 "github.com/openshift/api/route/v1" +) + +const ( + serverName = "e2e-gitserver" + + httpPort = int32(3000) + sshPort = int32(2222) // rootless image listens on 2222 + sshServicePort = int32(22) // service maps 22 -> 2222 for callers + gitUsername = "gituser" + giteaSSHLogin = "git" // rootless builtin SSH authenticates as RUN_USER (git), not the Gitea account name +) + +// Server exposes connection details for a Gitea instance started in the test namespace. +type Server struct { + namespace string + serviceName string + + clusterDomain string // in-cluster service DNS name (matches the TLS certificate SAN) + domain string // external HTTPS route hostname for local git clients + localSSHPort int // kubectl port-forward local port for git clients on the test runner + httpURL string + httpUsername string + httpPassword string + sshPrivateKey []byte + sshPublicKey string + sshKnownHosts string + sshKeyFile string + caCert []byte + + stopPortForward func() + httpsRoute *routev1.Route +} + +func (s *Server) getSSHKeyFile() (string, error) { + if s.sshKeyFile != "" { + return s.sshKeyFile, nil + } + + f, err := os.CreateTemp("", "operator-gitserver-ssh-key-") + if err != nil { + return "", err + } + + if _, err := f.Write(s.getSSHPrivateKey()); err != nil { + _ = f.Close() + _ = os.Remove(f.Name()) + return "", err + } + if err := f.Chmod(0o600); err != nil { + _ = f.Close() + _ = os.Remove(f.Name()) + return "", err + } + if err := f.Close(); err != nil { + _ = os.Remove(f.Name()) + return "", err + } + + s.sshKeyFile = f.Name() + return s.sshKeyFile, nil +} + +func (s *Server) removeSSHKeyFile() { + if s.sshKeyFile == "" { + return + } + _ = os.Remove(s.sshKeyFile) + s.sshKeyFile = "" +} + +func (s *Server) getSSHPrivateKey() []byte { + return append([]byte(nil), s.sshPrivateKey...) +} + +// TLSHostKey returns the hostname key used in argocd-tls-certs-cm and InitialCerts. +// Use the in-cluster DNS name so it matches the Git server TLS certificate SAN. +func (s *Server) TLSHostKey() string { + return s.clusterDomain +} + +// GetCACert returns the PEM-encoded CA certificate used by the Git server HTTPS endpoint. +func (s *Server) GetCACert() []byte { + return append([]byte(nil), s.caCert...) +} + +// StartServer deploys a functional Git instance with HTTPS and SSH enabled in the given namespace. +func StartServer(ctx context.Context, k8sClient client.Client, ns *corev1.Namespace) (server *Server, cleanup func()) { + Expect(ns).ToNot(BeNil()) + + By("Deploying Git server") + clusterDomain := fmt.Sprintf("%s.%s.svc.cluster.local", serverName, ns.Name) + + tls := generateTLSSecretData(clusterDomain, serverName, ns.Name) + httpPassword := argocdutil.GenerateRandomString(24) + internalToken := argocdutil.GenerateRandomString(24) + sshKeys := generateSSHKeyPair() + + server = &Server{ + namespace: ns.Name, + serviceName: serverName, + clusterDomain: clusterDomain, + httpUsername: gitUsername, + httpPassword: httpPassword, + sshPrivateKey: sshKeys.privateKeyPEM, + sshPublicKey: strings.TrimSpace(sshKeys.publicKey), + caCert: tls["ca.crt"], + } + + tlsSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: serverName + "-tls", + Namespace: ns.Name, + Labels: fixture.NamespaceLabels, + }, + Type: corev1.SecretTypeTLS, + Data: tls, + } + Expect(k8sClient.Create(ctx, tlsSecret)).To(Succeed()) + + httpCredentialsSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: serverName + "-http-credentials", + Namespace: ns.Name, + Labels: fixture.NamespaceLabels, + }, + Type: corev1.SecretTypeOpaque, + StringData: map[string]string{ + "username": gitUsername, + "password": httpPassword, + }, + } + Expect(k8sClient.Create(ctx, httpCredentialsSecret)).To(Succeed()) + + sshCredentialsSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: serverName + "-ssh-credentials", + Namespace: ns.Name, + Labels: fixture.NamespaceLabels, + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "ssh-privatekey": sshKeys.privateKeyPEM, + "ssh-publickey": []byte(strings.TrimSpace(sshKeys.publicKey)), + }, + } + Expect(k8sClient.Create(ctx, sshCredentialsSecret)).To(Succeed()) + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: serverName, + Namespace: ns.Name, + Labels: map[string]string{ + fixture.E2ETestLabelsKey: fixture.E2ETestLabelsValue, + "app.kubernetes.io/name": serverName, + "app.kubernetes.io/component": "git-server", + "app.kubernetes.io/instance": serverName, + "app.kubernetes.io/managed-by": "argocd-operator-e2e", + }, + }, + Spec: corev1.PodSpec{ + SecurityContext: &corev1.PodSecurityContext{ + FSGroup: ptr.To(int64(1000)), + }, + Containers: []corev1.Container{ + { + Name: serverName, + Image: giteaImage, + ImagePullPolicy: corev1.PullIfNotPresent, + Env: giteaEnvVars(clusterDomain, internalToken), + Ports: []corev1.ContainerPort{ + {Name: "http", ContainerPort: httpPort, Protocol: corev1.ProtocolTCP}, + {Name: "ssh", ContainerPort: sshPort, Protocol: corev1.ProtocolTCP}, + }, + VolumeMounts: []corev1.VolumeMount{ + {Name: "data", MountPath: "/data"}, + {Name: "tls", MountPath: "/etc/gitea/certs", ReadOnly: true}, + {Name: "http-credentials", MountPath: "/etc/gitea/credentials/http", ReadOnly: true}, + {Name: "ssh-credentials", MountPath: "/etc/gitea/credentials/ssh", ReadOnly: true}, + }, + ReadinessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + TCPSocket: &corev1.TCPSocketAction{ + Port: intstr.FromInt32(httpPort), + }, + }, + InitialDelaySeconds: 10, + PeriodSeconds: 5, + }, + SecurityContext: &corev1.SecurityContext{ + RunAsUser: ptr.To(int64(1000)), + RunAsGroup: ptr.To(int64(1000)), + SeccompProfile: &corev1.SeccompProfile{ + Type: corev1.SeccompProfileTypeRuntimeDefault, + }, + AllowPrivilegeEscalation: ptr.To(false), + Capabilities: &corev1.Capabilities{ + Drop: []corev1.Capability{ + "ALL", + }, + }, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: "data", + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{}, + }, + }, + { + Name: "tls", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{SecretName: tlsSecret.Name}, + }, + }, + { + Name: "http-credentials", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{SecretName: httpCredentialsSecret.Name}, + }, + }, + { + Name: "ssh-credentials", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{SecretName: sshCredentialsSecret.Name}, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, pod)).To(Succeed()) + + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: serverName, + Namespace: ns.Name, + Labels: fixture.NamespaceLabels, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Selector: map[string]string{ + "app.kubernetes.io/name": serverName, + }, + Ports: []corev1.ServicePort{ + { + Name: "https", + Port: httpPort, + TargetPort: intstr.FromString("http"), + Protocol: corev1.ProtocolTCP, + }, + { + Name: "ssh", + Port: sshServicePort, + TargetPort: intstr.FromInt32(sshPort), + Protocol: corev1.ProtocolTCP, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, service)).To(Succeed()) + + Eventually(pod, "2m", "10s").Should(podFixture.HavePhase(corev1.PodRunning)) + + By("exposing Git server outside the cluster") + server.httpsRoute = &routev1.Route{ + ObjectMeta: metav1.ObjectMeta{ + Name: serverName + "-https", + Namespace: ns.Name, + Labels: fixture.NamespaceLabels, + }, + Spec: routev1.RouteSpec{ + To: routev1.RouteTargetReference{ + Kind: "Service", + Name: service.Name, + Weight: ptr.To(int32(100)), + }, + Port: &routev1.RoutePort{ + TargetPort: intstr.FromString("https"), + }, + TLS: &routev1.TLSConfig{ + Termination: routev1.TLSTerminationPassthrough, + }, + }, + } + Expect(k8sClient.Create(ctx, server.httpsRoute)).To(Succeed()) + Eventually(func(g Gomega) { + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(server.httpsRoute), server.httpsRoute)).To(Succeed()) + g.Expect(server.httpsRoute.Status.Ingress).NotTo(BeEmpty()) + g.Expect(server.httpsRoute.Status.Ingress[0].Host).NotTo(BeEmpty()) + }, "3m", "5s").Should(Succeed()) + + server.domain = server.httpsRoute.Status.Ingress[0].Host + server.httpURL = fmt.Sprintf("https://%s", server.domain) + server.localSSHPort = reserveLocalSSHPort() + server.stopPortForward = startSSHPortForward(ns.Name, service.Name, server.localSSHPort) + GinkgoWriter.Printf("Git server HTTPS endpoint: %s\n", server.httpURL) + GinkgoWriter.Printf("Git server local SSH endpoint: 127.0.0.1:%d\n", server.localSSHPort) + + configureGiteaAdmin(server) + server.sshKnownHosts = fetchSSHKnownHosts(server) + + By("registering repository credentials for Argo CD to use") + repoCredentialsSecrets := server.repoCredentialsSecrets(ns.Name) + for _, secret := range repoCredentialsSecrets { + Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + } + + cleanup = func() { + server.removeSSHKeyFile() + if server.stopPortForward != nil { + server.stopPortForward() + } + if server.httpsRoute != nil { + err := k8sClient.Delete(ctx, server.httpsRoute) + if err != nil && !apierrors.IsNotFound(err) { + GinkgoWriter.Println("gitserver cleanup:", client.ObjectKeyFromObject(server.httpsRoute), err) + } + } + + resources := []client.Object{ + service, pod, sshCredentialsSecret, httpCredentialsSecret, tlsSecret, + } + for _, secret := range repoCredentialsSecrets { + resources = append(resources, secret) + } + for _, obj := range resources { + err := k8sClient.Delete(ctx, obj) + if err != nil && !apierrors.IsNotFound(err) { + GinkgoWriter.Println("gitserver cleanup:", client.ObjectKeyFromObject(obj), err) + } + } + } + + return server, cleanup +} + +func (s *Server) sshRepoURLPrefix() string { + return fmt.Sprintf("ssh://%s@%s:%d/%s/", giteaSSHLogin, s.clusterDomain, sshServicePort, s.httpUsername) +} + +// SSHKnownHosts returns the known_hosts entry for the server's SSH host key. +func (s *Server) SSHKnownHosts() string { + Expect(s.sshKnownHosts).NotTo(BeEmpty()) + return s.sshKnownHosts +} + +func (s *Server) httpRepoURLPrefix() string { + return fmt.Sprintf("https://%s:%d/%s/", s.clusterDomain, httpPort, s.httpUsername) +} + +func (s *Server) repoCredentialsSecrets(namespace string) []*corev1.Secret { + return []*corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: serverName + "-argocd-ssh-repo-creds", + Namespace: namespace, + Labels: map[string]string{ + common.ArgoCDSecretTypeLabel: "repo-creds", + }, + }, + StringData: map[string]string{ + "type": "git", + "url": s.sshRepoURLPrefix(), + "sshPrivateKey": string(s.getSSHPrivateKey()), + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: serverName + "-argocd-ssh-repo-write-creds", + Namespace: namespace, + Labels: map[string]string{ + common.ArgoCDSecretTypeLabel: "repo-write-creds", + }, + }, + StringData: map[string]string{ + "type": "git", + "url": s.sshRepoURLPrefix(), + "sshPrivateKey": string(s.getSSHPrivateKey()), + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: serverName + "-argocd-http-repo-creds", + Namespace: namespace, + Labels: map[string]string{ + common.ArgoCDSecretTypeLabel: "repo-creds", + }, + }, + StringData: map[string]string{ + "type": "git", + "url": s.httpRepoURLPrefix(), + "username": s.httpUsername, + "password": s.httpPassword, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: serverName + "-argocd-http-repo-write-creds", + Namespace: namespace, + Labels: map[string]string{ + common.ArgoCDSecretTypeLabel: "repo-write-creds", + }, + }, + StringData: map[string]string{ + "type": "git", + "url": s.httpRepoURLPrefix(), + "username": s.httpUsername, + "password": s.httpPassword, + }, + }, + } +} + +func (s *Server) CreateRepo(repoName string) Repo { + _, err := giteaAPIPost( + s.namespace, + s.httpPassword, + fmt.Sprintf("/api/v1/admin/users/%s/repos", gitUsername), + fmt.Sprintf(`{"name":"%s","private":false}`, repoName), + ) + if err != nil { + GinkgoWriter.Println("gitea repo create returned error (may already exist):", err) + } + + Eventually(func() error { + output, err := giteaAPIGet(s.namespace, s.httpPassword, + fmt.Sprintf("/api/v1/repos/%s/%s", gitUsername, repoName), + ) + if err != nil { + return err + } + if !strings.Contains(output, repoName) { + return fmt.Errorf("repository %q not found in API response", repoName) + } + return nil + }, "30s", "5s").Should(Succeed()) + + return Repo{ + server: s, + repoName: repoName, + } +} + +func reserveLocalSSHPort() int { + listener, err := net.Listen("tcp", "127.0.0.1:0") + Expect(err).NotTo(HaveOccurred()) + port := listener.Addr().(*net.TCPAddr).Port + Expect(listener.Close()).To(Succeed()) + return port +} + +func startSSHPortForward(namespace, serviceName string, localPort int) func() { + portMapping := fmt.Sprintf("%d:%d", localPort, sshServicePort) + cmdArgs := []string{"kubectl", "port-forward", "-n", namespace, "svc/" + serviceName, portMapping} + GinkgoWriter.Println("executing command:", cmdArgs) + + // #nosec G204 + cmd := exec.Command(cmdArgs[0], cmdArgs[1:]...) + + stdout, err := cmd.StdoutPipe() + Expect(err).NotTo(HaveOccurred()) + stderr, err := cmd.StderrPipe() + Expect(err).NotTo(HaveOccurred()) + + ready := make(chan struct{}) + streamOutput := func(pipe io.Reader, signalReady func()) { + defer GinkgoRecover() + + scanner := bufio.NewScanner(pipe) + for scanner.Scan() { + line := scanner.Text() + GinkgoWriter.Println("port-forward:", line) + if signalReady != nil && strings.HasPrefix(line, "Forwarding from") { + signalReady() + signalReady = nil + } + } + if scanErr := scanner.Err(); scanErr != nil { + GinkgoWriter.Println("port-forward scanner error:", scanErr) + } + } + + Expect(cmd.Start()).To(Succeed()) + go streamOutput(stdout, func() { close(ready) }) + go streamOutput(stderr, nil) + + select { + case <-ready: + GinkgoWriter.Println("SSH port-forward is ready") + case <-time.After(60 * time.Second): + Fail("timed out waiting for SSH port-forward to be ready") + } + + go func() { + defer GinkgoRecover() + if err := cmd.Wait(); err != nil && !strings.Contains(err.Error(), "killed") { + GinkgoWriter.Println("port-forward process error:", err) + } + }() + + return func() { + GinkgoWriter.Println("terminating SSH port-forward") + _ = cmd.Process.Kill() + } +} diff --git a/test/openshift/e2e/ginkgo/fixture/gitserver/webhook.go b/test/openshift/e2e/ginkgo/fixture/gitserver/webhook.go new file mode 100644 index 00000000000..7da9419c2cd --- /dev/null +++ b/test/openshift/e2e/ginkgo/fixture/gitserver/webhook.go @@ -0,0 +1,122 @@ +package gitserver + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "maps" + "net/http" + "slices" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + argov1beta1api "github.com/argoproj-labs/argocd-operator/api/v1beta1" +) + +type gogsPushWebhookPayload struct { + Ref string `json:"ref"` + Before string `json:"before"` + After string `json:"after"` + Commits []gogsPushWebhookCommit `json:"commits"` + Repository gogsPushWebhookRepository `json:"repository"` +} + +type gogsPushWebhookCommit struct { + ID string `json:"id"` + Message string `json:"message"` + Added []string `json:"added"` + Removed []string `json:"removed"` + Modified []string `json:"modified"` +} + +type gogsPushWebhookRepository struct { + Name string `json:"name"` + FullName string `json:"full_name"` + HTMLURL string `json:"html_url"` + SSHURL string `json:"ssh_url"` + CloneURL string `json:"clone_url"` + DefaultBranch string `json:"default_branch"` + Private bool `json:"private"` +} + +// NotifyArgoCDWebhook posts a push webhook to Argo CD for the current HEAD commit. +func (r *Repo) NotifyArgoCDWebhook(argoCD *argov1beta1api.ArgoCD, commit Commit) error { + Expect(argoCD).NotTo(BeNil()) + hosts := strings.Split(argoCD.Status.Host, ", ") + Expect(hosts).NotTo(BeEmpty()) + + branch := commit.Branch + if branch == "" { + branch = "main" + } + + commitSHA, err := r.git("rev-parse", "HEAD") + if err != nil { + return fmt.Errorf("failed to resolve pushed commit: %w: %q", err, commitSHA) + } + + ref := branch + if !strings.HasPrefix(ref, "refs/") { + ref = "refs/heads/" + branch + } + + changedFiles := slices.Collect(maps.Keys(commit.Files)) + commitSHA = strings.TrimSpace(commitSHA) + payload := gogsPushWebhookPayload{ + Ref: ref, + Before: strings.Repeat("0", 40), + After: commitSHA, + Commits: []gogsPushWebhookCommit{{ + ID: commitSHA, + Message: defaultCommitMessage, + Modified: append([]string(nil), changedFiles...), + }}, + Repository: gogsPushWebhookRepository{ + Name: r.repoName, + FullName: fmt.Sprintf("%s/%s", r.server.httpUsername, r.repoName), + HTMLURL: fmt.Sprintf("https://%s/%s/%s", r.server.domain, r.server.httpUsername, r.repoName), + SSHURL: r.GetRepoSshURL(), + CloneURL: r.GetRepoHttpURL(), + DefaultBranch: "main", + Private: false, + }, + } + + body, err := json.Marshal(payload) + if err != nil { + return err + } + + webhookURL := fmt.Sprintf("https://%s/api/webhook", hosts[0]) + client := &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // #nosec G402 // e2e test against operator-managed routes + }, + } + + var lastStatus int + Eventually(func(g Gomega) { + req, err := http.NewRequest(http.MethodPost, webhookURL, bytes.NewReader(body)) + g.Expect(err).NotTo(HaveOccurred()) + req.Host = hosts[0] + req.Header.Set("Host", hosts[0]) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Gogs-Event", "push") + req.Header.Set("X-Gitea-Event", "push") + + resp, err := client.Do(req) + g.Expect(err).NotTo(HaveOccurred()) + defer resp.Body.Close() + lastStatus = resp.StatusCode + g.Expect(resp.StatusCode).To(BeNumerically(">=", http.StatusOK)) + g.Expect(resp.StatusCode).To(BeNumerically("<", http.StatusMultipleChoices)) + }, "2m", "5s").Should(Succeed()) + + GinkgoWriter.Printf("notified Argo CD webhook %s for branch %s@%s (status %d)\n", webhookURL, branch, commitSHA, lastStatus) + return nil +} diff --git a/test/openshift/e2e/ginkgo/fixture/k8s/fixture.go b/test/openshift/e2e/ginkgo/fixture/k8s/fixture.go index 7f1f3486e49..0a0a751fa40 100644 --- a/test/openshift/e2e/ginkgo/fixture/k8s/fixture.go +++ b/test/openshift/e2e/ginkgo/fixture/k8s/fixture.go @@ -2,6 +2,8 @@ package k8s import ( "context" + "fmt" + "strings" "sigs.k8s.io/controller-runtime/pkg/client" @@ -115,10 +117,11 @@ func ExistByNameWithClient(k8sClient client.Client) matcher.GomegaMatcher { return WithTransform(func(k8sObject client.Object) bool { err := k8sClient.Get(context.Background(), client.ObjectKeyFromObject(k8sObject), k8sObject) + kind := strings.TrimPrefix(fmt.Sprintf("%T", k8sObject), "*") if err != nil { - GinkgoWriter.Println("Object does not exists in ExistByName:", k8sObject.GetName(), err) + GinkgoWriter.Println(kind, "does not exists in ExistByName:", k8sObject.GetName(), err) } else { - GinkgoWriter.Println("Object exists in ExistByName:", k8sObject.GetName()) + GinkgoWriter.Println(kind, "exists in ExistByName:", k8sObject.GetName()) } return err == nil }, BeTrue()) diff --git a/test/openshift/e2e/ginkgo/parallel/1-141_source_hydrator.go b/test/openshift/e2e/ginkgo/parallel/1-141_source_hydrator.go new file mode 100644 index 00000000000..cc383dc076e --- /dev/null +++ b/test/openshift/e2e/ginkgo/parallel/1-141_source_hydrator.go @@ -0,0 +1,530 @@ +package parallel + +import ( + "context" + _ "embed" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + v1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/argoproj-labs/argocd-operator/common" + "github.com/redhat-developer/gitops-operator/test/openshift/e2e/ginkgo/fixture/gitserver" + k8sFixture "github.com/redhat-developer/gitops-operator/test/openshift/e2e/ginkgo/fixture/k8s" + + "github.com/argoproj/argo-cd/gitops-engine/pkg/health" + synccommon "github.com/argoproj/argo-cd/gitops-engine/pkg/sync/common" + argocdv1alpha1 "github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1" + + argov1beta1api "github.com/argoproj-labs/argocd-operator/api/v1beta1" + "github.com/redhat-developer/gitops-operator/test/openshift/e2e/ginkgo/fixture" + appFixture "github.com/redhat-developer/gitops-operator/test/openshift/e2e/ginkgo/fixture/application" + argocdFixture "github.com/redhat-developer/gitops-operator/test/openshift/e2e/ginkgo/fixture/argocd" + configmapFixture "github.com/redhat-developer/gitops-operator/test/openshift/e2e/ginkgo/fixture/configmap" + fixtureUtils "github.com/redhat-developer/gitops-operator/test/openshift/e2e/ginkgo/fixture/utils" +) + +var _ = Describe("GitOps Operator Parallel E2E Tests", func() { + + Context("1-141_source_hydrator", func() { + + var ( + ctx context.Context + k8sClient client.Client + ns *corev1.Namespace + + nsCleanup func() + gitServerCleanup func() + gitRepoCleanup func() + + helmDrySourceFiles = map[string]string{ + "helm/Chart.yaml": ` +apiVersion: v2 +name: source-hydrator-helm +version: 0.1.0 +`, + "helm/values.yaml": ` +foo: base +`, + "helm/templates/configmap.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: source-hydrator-helm-test +data: + foo: {{ .Values.foo | quote }} +`, + } + kustomizeDrySourceFiles = map[string]string{ + "app/base/kustomization.yaml": ` +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- configmap.yaml +`, + "app/base/configmap.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: source-hydrator-test +data: + foo: base +`, + "app/overlays/prod/kustomization.yaml": ` +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- ../../base +patches: +- target: + kind: ConfigMap + name: source-hydrator-test + patch: |- + - op: replace + path: /data/foo + value: prod +`, + } + ) + + BeforeEach(func() { + fixture.EnsureParallelCleanSlate() + k8sClient, _ = fixtureUtils.GetE2ETestKubeClient() + ctx = context.Background() + }) + + AfterEach(func() { + fixture.OutputDebugOnFail(ns) + + if gitRepoCleanup != nil { + gitRepoCleanup() + } + if gitServerCleanup != nil { + gitServerCleanup() + } + nsCleanup() + }) + + It("activate Commit Server by Source Hydrator config", func() { + ns, nsCleanup = fixture.CreateRandomE2ETestNamespaceWithCleanupFunc() + + csService := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "example-commit-server", Namespace: ns.Name}} + csNetPol := &v1.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Name: "example-commit-server-network-policy", Namespace: ns.Name}} + csSA := &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "example-argocd-commit-server", Namespace: ns.Name}} + + argoCD := &argov1beta1api.ArgoCD{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example", + Namespace: ns.Name, + }, + // The existence of CommitServer section does NOT start the commit server - only when actual use-cases are detected. + Spec: argov1beta1api.ArgoCDSpec{ + CommitServer: argov1beta1api.ArgoCDCommitServerSpec{ + LogLevel: "debug", + }, + }, + } + + assertRunning := func(running bool) { + exist := k8sFixture.ExistByName() + + if running { + haveStatus := argocdFixture.HaveCommitServerStatus("Running") + Eventually(argoCD, "30s", "5s").Should(haveStatus) + Consistently(argoCD, "10s", "2s").Should(haveStatus) + Expect(csService).Should(exist) + Expect(csNetPol).Should(exist) + Expect(csSA).Should(exist) + } else { + haveStatus := argocdFixture.HaveCommitServerStatus("") + Eventually(argoCD, "30s", "5s").Should(haveStatus) + Consistently(argoCD, "10s", "2s").Should(haveStatus) + Expect(csService).ShouldNot(exist) + Expect(csNetPol).ShouldNot(exist) + Expect(csSA).ShouldNot(exist) + } + } + + By("Not running by default") + Expect(k8sClient.Create(ctx, argoCD)).To(Succeed()) + Eventually(argoCD, "5m", "5s").Should(argocdFixture.BeAvailable()) + assertRunning(false) + + By("Running when enabled") + argocdFixture.Update(argoCD, func(argoCD *argov1beta1api.ArgoCD) { + argoCD.Spec.SourceHydrator.Enabled = ptr.To(true) + }) + assertRunning(true) + + By("Not running when disabled") + argocdFixture.Update(argoCD, func(argoCD *argov1beta1api.ArgoCD) { + argoCD.Spec.SourceHydrator.Enabled = ptr.To(false) + }) + assertRunning(false) + }) + + It("apply CommitServer configuration options to the running CommitServer deployment", func() { + ns, nsCleanup = fixture.CreateRandomE2ETestNamespaceWithCleanupFunc() + + expectedCommand := []string{"/usr/local/bin/argocd-commit-server", "--loglevel", "info", "--logformat", "json"} + deploymentObjectKey := client.ObjectKey{Namespace: ns.Name, Name: "cs-options-commit-server"} + fetchDeployment := func() *appsv1.Deployment { + deploy := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, deploymentObjectKey, deploy)).Should(Succeed()) + return deploy + } + + By("creating ArgoCD with detailed CommitServer spec") + + logLevel := "info" + logFormat := "json" + annotations := map[string]string{ + "example-annotation": "commit-server-test", + } + labels := map[string]string{ + "example-label": "commit-server-test", + } + envVars := []corev1.EnvVar{ + {Name: "FOO", Value: "BAR"}, + {Name: "LOG_FEATURE", Value: "enabled"}, + } + resources := corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("300m"), + corev1.ResourceMemory: resource.MustParse("256Mi"), + }, + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("100m"), + corev1.ResourceMemory: resource.MustParse("128Mi"), + }, + } + initContainers := []corev1.Container{ + { + Name: "init-commit-server", + Image: common.ArgoCDDefaultArgoImage, + ImagePullPolicy: corev1.PullAlways, + Command: []string{"echo", "init"}, + }, + } + + argoCD := &argov1beta1api.ArgoCD{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cs-options", + Namespace: ns.Name, + }, + Spec: argov1beta1api.ArgoCDSpec{ + SourceHydrator: argov1beta1api.ArgoCDSourceHydratorSpec{ + Enabled: ptr.To(true), + }, + CommitServer: argov1beta1api.ArgoCDCommitServerSpec{ + LogLevel: logLevel, + LogFormat: logFormat, + Annotations: annotations, + Labels: labels, + Env: envVars, + Resources: &resources, + InitContainers: initContainers, + }, + }, + } + Expect(k8sClient.Create(ctx, argoCD)).To(Succeed()) + Eventually(argoCD, "5m", "5s").Should(argocdFixture.BeAvailable()) + + By("Attributes configured innitially") + deploy := fetchDeployment() + + Expect(deploy.Spec.Template.Annotations).To(HaveKeyWithValue("example-annotation", "commit-server-test")) + Expect(deploy.Spec.Template.Labels).To(HaveKeyWithValue("example-label", "commit-server-test")) + + container := deploy.Spec.Template.Spec.Containers[0] + Expect(container.Command).To(Equal(expectedCommand)) + for _, expectedEnv := range envVars { + Expect(container.Env).To(ContainElement(expectedEnv)) + } + Expect(container.Resources).To(Equal(resources)) + + Expect(deploy.Spec.Template.Spec.InitContainers).To(HaveLen(1)) + ic := deploy.Spec.Template.Spec.InitContainers[0] + Expect(ic.Name).To(Equal("init-commit-server")) + Expect(ic.Image).To(Equal(common.ArgoCDDefaultArgoImage)) + Expect(ic.Command).To(Equal([]string{"echo", "init"})) + + By("Annotations reconciles") + argocdFixture.Update(argoCD, func(argoCD *argov1beta1api.ArgoCD) { + argoCD.Spec.CommitServer.Annotations = nil + }) + Eventually(func() map[string]string { + return fetchDeployment().Spec.Template.Annotations + }, "5s", "1s").ToNot(HaveKey("example-annotation")) + + By("Labels reconciles") + argocdFixture.Update(argoCD, func(argoCD *argov1beta1api.ArgoCD) { + argoCD.Spec.CommitServer.Labels = nil + }) + Eventually(func() map[string]string { + return fetchDeployment().Spec.Template.Labels + }, "5s", "1s").ToNot(HaveKey("example-label")) + + By("Env reconciles") + argocdFixture.Update(argoCD, func(argoCD *argov1beta1api.ArgoCD) { + argoCD.Spec.CommitServer.Env = nil + }) + Eventually(func() []corev1.EnvVar { + return fetchDeployment().Spec.Template.Spec.Containers[0].Env + }, "5s", "1s").ToNot(ContainElement(corev1.EnvVar{Name: "FOO", Value: "BAR"})) + + By("Resources reconciles") + argocdFixture.Update(argoCD, func(argoCD *argov1beta1api.ArgoCD) { + argoCD.Spec.CommitServer.Resources.Limits = nil + }) + Eventually(func() corev1.ResourceRequirements { + return fetchDeployment().Spec.Template.Spec.Containers[0].Resources + }, "5s", "1s").To(Equal(corev1.ResourceRequirements{Requests: resources.Requests})) // Same Requests, no Limits + + By("InitContainers reconciles") + argocdFixture.Update(argoCD, func(argoCD *argov1beta1api.ArgoCD) { + argoCD.Spec.CommitServer.InitContainers[0].Command = []string{"echo", "init-2"} + }) + Eventually(func() []string { + return fetchDeployment().Spec.Template.Spec.InitContainers[0].Command + }, "5s", "1s").To(Equal([]string{"echo", "init-2"})) + + By("Command reconciles") + argocdFixture.Update(argoCD, func(argoCD *argov1beta1api.ArgoCD) { + argoCD.Spec.CommitServer.LogLevel = "debug" + }) + Eventually(func() []string { + return fetchDeployment().Spec.Template.Spec.Containers[0].Command + }, "5s", "1s").To(ContainElement("debug")) + }) + + It("hydrate kustomize to another branch via ssh", func() { + ns, nsCleanup = fixture.CreateRandomE2ETestNamespaceWithCleanupFunc() + + server, cleanup := gitserver.StartServer(ctx, k8sClient, ns) + gitServerCleanup = cleanup + + argoCD := &argov1beta1api.ArgoCD{ + ObjectMeta: metav1.ObjectMeta{ + Name: "source-hydrator", + Namespace: ns.Name, + }, + Spec: argov1beta1api.ArgoCDSpec{ + InitialSSHKnownHosts: argov1beta1api.SSHHostsSpec{ + ExcludeDefaultHosts: true, + Keys: server.SSHKnownHosts(), + }, + SourceHydrator: argov1beta1api.ArgoCDSourceHydratorSpec{ + Enabled: ptr.To(true), + }, + // Expose host for the git push webhook to work. + Server: argov1beta1api.ArgoCDServerSpec{ + Route: argov1beta1api.ArgoCDRouteSpec{ + Enabled: true, + }, + Insecure: true, + }, + }, + } + Expect(k8sClient.Create(ctx, argoCD)).To(Succeed()) + Eventually(argoCD, "5m", "5s").Should(argocdFixture.BeAvailable()) + Expect(argoCD.Status.Host).NotTo(BeEmpty()) + + repo := server.CreateRepo("hydrator-kustomize") + + By("pushing dry source before creating the Application") + cleanup, err := repo.Clone(gitserver.TransportSSH) + Expect(err).NotTo(HaveOccurred()) + gitRepoCleanup = cleanup + dryCommit := gitserver.Commit{ + Branch: "dry", + Files: kustomizeDrySourceFiles, + } + Expect(repo.CommitAndPush(dryCommit)).To(Succeed()) + + By("creating a test Argo CD Application") + app := &argocdv1alpha1.Application{ + ObjectMeta: metav1.ObjectMeta{Name: "hydrated", Namespace: ns.Name}, + Spec: argocdv1alpha1.ApplicationSpec{ + Project: "default", + Destination: argocdv1alpha1.ApplicationDestination{ + Namespace: ns.Name, + Server: "https://kubernetes.default.svc", + }, + + SourceHydrator: &argocdv1alpha1.SourceHydrator{ + DrySource: argocdv1alpha1.DrySource{ + Path: "app/overlays/prod", + RepoURL: repo.GetRepoSshURL(), + TargetRevision: "dry", + }, + SyncSource: argocdv1alpha1.SyncSource{ + Path: "app", + TargetBranch: "hydrated", + }, + }, + SyncPolicy: &argocdv1alpha1.SyncPolicy{ + Automated: &argocdv1alpha1.SyncPolicyAutomated{ + Prune: ptr.To(true), + SelfHeal: ptr.To(true), + }, + }, + }, + } + Expect(k8sClient.Create(ctx, app)).To(Succeed()) + Expect(repo.NotifyArgoCDWebhook(argoCD, dryCommit)).To(Succeed()) + + By("waiting for Source Hydrator to hydrate and sync the application") + Eventually(func(g Gomega) { + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(app), app)).To(Succeed()) + g.Expect(app.Status.SourceHydrator.CurrentOperation).NotTo(BeNil()) + g.Expect(app.Status.SourceHydrator.CurrentOperation.Phase).To( + Equal(argocdv1alpha1.HydrateOperationPhaseHydrated), + ) + }, "1m", "5s").Should(Succeed()) + Expect(app).Should(appFixture.HaveSyncStatusCode(argocdv1alpha1.SyncStatusCodeSynced)) + Expect(app).Should(appFixture.HaveHealthStatusCode(health.HealthStatusHealthy)) + + By("verifying prod overlay patch was applied to the synced ConfigMap") + syncedCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "source-hydrator-test", + Namespace: ns.Name, + }, + } + Expect(syncedCM).Should(configmapFixture.HaveStringDataKeyValue("foo", "prod")) + + By("verifying hydrated branch contains rendered manifests") + Expect(func(g Gomega) { + g.Expect(repo.CheckoutBranch("hydrated")).To(Succeed()) + manifest, err := repo.ReadFile("app/manifest.yaml") + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(manifest).To(ContainSubstring("kind: ConfigMap")) + g.Expect(manifest).To(ContainSubstring("name: source-hydrator-test")) + g.Expect(manifest).To(ContainSubstring("foo: prod")) + g.Expect(manifest).NotTo(ContainSubstring("foo: base")) + }).Should(Succeed()) + }) + + It("hydrate helm to another directory via https", func() { + ns, nsCleanup = fixture.CreateRandomE2ETestNamespaceWithCleanupFunc() + + server, cleanup := gitserver.StartServer(ctx, k8sClient, ns) + gitServerCleanup = cleanup + + argoCD := &argov1beta1api.ArgoCD{ + ObjectMeta: metav1.ObjectMeta{ + Name: "source-hydrator-helm", + Namespace: ns.Name, + }, + Spec: argov1beta1api.ArgoCDSpec{ + TLS: argov1beta1api.ArgoCDTLSSpec{ + InitialCerts: map[string]string{ + server.TLSHostKey(): string(server.GetCACert()), + }, + }, + SourceHydrator: argov1beta1api.ArgoCDSourceHydratorSpec{ + Enabled: ptr.To(true), + }, + // Expose host for the git push webhook to work. + Server: argov1beta1api.ArgoCDServerSpec{ + Route: argov1beta1api.ArgoCDRouteSpec{ + Enabled: true, + }, + Insecure: true, + }, + }, + } + Expect(k8sClient.Create(ctx, argoCD)).To(Succeed()) + Eventually(argoCD, "5m", "5s").Should(argocdFixture.BeAvailable()) + Expect(argoCD.Status.Host).NotTo(BeEmpty()) + + repo := server.CreateRepo("hydrator-helm") + + By("pushing dry source before creating the Application") + cleanup, err := repo.Clone(gitserver.TransportHTTPS) + Expect(err).NotTo(HaveOccurred()) + gitRepoCleanup = cleanup + dryCommit := gitserver.Commit{ + Branch: "main", + Files: helmDrySourceFiles, + } + Expect(repo.CommitAndPush(dryCommit)).To(Succeed()) + + By("creating a test Argo CD Application") + app := &argocdv1alpha1.Application{ + ObjectMeta: metav1.ObjectMeta{Name: "hydrated-helm", Namespace: ns.Name}, + Spec: argocdv1alpha1.ApplicationSpec{ + Project: "default", + Destination: argocdv1alpha1.ApplicationDestination{ + Namespace: ns.Name, + Server: "https://kubernetes.default.svc", + }, + SourceHydrator: &argocdv1alpha1.SourceHydrator{ + DrySource: argocdv1alpha1.DrySource{ + Path: "helm", + RepoURL: repo.GetRepoHttpURL(), + TargetRevision: "main", + Helm: &argocdv1alpha1.ApplicationSourceHelm{ + Parameters: []argocdv1alpha1.HelmParameter{ + {Name: "foo", Value: "helm"}, + }, + }, + }, + SyncSource: argocdv1alpha1.SyncSource{ + Path: "helm-output", + TargetBranch: "main", + }, + }, + }, + } + Expect(k8sClient.Create(ctx, app)).To(Succeed()) + Expect(repo.NotifyArgoCDWebhook(argoCD, dryCommit)).To(Succeed()) + + By("waiting for Source Hydrator to hydrate the application") + Eventually(func(g Gomega) { + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(app), app)).To(Succeed()) + g.Expect(app.Status.SourceHydrator.CurrentOperation).NotTo(BeNil()) + g.Expect(app.Status.SourceHydrator.CurrentOperation.Phase).To( + Equal(argocdv1alpha1.HydrateOperationPhaseHydrated), + ) + }, "1m", "5s").Should(Succeed()) + + By("syncing hydrated manifests to the cluster") + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(app), app)).To(Succeed()) + app.Operation = &argocdv1alpha1.Operation{ + Sync: &argocdv1alpha1.SyncOperation{}, + } + Expect(k8sClient.Update(ctx, app)).To(Succeed()) + Eventually(app, "2m", "5s").Should(appFixture.HaveOperationStatePhase(synccommon.OperationSucceeded)) + Expect(app).Should(appFixture.HaveSyncStatusCode(argocdv1alpha1.SyncStatusCodeSynced)) + Expect(app).Should(appFixture.HaveHealthStatusCode(health.HealthStatusHealthy)) + + By("verifying helm parameter was applied to the synced ConfigMap") + syncedCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "source-hydrator-helm-test", + Namespace: ns.Name, + }, + } + Expect(syncedCM).Should(configmapFixture.HaveStringDataKeyValue("foo", "helm")) + + By("verifying dry branch contains rendered manifests in helm-output") + Expect(func(g Gomega) { + g.Expect(repo.CheckoutBranch("main")).To(Succeed()) + manifest, err := repo.ReadFile("helm-output/manifest.yaml") + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(manifest).To(ContainSubstring("kind: ConfigMap")) + g.Expect(manifest).To(ContainSubstring("name: source-hydrator-helm-test")) + g.Expect(manifest).To(ContainSubstring("foo: helm")) + g.Expect(manifest).NotTo(ContainSubstring("foo: base")) + }).Should(Succeed()) + }) + }) +})