diff --git a/manifests/base/rbac/argocd-image-updater-clusterrole.yaml b/manifests/base/rbac/argocd-image-updater-clusterrole.yaml index fb4bc82d..2c329f7b 100644 --- a/manifests/base/rbac/argocd-image-updater-clusterrole.yaml +++ b/manifests/base/rbac/argocd-image-updater-clusterrole.yaml @@ -13,3 +13,12 @@ rules: - events verbs: - create + - apiGroups: + - argoproj.io + resources: + - applications + verbs: + - get + - list + - update + - patch diff --git a/manifests/base/rbac/argocd-image-updater-role.yaml b/manifests/base/rbac/argocd-image-updater-role.yaml index aa7cd020..c1711f1d 100644 --- a/manifests/base/rbac/argocd-image-updater-role.yaml +++ b/manifests/base/rbac/argocd-image-updater-role.yaml @@ -16,12 +16,3 @@ rules: - get - list - watch - - apiGroups: - - argoproj.io - resources: - - applications - verbs: - - get - - list - - update - - patch diff --git a/manifests/install.yaml b/manifests/install.yaml index 165ac03f..5457af52 100644 --- a/manifests/install.yaml +++ b/manifests/install.yaml @@ -25,15 +25,6 @@ rules: - get - list - watch -- apiGroups: - - argoproj.io - resources: - - applications - verbs: - - get - - list - - update - - patch --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole @@ -50,6 +41,15 @@ rules: - events verbs: - create +- apiGroups: + - argoproj.io + resources: + - applications + verbs: + - get + - list + - update + - patch --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding diff --git a/pkg/argocd/argocd.go b/pkg/argocd/argocd.go index 4596f509..9846e7e5 100644 --- a/pkg/argocd/argocd.go +++ b/pkg/argocd/argocd.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/argoproj-labs/argocd-image-updater/pkg/common" "github.com/argoproj-labs/argocd-image-updater/pkg/image" @@ -25,40 +26,97 @@ type k8sClient struct { kubeClient *kube.KubernetesClient } +// GetApplication retrieves an application by name across all namespaces. func (client *k8sClient) GetApplication(ctx context.Context, appName string) (*v1alpha1.Application, error) { - return client.kubeClient.ApplicationsClientset.ArgoprojV1alpha1().Applications(client.kubeClient.Namespace).Get(ctx, appName, v1.GetOptions{}) + log.Debugf("Getting application %s across all namespaces", appName) + + // List all applications across all namespaces (using empty labelSelector) + appList, err := client.ListApplications("") + if err != nil { + return nil, fmt.Errorf("error listing applications: %w", err) + } + + // Filter applications by name using nameMatchesPattern + app, err := findApplicationByName(appList, appName) + if err != nil { + log.Errorf("error getting application: %v", err) + return nil, fmt.Errorf("error getting application: %w", err) + } + + // Retrieve the application in the specified namespace + return client.kubeClient.ApplicationsClientset.ArgoprojV1alpha1().Applications(app.Namespace).Get(ctx, app.Name, v1.GetOptions{}) } +// ListApplications lists all applications across all namespaces. func (client *k8sClient) ListApplications(labelSelector string) ([]v1alpha1.Application, error) { - list, err := client.kubeClient.ApplicationsClientset.ArgoprojV1alpha1().Applications(client.kubeClient.Namespace).List(context.TODO(), v1.ListOptions{LabelSelector: labelSelector}) + list, err := client.kubeClient.ApplicationsClientset.ArgoprojV1alpha1().Applications(v1.NamespaceAll).List(context.TODO(), v1.ListOptions{LabelSelector: labelSelector}) if err != nil { - return nil, err + return nil, fmt.Errorf("error listing applications: %w", err) } + log.Debugf("Applications listed: %d", len(list.Items)) return list.Items, nil } +// findApplicationByName filters the list of applications by name using nameMatchesPattern. +func findApplicationByName(appList []v1alpha1.Application, appName string) (*v1alpha1.Application, error) { + var matchedApps []*v1alpha1.Application + + for _, app := range appList { + log.Debugf("Found application: %s in namespace %s", app.Name, app.Namespace) + if nameMatchesPattern(app.Name, []string{appName}) { + log.Debugf("Application %s matches the pattern", app.Name) + matchedApps = append(matchedApps, &app) + } + } + + if len(matchedApps) == 0 { + return nil, fmt.Errorf("application %s not found", appName) + } + + if len(matchedApps) > 1 { + return nil, fmt.Errorf("multiple applications found matching %s", appName) + } + + return matchedApps[0], nil +} + func (client *k8sClient) UpdateSpec(ctx context.Context, spec *application.ApplicationUpdateSpecRequest) (*v1alpha1.ApplicationSpec, error) { - for { - app, err := client.kubeClient.ApplicationsClientset.ArgoprojV1alpha1().Applications(client.kubeClient.Namespace).Get(ctx, spec.GetName(), v1.GetOptions{}) + const defaultMaxRetries = 7 + const baseDelay = 100 * time.Millisecond // Initial delay before retrying + + // Allow overriding max retries for testing purposes + maxRetries := defaultMaxRetries + if overrideRetries, ok := os.LookupEnv("OVERRIDE_MAX_RETRIES"); ok { + var retries int + if _, err := fmt.Sscanf(overrideRetries, "%d", &retries); err == nil { + maxRetries = retries + } + } + + for attempts := 0; attempts < maxRetries; attempts++ { + app, err := client.GetApplication(ctx, spec.GetName()) if err != nil { - return nil, err + log.Errorf("could not get application: %s, error: %v", spec.GetName(), err) + return nil, fmt.Errorf("error getting application: %w", err) } app.Spec = *spec.Spec - updatedApp, err := client.kubeClient.ApplicationsClientset.ArgoprojV1alpha1().Applications(client.kubeClient.Namespace).Update(ctx, app, v1.UpdateOptions{}) + updatedApp, err := client.kubeClient.ApplicationsClientset.ArgoprojV1alpha1().Applications(app.Namespace).Update(ctx, app, v1.UpdateOptions{}) if err != nil { if errors.IsConflict(err) { + log.Warnf("conflict occurred while updating application: %s, retrying... (%d/%d)", spec.GetName(), attempts+1, maxRetries) + time.Sleep(baseDelay * (1 << attempts)) // Exponential backoff, multiply baseDelay by 2^attempts continue } - return nil, err + log.Errorf("could not update application: %s, error: %v", spec.GetName(), err) + return nil, fmt.Errorf("error updating application: %w", err) } return &updatedApp.Spec, nil } - + return nil, fmt.Errorf("max retries(%d) reached while updating application: %s", maxRetries, spec.GetName()) } -// NewAPIClient creates a new API client for ArgoCD and connects to the ArgoCD -// API server. +// NewK8SClient creates a new kubernetes client to interact with kubernetes api-server. func NewK8SClient(kubeClient *kube.KubernetesClient) (ArgoCD, error) { return &k8sClient{kubeClient: kubeClient}, nil } diff --git a/pkg/argocd/argocd_test.go b/pkg/argocd/argocd_test.go index 317875cb..ce565f21 100644 --- a/pkg/argocd/argocd_test.go +++ b/pkg/argocd/argocd_test.go @@ -3,6 +3,7 @@ package argocd import ( "context" "fmt" + "os" "testing" "github.com/argoproj-labs/argocd-image-updater/pkg/common" @@ -1024,59 +1025,193 @@ func TestKubernetesClient(t *testing.T) { t.Run("List applications", func(t *testing.T) { apps, err := client.ListApplications("") require.NoError(t, err) - require.Len(t, apps, 1) - - assert.ElementsMatch(t, []string{"test-app1"}, []string{app1.Name}) + require.Len(t, apps, 2) + assert.ElementsMatch(t, []string{"test-app1", "test-app2"}, []string{app1.Name, app2.Name}) }) - t.Run("Get application successful", func(t *testing.T) { + t.Run("Get application test-app1 successful", func(t *testing.T) { app, err := client.GetApplication(context.TODO(), "test-app1") require.NoError(t, err) assert.Equal(t, "test-app1", app.GetName()) }) + t.Run("Get application test-app2 successful", func(t *testing.T) { + app, err := client.GetApplication(context.TODO(), "test-app2") + require.NoError(t, err) + assert.Equal(t, "test-app2", app.GetName()) + }) + t.Run("Get application not found", func(t *testing.T) { - _, err := client.GetApplication(context.TODO(), "test-app2") + _, err := client.GetApplication(context.TODO(), "test-app-non-existent") require.Error(t, err) - assert.True(t, errors.IsNotFound(err)) + assert.Contains(t, err.Error(), "application test-app-non-existent not found") + }) + + t.Run("List and Get applications errors", func(t *testing.T) { + // Create a fake clientset + clientset := fake.NewSimpleClientset() + + // Simulate an error in the List action + clientset.PrependReactor("list", "applications", func(action k8stesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, errors.NewInternalError(fmt.Errorf("list error")) + }) + + // Create the Kubernetes client + client, err := NewK8SClient(&kube.KubernetesClient{ + ApplicationsClientset: clientset, + }) + require.NoError(t, err) + + // Test ListApplications error handling + apps, err := client.ListApplications("") + assert.Nil(t, apps) + assert.EqualError(t, err, "error listing applications: Internal error occurred: list error") + + // Test GetApplication error handling + _, err = client.GetApplication(context.TODO(), "test-app") + assert.Error(t, err) + assert.Contains(t, err.Error(), "error listing applications: Internal error occurred: list error") + }) + + t.Run("Get applications with multiple applications found", func(t *testing.T) { + // Create a fake clientset with multiple applications having the same name + clientset := fake.NewSimpleClientset( + &v1alpha1.Application{ + ObjectMeta: v1.ObjectMeta{Name: "test-app", Namespace: "ns1"}, + Spec: v1alpha1.ApplicationSpec{}, + }, + &v1alpha1.Application{ + ObjectMeta: v1.ObjectMeta{Name: "test-app", Namespace: "ns2"}, + Spec: v1alpha1.ApplicationSpec{}, + }, + ) + + // Create the Kubernetes client + client, err := NewK8SClient(&kube.KubernetesClient{ + ApplicationsClientset: clientset, + }) + require.NoError(t, err) + + // Test GetApplication with multiple matching applications + _, err = client.GetApplication(context.TODO(), "test-app") + assert.Error(t, err) + assert.EqualError(t, err, "error getting application: multiple applications found matching test-app") }) } -func TestKubernetesClient_UpdateSpec_Conflict(t *testing.T) { +func TestKubernetesClientUpdateSpec(t *testing.T) { app := &v1alpha1.Application{ ObjectMeta: v1.ObjectMeta{Name: "test-app", Namespace: "testns"}, } clientset := fake.NewSimpleClientset(app) - attempts := 0 - clientset.PrependReactor("update", "*", func(action k8stesting.Action) (handled bool, ret runtime.Object, err error) { - if attempts == 0 { - attempts++ - return true, nil, errors.NewConflict( - schema.GroupResource{Group: "argoproj.io", Resource: "Application"}, app.Name, fmt.Errorf("conflict updating %s", app.Name)) - } else { - return false, nil, nil - } + t.Run("Successful update after conflict retry", func(t *testing.T) { + attempts := 0 + clientset.PrependReactor("update", "*", func(action k8stesting.Action) (handled bool, ret runtime.Object, err error) { + if attempts == 0 { + attempts++ + return true, nil, errors.NewConflict( + schema.GroupResource{Group: "argoproj.io", Resource: "Application"}, app.Name, fmt.Errorf("conflict updating %s", app.Name)) + } else { + return false, nil, nil + } + }) + + client, err := NewK8SClient(&kube.KubernetesClient{ + ApplicationsClientset: clientset, + }) + require.NoError(t, err) + + appName := "test-app" + spec, err := client.UpdateSpec(context.TODO(), &application.ApplicationUpdateSpecRequest{ + Name: &appName, + Spec: &v1alpha1.ApplicationSpec{Source: &v1alpha1.ApplicationSource{ + RepoURL: "https://github.com/argoproj/argocd-example-apps", + }}, + }) + + require.NoError(t, err) + assert.Equal(t, "https://github.com/argoproj/argocd-example-apps", spec.Source.RepoURL) }) - client, err := NewK8SClient(&kube.KubernetesClient{ - Namespace: "testns", - ApplicationsClientset: clientset, + t.Run("UpdateSpec errors - application not found", func(t *testing.T) { + // Create a fake empty clientset + clientset := fake.NewSimpleClientset() + + client, err := NewK8SClient(&kube.KubernetesClient{ + ApplicationsClientset: clientset, + }) + require.NoError(t, err) + + appName := "test-app" + appNamespace := "testns" + spec := &application.ApplicationUpdateSpecRequest{ + Name: &appName, + AppNamespace: &appNamespace, + Spec: &v1alpha1.ApplicationSpec{}, + } + + _, err = client.UpdateSpec(context.TODO(), spec) + assert.Error(t, err) + assert.Contains(t, err.Error(), "error getting application: application test-app not found") }) - require.NoError(t, err) - appName := "test-app" + t.Run("UpdateSpec errors - conflict failing retries", func(t *testing.T) { + clientset := fake.NewSimpleClientset(&v1alpha1.Application{ + ObjectMeta: v1.ObjectMeta{Name: "test-app", Namespace: "testns"}, + Spec: v1alpha1.ApplicationSpec{}, + }) - spec, err := client.UpdateSpec(context.TODO(), &application.ApplicationUpdateSpecRequest{ - Name: &appName, - Spec: &v1alpha1.ApplicationSpec{Source: &v1alpha1.ApplicationSource{ - RepoURL: "https://github.com/argoproj/argocd-example-apps", - }}, + client, err := NewK8SClient(&kube.KubernetesClient{ + ApplicationsClientset: clientset, + }) + require.NoError(t, err) + + clientset.PrependReactor("update", "applications", func(action k8stesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, errors.NewConflict(v1alpha1.Resource("applications"), "test-app", fmt.Errorf("conflict error")) + }) + + os.Setenv("OVERRIDE_MAX_RETRIES", "0") + defer os.Unsetenv("OVERRIDE_MAX_RETRIES") + + appName := "test-app" + spec := &application.ApplicationUpdateSpecRequest{ + Name: &appName, + Spec: &v1alpha1.ApplicationSpec{}, + } + + _, err = client.UpdateSpec(context.TODO(), spec) + assert.Error(t, err) + assert.Contains(t, err.Error(), "max retries(0) reached while updating application: test-app") }) - require.NoError(t, err) + t.Run("UpdateSpec errors - non-conflict update error", func(t *testing.T) { + clientset := fake.NewSimpleClientset(&v1alpha1.Application{ + ObjectMeta: v1.ObjectMeta{Name: "test-app", Namespace: "testns"}, + Spec: v1alpha1.ApplicationSpec{}, + }) + + client, err := NewK8SClient(&kube.KubernetesClient{ + ApplicationsClientset: clientset, + }) + require.NoError(t, err) + + clientset.PrependReactor("update", "applications", func(action k8stesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, fmt.Errorf("non-conflict error") + }) - assert.Equal(t, "https://github.com/argoproj/argocd-example-apps", spec.Source.RepoURL) + appName := "test-app" + appNamespace := "testns" + spec := &application.ApplicationUpdateSpecRequest{ + Name: &appName, + AppNamespace: &appNamespace, + Spec: &v1alpha1.ApplicationSpec{}, + } + + _, err = client.UpdateSpec(context.TODO(), spec) + assert.Error(t, err) + assert.Contains(t, err.Error(), "error updating application: non-conflict error") + }) } func Test_parseImageList(t *testing.T) {