1
0
Fork 0
mirror of https://github.com/imjasonh/git-k8s synced 2026-07-12 16:38:37 +00:00
git-k8s/test/e2e/e2e_test.go
Claude 765007bb87
Fix stale resourceVersion bug in push controller
The push controller updates status to InProgress then later to
Succeeded/Failed. The first UpdateStatus call returns a new object with
an updated resourceVersion, but the code was discarding it. Subsequent
status updates used the stale resourceVersion, causing 409 Conflict
errors and an infinite retry loop where transactions never reached a
terminal phase.

Fix: capture the returned object from the InProgress UpdateStatus so
subsequent calls use the correct resourceVersion.

Also adds better timeout diagnostics in e2e test helpers.

https://claude.ai/code/session_01QfFzqKQUsxxBsiZUhuJ3pG
2026-02-28 04:09:56 +00:00

296 lines
8.5 KiB
Go

//go:build e2e
package e2e
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"testing"
"time"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/clientcmd"
gitv1alpha1 "github.com/imjasonh/git-k8s/pkg/apis/git/v1alpha1"
gitclient "github.com/imjasonh/git-k8s/pkg/client"
)
const (
testNamespace = "default"
giteaUsername = "testadmin"
giteaPassword = "testpassword123"
pollInterval = 2 * time.Second
pollTimeout = 2 * time.Minute
)
var (
gitClient *gitclient.GitV1alpha1Client
kubeClient kubernetes.Interface
giteaURL string // localhost URL for test process (via port-forward)
giteaInURL string // in-cluster URL for controllers
)
func TestMain(m *testing.M) {
// Resolve Gitea URLs.
giteaURL = envOrDefault("GITEA_URL", "http://localhost:3000")
giteaInURL = envOrDefault("GITEA_INTERNAL_URL", "http://gitea.git-system.svc.cluster.local:3000")
// Build clients from KUBECONFIG.
kubeconfig := os.Getenv("KUBECONFIG")
if kubeconfig == "" {
kubeconfig = clientcmd.RecommendedHomeFile
}
config, err := clientcmd.BuildConfigFromFlags("", kubeconfig)
if err != nil {
fmt.Fprintf(os.Stderr, "building kubeconfig: %v\n", err)
os.Exit(1)
}
kubeClient, err = kubernetes.NewForConfig(config)
if err != nil {
fmt.Fprintf(os.Stderr, "creating kubernetes client: %v\n", err)
os.Exit(1)
}
dynClient, err := dynamic.NewForConfig(config)
if err != nil {
fmt.Fprintf(os.Stderr, "creating dynamic client: %v\n", err)
os.Exit(1)
}
gitClient = gitclient.NewFromDynamic(dynClient)
os.Exit(m.Run())
}
func envOrDefault(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// createGiteaRepo creates a repository on Gitea via the API.
func createGiteaRepo(t *testing.T, name string) {
t.Helper()
body, _ := json.Marshal(map[string]interface{}{
"name": name,
"auto_init": true,
"default_branch": "main",
})
req, err := http.NewRequest("POST", giteaURL+"/api/v1/user/repos", bytes.NewReader(body))
if err != nil {
t.Fatalf("creating request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.SetBasicAuth(giteaUsername, giteaPassword)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("creating Gitea repo %q: %v", name, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("creating Gitea repo %q: status %d", name, resp.StatusCode)
}
t.Logf("Created Gitea repo %q", name)
}
// getGiteaBranchCommit fetches the HEAD commit of a branch from Gitea.
func getGiteaBranchCommit(t *testing.T, repo, branch string) string {
t.Helper()
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/branches/%s", giteaURL, giteaUsername, repo, branch)
req, _ := http.NewRequest("GET", url, nil)
req.SetBasicAuth(giteaUsername, giteaPassword)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("getting branch %s/%s: %v", repo, branch, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("getting branch %s/%s: status %d", repo, branch, resp.StatusCode)
}
var result struct {
Commit struct {
ID string `json:"id"`
} `json:"commit"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("decoding branch response: %v", err)
}
return result.Commit.ID
}
// giteaBranchExists checks if a branch exists on Gitea.
func giteaBranchExists(t *testing.T, repo, branch string) bool {
t.Helper()
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/branches/%s", giteaURL, giteaUsername, repo, branch)
req, _ := http.NewRequest("GET", url, nil)
req.SetBasicAuth(giteaUsername, giteaPassword)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("checking branch %s/%s: %v", repo, branch, err)
}
resp.Body.Close()
return resp.StatusCode == http.StatusOK
}
// createCredentialsSecret creates a Secret with Gitea credentials.
// It returns the secret name. The secret is cleaned up when the test finishes.
func createCredentialsSecret(t *testing.T, name string) string {
t.Helper()
ctx := context.Background()
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: testNamespace,
},
StringData: map[string]string{
"username": giteaUsername,
"password": giteaPassword,
},
}
_, err := kubeClient.CoreV1().Secrets(testNamespace).Create(ctx, secret, metav1.CreateOptions{})
if err != nil {
t.Fatalf("creating secret %q: %v", name, err)
}
t.Cleanup(func() {
kubeClient.CoreV1().Secrets(testNamespace).Delete(ctx, name, metav1.DeleteOptions{})
})
return name
}
// createGitRepository creates a GitRepository CR pointing at a Gitea repo.
func createGitRepository(t *testing.T, name, repoName, secretName string) *gitv1alpha1.GitRepository {
t.Helper()
ctx := context.Background()
repo := &gitv1alpha1.GitRepository{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: testNamespace,
},
Spec: gitv1alpha1.GitRepositorySpec{
URL: fmt.Sprintf("%s/%s/%s.git", giteaInURL, giteaUsername, repoName),
DefaultBranch: "main",
Auth: &gitv1alpha1.GitAuth{
SecretRef: &gitv1alpha1.SecretRef{Name: secretName},
},
},
}
created, err := gitClient.GitRepositories(testNamespace).Create(ctx, repo, metav1.CreateOptions{})
if err != nil {
t.Fatalf("creating GitRepository %q: %v", name, err)
}
t.Cleanup(func() {
gitClient.GitRepositories(testNamespace).Delete(ctx, name, metav1.DeleteOptions{})
})
return created
}
// createGitBranch creates a GitBranch CR.
func createGitBranch(t *testing.T, name, repoRef, branchName string) *gitv1alpha1.GitBranch {
t.Helper()
ctx := context.Background()
branch := &gitv1alpha1.GitBranch{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: testNamespace,
},
Spec: gitv1alpha1.GitBranchSpec{
RepositoryRef: repoRef,
BranchName: branchName,
},
}
created, err := gitClient.GitBranches(testNamespace).Create(ctx, branch, metav1.CreateOptions{})
if err != nil {
t.Fatalf("creating GitBranch %q: %v", name, err)
}
t.Cleanup(func() {
gitClient.GitBranches(testNamespace).Delete(ctx, name, metav1.DeleteOptions{})
})
return created
}
// waitForPushTransaction polls until the GitPushTransaction reaches a terminal phase.
func waitForPushTransaction(t *testing.T, name string) *gitv1alpha1.GitPushTransaction {
t.Helper()
ctx := context.Background()
deadline := time.Now().Add(pollTimeout)
var lastTxn *gitv1alpha1.GitPushTransaction
for time.Now().Before(deadline) {
txn, err := gitClient.GitPushTransactions(testNamespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
t.Logf("getting push transaction %q: %v (retrying)", name, err)
time.Sleep(pollInterval)
continue
}
lastTxn = txn
t.Logf("GitPushTransaction %q phase: %s", name, txn.Status.Phase)
switch txn.Status.Phase {
case gitv1alpha1.TransactionPhaseSucceeded:
return txn
case gitv1alpha1.TransactionPhaseFailed:
t.Fatalf("GitPushTransaction %q failed: %s", name, txn.Status.Message)
}
time.Sleep(pollInterval)
}
if lastTxn != nil {
t.Logf("Last seen state: phase=%q message=%q resourceVersion=%s", lastTxn.Status.Phase, lastTxn.Status.Message, lastTxn.ResourceVersion)
}
t.Fatalf("timed out waiting for GitPushTransaction %q", name)
return nil
}
// waitForGitBranchCommit polls until the GitBranch has a non-empty headCommit.
func waitForGitBranchCommit(t *testing.T, name string) string {
t.Helper()
ctx := context.Background()
deadline := time.Now().Add(pollTimeout)
for time.Now().Before(deadline) {
branch, err := gitClient.GitBranches(testNamespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
time.Sleep(pollInterval)
continue
}
if branch.Status.HeadCommit != "" {
return branch.Status.HeadCommit
}
time.Sleep(pollInterval)
}
t.Fatalf("timed out waiting for GitBranch %q headCommit", name)
return ""
}
// waitForSyncPhase polls until the GitRepoSync reaches a non-empty phase.
func waitForSyncPhase(t *testing.T, name string) *gitv1alpha1.GitRepoSync {
t.Helper()
ctx := context.Background()
deadline := time.Now().Add(pollTimeout)
for time.Now().Before(deadline) {
syncObj, err := gitClient.GitRepoSyncs(testNamespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
time.Sleep(pollInterval)
continue
}
if syncObj.Status.Phase != "" {
t.Logf("GitRepoSync %q phase: %s", name, syncObj.Status.Phase)
return syncObj
}
time.Sleep(pollInterval)
}
t.Fatalf("timed out waiting for GitRepoSync %q phase", name)
return nil
}