Repository: shini4i/argo-watcher (go)

Issue:
Deployment fail in specific case
Noticed failed deployment in following scenario:

application with CronJob only resource which triggers Job every hour. argo-watcher committed override, argocd synced it, but previous job configuration with old image. Unless new Job is triggered, the deployment's validation fails.

Pull request description:
feat(updater): add fire and forget mode
Closes #308 by implementing `argo-watcher/fire-and-forget` annotation.

Patch:
diff --git a/cmd/argo-watcher/argocd/argo_status_updater.go b/cmd/argo-watcher/argocd/argo_status_updater.go
index ef0746a..59ce177 100644
--- a/cmd/argo-watcher/argocd/argo_status_updater.go
+++ b/cmd/argo-watcher/argocd/argo_status_updater.go
@@ -95,6 +95,9 @@ func (updater *ArgoStatusUpdater) WaitForRollout(task models.Task) {
 
 	// get application status
 	status := application.GetRolloutStatus(task.ListImages(), updater.registryProxyUrl, updater.acceptSuspended)
+	if application.IsFireAndForgetModeActive() {
+		status = models.ArgoRolloutAppSuccess
+	}
 	if status == models.ArgoRolloutAppSuccess {
 		log.Info().Str("id", task.Id).Msg("App is running on the expected version.")
 		// deployment success
@@ -213,6 +216,11 @@ func (updater *ArgoStatusUpdater) waitRollout(task models.Task) (*models.Applica
 	_ = retry.Do(func() error {
 		application, err = updater.argo.api.GetApplication(task.App)
 
+		if application.IsFireAndForgetModeActive() {
+			log.Debug().Str("id", task.Id).Msg("Fire and forge mode is active, skipping checks...")
+			return nil
+		}
+
 		if err != nil {
 			// check if ArgoCD didn't have the app
 			if task.IsAppNotFoundError(err) {
diff --git a/docs/git-integration.md b/docs/git-integration.md
index 8bf8170..fb21a8a 100644
--- a/docs/git-integration.md
+++ b/docs/git-integration.md
@@ -78,6 +78,16 @@ sandbox/charts/demo/.argocd-source-demo.yaml
 
 > This is not an ideal solution, but so far it is the only way to reliably determine the correct override file to update.
 
+### Fire and forget mode
+
+In rare cases, we need to force update an application without waiting for image to be detected by ArgoCD as a running. For example for Applications with `CronJob` only.
+
+The following annotation will force argo-watcher to just update the image, and consider application `deployed`:
+
+```bash
+argo-watcher/fire-and-forget: "true"
+```
+
 ### Additional information
 
 - The `app` alias is intended to correspond with an alias specified in the `argo-watcher/ALIAS.helm.image-tag` annotation.
diff --git a/internal/models/argo.go b/internal/models/argo.go
index c8f1663..d0aea71 100644
--- a/internal/models/argo.go
+++ b/internal/models/argo.go
@@ -25,6 +25,7 @@ const (
 	managedGitBranch        = "argo-watcher/write-back-branch"
 	managedGitPath          = "argo-watcher/write-back-path"
 	managedGitFile          = "argo-watcher/write-back-filename"
+	fireAndForgetAnnotation = "argo-watcher/fire-and-forget"
 )
 
 type ApplicationOperationResource struct {
@@ -286,6 +287,14 @@ func (app *Application) UpdateGitImageTag(task *Task) error {
 	return nil
 }
 
+// IsFireAndForgetModeActive checks if 'fire-and-forget' mode is enabled in Application's annotations.
+func (app *Application) IsFireAndForgetModeActive() bool {
+	if app.Metadata.Annotations == nil {
+		return false
+	}
+	return app.Metadata.Annotations[fireAndForgetAnnotation] == "true"
+}
+
 // extractManagedImages extracts the managed images from the application's annotations.
 // It returns a map of the managed images, where the key is the application alias and the value is the image name.
 func extractManagedImages(annotations map[string]string) (map[string]string, error) {


Test patch:
diff --git a/internal/models/argo_test.go b/internal/models/argo_test.go
index 369bf81..b7dcaac 100644
--- a/internal/models/argo_test.go
+++ b/internal/models/argo_test.go
@@ -386,3 +386,49 @@ func TestExtractManagedImages(t *testing.T) {
 		})
 	}
 }
+
+func TestIsFireAndForgetModeActive(t *testing.T) {
+	tt := []struct {
+		name string
+		app  Application
+		want bool
+	}{
+		{
+			name: "FireAndForget mode active",
+			app: Application{
+				Metadata: ApplicationMetadata{
+					Annotations: map[string]string{
+						fireAndForgetAnnotation: "true",
+					},
+				},
+			},
+			want: true,
+		},
+		{
+			name: "FireAndForget mode inactive",
+			app: Application{
+				Metadata: ApplicationMetadata{
+					Annotations: map[string]string{
+						fireAndForgetAnnotation: "false",
+					},
+				},
+			},
+			want: false,
+		},
+		{
+			name: "Annotations are nil",
+			app: Application{
+				Metadata: ApplicationMetadata{
+					Annotations: nil,
+				},
+			},
+			want: false,
+		},
+	}
+
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			assert.Equal(t, tc.want, tc.app.IsFireAndForgetModeActive())
+		})
+	}
+}