Repository: shini4i/argo-watcher (go)

Issue:
add support for applications that are using argo rollouts
Currently, we expect the application to be Healthy and Synced, but in the case of Argo Rollouts, there might be cases when the application is intentionally left in Suspended health. We need to add some sort of workaround that will support such use cases.

One of the options would be to introduce a feature toggle that will accept both Healthy and Suspended values during deployment.

Pull request description:
feat: add support for Rollout kind in Application
Adds support for Rollout kind in Application. Basically, it just accepts "Suspended" alongside "Healthy" value.

Closes #248 

Patch:
diff --git a/cmd/argo-watcher/argocd/argo_status_updater.go b/cmd/argo-watcher/argocd/argo_status_updater.go
index aa1b9cc..fb3a478 100644
--- a/cmd/argo-watcher/argocd/argo_status_updater.go
+++ b/cmd/argo-watcher/argocd/argo_status_updater.go
@@ -33,9 +33,10 @@ type ArgoStatusUpdater struct {
 	registryProxyUrl string
 	retryOptions     []retry.Option
 	mutex            MutexMap
+	acceptSuspended  bool
 }
 
-func (updater *ArgoStatusUpdater) Init(argo Argo, retryAttempts uint, retryDelay time.Duration, registryProxyUrl string) {
+func (updater *ArgoStatusUpdater) Init(argo Argo, retryAttempts uint, retryDelay time.Duration, registryProxyUrl string, acceptSuspended bool) {
 	updater.argo = argo
 	updater.registryProxyUrl = registryProxyUrl
 	updater.retryOptions = []retry.Option{
@@ -44,6 +45,7 @@ func (updater *ArgoStatusUpdater) Init(argo Argo, retryAttempts uint, retryDelay
 		retry.Delay(retryDelay),
 		retry.LastErrorOnly(true),
 	}
+	updater.acceptSuspended = acceptSuspended
 }
 
 func (updater *ArgoStatusUpdater) collectInitialAppStatus(task *models.Task) error {
@@ -52,7 +54,7 @@ func (updater *ArgoStatusUpdater) collectInitialAppStatus(task *models.Task) err
 		return err
 	}
 
-	status := application.GetRolloutStatus(task.ListImages(), updater.registryProxyUrl)
+	status := application.GetRolloutStatus(task.ListImages(), updater.registryProxyUrl, updater.acceptSuspended)
 
 	// sort images to avoid hash mismatch
 	slices.Sort(application.Status.Summary.Images)
@@ -79,7 +81,7 @@ func (updater *ArgoStatusUpdater) WaitForRollout(task models.Task) {
 	}
 
 	// get application status
-	status := application.GetRolloutStatus(task.ListImages(), updater.registryProxyUrl)
+	status := application.GetRolloutStatus(task.ListImages(), updater.registryProxyUrl, updater.acceptSuspended)
 	if status == models.ArgoRolloutAppSuccess {
 		log.Info().Str("id", task.Id).Msg("App is running on the expected version.")
 		// deployment success
@@ -174,7 +176,7 @@ func (updater *ArgoStatusUpdater) waitForApplicationDeployment(task models.Task)
 			return err
 		}
 
-		status := application.GetRolloutStatus(task.ListImages(), updater.registryProxyUrl)
+		status := application.GetRolloutStatus(task.ListImages(), updater.registryProxyUrl, updater.acceptSuspended)
 
 		switch status {
 		case models.ArgoRolloutAppDegraded:
diff --git a/cmd/argo-watcher/config/config.go b/cmd/argo-watcher/config/config.go
index b6cd714..0706598 100644
--- a/cmd/argo-watcher/config/config.go
+++ b/cmd/argo-watcher/config/config.go
@@ -28,23 +28,24 @@ type DatabaseConfig struct {
 }
 
 type ServerConfig struct {
-	ArgoUrl           url.URL        `env:"ARGO_URL,required" json:"argo_cd_url"`
-	ArgoUrlAlias      string         `env:"ARGO_URL_ALIAS" json:"argo_cd_url_alias,omitempty"` // Used to generate App URL. Can be omitted if ArgoUrl is reachable from outside.
-	ArgoToken         string         `env:"ARGO_TOKEN,required" json:"-"`
-	ArgoApiTimeout    int64          `env:"ARGO_API_TIMEOUT" envDefault:"60" json:"argo_api_timeout"`
-	DeploymentTimeout uint           `env:"DEPLOYMENT_TIMEOUT" envDefault:"900" json:"deployment_timeout"`
-	ArgoRefreshApp    bool           `env:"ARGO_REFRESH_APP" envDefault:"true" json:"argo_refresh_app"`
-	RegistryProxyUrl  string         `env:"DOCKER_IMAGES_PROXY" json:"registry_proxy_url,omitempty"`
-	StateType         string         `env:"STATE_TYPE,required" validate:"oneof=postgres in-memory" json:"state_type"`
-	StaticFilePath    string         `env:"STATIC_FILES_PATH" envDefault:"static" json:"-"`
-	SkipTlsVerify     bool           `env:"SKIP_TLS_VERIFY" envDefault:"false" json:"skip_tls_verify"`
-	LogLevel          string         `env:"LOG_LEVEL" envDefault:"info" json:"log_level"`
-	LogFormat         string         `env:"LOG_FORMAT" envDefault:"json" json:"-"`
-	Host              string         `env:"HOST" envDefault:"0.0.0.0" json:"-"`
-	Port              string         `env:"PORT" envDefault:"8080" json:"-"`
-	DeployToken       string         `env:"ARGO_WATCHER_DEPLOY_TOKEN" json:"-"`
-	Db                DatabaseConfig `json:"db,omitempty"`
-	Keycloak          KeycloakConfig `json:"keycloak,omitempty"`
+	ArgoUrl            url.URL        `env:"ARGO_URL,required" json:"argo_cd_url"`
+	ArgoUrlAlias       string         `env:"ARGO_URL_ALIAS" json:"argo_cd_url_alias,omitempty"` // Used to generate App URL. Can be omitted if ArgoUrl is reachable from outside.
+	ArgoToken          string         `env:"ARGO_TOKEN,required" json:"-"`
+	ArgoApiTimeout     int64          `env:"ARGO_API_TIMEOUT" envDefault:"60" json:"argo_api_timeout"`
+	AcceptSuspendedApp bool           `env:"ACCEPT_SUSPENDED_APP" envDefault:"false" json:"accept_suspended_app"` // If true, we will accept "Suspended" health status as valid
+	DeploymentTimeout  uint           `env:"DEPLOYMENT_TIMEOUT" envDefault:"900" json:"deployment_timeout"`
+	ArgoRefreshApp     bool           `env:"ARGO_REFRESH_APP" envDefault:"true" json:"argo_refresh_app"`
+	RegistryProxyUrl   string         `env:"DOCKER_IMAGES_PROXY" json:"registry_proxy_url,omitempty"`
+	StateType          string         `env:"STATE_TYPE,required" validate:"oneof=postgres in-memory" json:"state_type"`
+	StaticFilePath     string         `env:"STATIC_FILES_PATH" envDefault:"static" json:"-"`
+	SkipTlsVerify      bool           `env:"SKIP_TLS_VERIFY" envDefault:"false" json:"skip_tls_verify"`
+	LogLevel           string         `env:"LOG_LEVEL" envDefault:"info" json:"log_level"`
+	LogFormat          string         `env:"LOG_FORMAT" envDefault:"json" json:"-"`
+	Host               string         `env:"HOST" envDefault:"0.0.0.0" json:"-"`
+	Port               string         `env:"PORT" envDefault:"8080" json:"-"`
+	DeployToken        string         `env:"ARGO_WATCHER_DEPLOY_TOKEN" json:"-"`
+	Db                 DatabaseConfig `json:"db,omitempty"`
+	Keycloak           KeycloakConfig `json:"keycloak,omitempty"`
 }
 
 // NewServerConfig parses the server configuration from environment variables using the envconfig package.
diff --git a/cmd/argo-watcher/server/server.go b/cmd/argo-watcher/server/server.go
index a1e034c..435b3c5 100644
--- a/cmd/argo-watcher/server/server.go
+++ b/cmd/argo-watcher/server/server.go
@@ -69,7 +69,12 @@ func RunServer() {
 
 	// initialize argo updater
 	updater := &argocd.ArgoStatusUpdater{}
-	updater.Init(*argo, serverConfig.GetRetryAttempts(), argocd.ArgoSyncRetryDelay, serverConfig.RegistryProxyUrl)
+	updater.Init(*argo,
+		serverConfig.GetRetryAttempts(),
+		argocd.ArgoSyncRetryDelay,
+		serverConfig.RegistryProxyUrl,
+		serverConfig.AcceptSuspendedApp,
+	)
 
 	// create environment
 	env := &Env{config: serverConfig, argo: argo, metrics: metrics, updater: updater}
diff --git a/internal/models/argo.go b/internal/models/argo.go
index 33c160c..f65aa3f 100644
--- a/internal/models/argo.go
+++ b/internal/models/argo.go
@@ -78,7 +78,7 @@ type Application struct {
 }
 
 // GetRolloutStatus calculates application rollout status depending on the expected images and proxy configuration.
-func (app *Application) GetRolloutStatus(rolloutImages []string, registryProxyUrl string) string {
+func (app *Application) GetRolloutStatus(rolloutImages []string, registryProxyUrl string, acceptSuspended bool) string {
 	// check if all the images rolled out
 	for _, image := range rolloutImages {
 		if !helpers.ImagesContains(app.Status.Summary.Images, image, registryProxyUrl) {
@@ -96,6 +96,12 @@ func (app *Application) GetRolloutStatus(rolloutImages []string, registryProxyUr
 		return ArgoRolloutAppNotSynced
 	}
 
+	// an optional check that helps when we are dealing with Rollout object that can be in a suspended state
+	// during the rollout process
+	if app.Status.Health.Status == "Suspended" && app.Status.Sync.Status == "Synced" && acceptSuspended {
+		return ArgoRolloutAppSuccess
+	}
+
 	// verify app health status
 	if app.Status.Health.Status != "Healthy" {
 		return ArgoRolloutAppNotHealthy


Test patch:
diff --git a/cmd/argo-watcher/argocd/argo_status_updater_test.go b/cmd/argo-watcher/argocd/argo_status_updater_test.go
index 7bf152f..d0a700d 100644
--- a/cmd/argo-watcher/argocd/argo_status_updater_test.go
+++ b/cmd/argo-watcher/argocd/argo_status_updater_test.go
@@ -29,7 +29,7 @@ func TestArgoStatusUpdaterCheck(t *testing.T) {
 
 		// argo updater
 		updater := &ArgoStatusUpdater{}
-		updater.Init(*argo, 1, 0*time.Second, "test-registry")
+		updater.Init(*argo, 1, 0*time.Second, "test-registry", false)
 
 		// prepare test data
 		task := models.Task{
@@ -70,7 +70,7 @@ func TestArgoStatusUpdaterCheck(t *testing.T) {
 
 		// argo updater
 		updater := &ArgoStatusUpdater{}
-		updater.Init(*argo, 3, 0*time.Second, "test-registry")
+		updater.Init(*argo, 3, 0*time.Second, "test-registry", false)
 
 		// prepare test data
 		task := models.Task{
@@ -118,7 +118,7 @@ func TestArgoStatusUpdaterCheck(t *testing.T) {
 
 		// argo updater
 		updater := &ArgoStatusUpdater{}
-		updater.Init(*argo, 1, 0*time.Second, "test-registry")
+		updater.Init(*argo, 1, 0*time.Second, "test-registry", false)
 
 		// prepare test data
 		task := models.Task{
@@ -159,7 +159,7 @@ func TestArgoStatusUpdaterCheck(t *testing.T) {
 
 		// argo updater
 		updater := &ArgoStatusUpdater{}
-		updater.Init(*argo, 1, 0*time.Second, "")
+		updater.Init(*argo, 1, 0*time.Second, "", false)
 
 		// prepare test data
 		task := models.Task{
@@ -201,7 +201,7 @@ func TestArgoStatusUpdaterCheck(t *testing.T) {
 
 		// argo updater
 		updater := &ArgoStatusUpdater{}
-		updater.Init(*argo, 1, 0*time.Second, "test-registry")
+		updater.Init(*argo, 1, 0*time.Second, "test-registry", false)
 
 		// prepare test data
 		task := models.Task{
@@ -230,7 +230,7 @@ func TestArgoStatusUpdaterCheck(t *testing.T) {
 
 		// argo updater
 		updater := &ArgoStatusUpdater{}
-		updater.Init(*argo, 1, 0*time.Second, "test-registry")
+		updater.Init(*argo, 1, 0*time.Second, "test-registry", false)
 
 		// prepare test data
 		task := models.Task{
@@ -259,7 +259,7 @@ func TestArgoStatusUpdaterCheck(t *testing.T) {
 
 		// argo updater
 		updater := &ArgoStatusUpdater{}
-		updater.Init(*argo, 1, 0*time.Second, "test-registry")
+		updater.Init(*argo, 1, 0*time.Second, "test-registry", false)
 
 		// prepare test data
 		task := models.Task{
@@ -288,7 +288,7 @@ func TestArgoStatusUpdaterCheck(t *testing.T) {
 
 		// argo updater
 		updater := &ArgoStatusUpdater{}
-		updater.Init(*argo, 1, 0*time.Second, "test-registry")
+		updater.Init(*argo, 1, 0*time.Second, "test-registry", false)
 
 		// prepare test data
 		task := models.Task{
@@ -328,7 +328,7 @@ func TestArgoStatusUpdaterCheck(t *testing.T) {
 
 		// argo updater
 		updater := &ArgoStatusUpdater{}
-		updater.Init(*argo, 1, 0*time.Second, "test-registry")
+		updater.Init(*argo, 1, 0*time.Second, "test-registry", false)
 
 		// prepare test data
 		task := models.Task{
@@ -372,7 +372,7 @@ func TestArgoStatusUpdaterCheck(t *testing.T) {
 
 		// argo updater
 		updater := &ArgoStatusUpdater{}
-		updater.Init(*argo, 1, 0*time.Second, "test-registry")
+		updater.Init(*argo, 1, 0*time.Second, "test-registry", false)
 
 		// prepare test data
 		task := models.Task{
diff --git a/internal/models/argo_test.go b/internal/models/argo_test.go
index e4057eb..369bf81 100644
--- a/internal/models/argo_test.go
+++ b/internal/models/argo_test.go
@@ -71,7 +71,7 @@ func TestArgoRolloutStatus(t *testing.T) {
 		images := []string{"ghcr.io/shini4i/argo-watcher:version2"}
 		registryProxyUrl := ""
 		// test status
-		assert.Equal(t, ArgoRolloutAppNotAvailable, application.GetRolloutStatus(images, registryProxyUrl))
+		assert.Equal(t, ArgoRolloutAppNotAvailable, application.GetRolloutStatus(images, registryProxyUrl, false))
 	})
 
 	t.Run("Rollout status - ArgoRolloutAppNotSynced", func(t *testing.T) {
@@ -83,7 +83,7 @@ func TestArgoRolloutStatus(t *testing.T) {
 		images := []string{"ghcr.io/shini4i/argo-watcher:version1"}
 		registryProxyUrl := ""
 		// test status
-		assert.Equal(t, ArgoRolloutAppNotSynced, application.GetRolloutStatus(images, registryProxyUrl))
+		assert.Equal(t, ArgoRolloutAppNotSynced, application.GetRolloutStatus(images, registryProxyUrl, false))
 	})
 
 	t.Run("Rollout status - ArgoRolloutAppNotHealthy", func(t *testing.T) {
@@ -96,7 +96,7 @@ func TestArgoRolloutStatus(t *testing.T) {
 		images := []string{"ghcr.io/shini4i/argo-watcher:version1"}
 		registryProxyUrl := ""
 		// test status
-		assert.Equal(t, ArgoRolloutAppNotHealthy, application.GetRolloutStatus(images, registryProxyUrl))
+		assert.Equal(t, ArgoRolloutAppNotHealthy, application.GetRolloutStatus(images, registryProxyUrl, false))
 	})
 
 	t.Run("Rollout status - ArgoRolloutAppSuccess", func(t *testing.T) {
@@ -109,7 +109,7 @@ func TestArgoRolloutStatus(t *testing.T) {
 		images := []string{"ghcr.io/shini4i/argo-watcher:version1"}
 		registryProxyUrl := ""
 		// test status
-		assert.Equal(t, ArgoRolloutAppSuccess, application.GetRolloutStatus(images, registryProxyUrl))
+		assert.Equal(t, ArgoRolloutAppSuccess, application.GetRolloutStatus(images, registryProxyUrl, false))
 	})
 
 	t.Run("Rollout status - ArgoRolloutAppDegraded", func(t *testing.T) {
@@ -122,7 +122,29 @@ func TestArgoRolloutStatus(t *testing.T) {
 		images := []string{"ghcr.io/shini4i/argo-watcher:version1"}
 		registryProxyUrl := ""
 		// test status
-		assert.Equal(t, ArgoRolloutAppDegraded, application.GetRolloutStatus(images, registryProxyUrl))
+		assert.Equal(t, ArgoRolloutAppDegraded, application.GetRolloutStatus(images, registryProxyUrl, false))
+	})
+
+	t.Run("acceptSuspended is true", func(t *testing.T) {
+		application := Application{}
+		application.Status.Health.Status = "Suspended"
+		application.Status.Sync.Status = "Synced"
+
+		status := application.GetRolloutStatus([]string{}, "", true)
+		if status != ArgoRolloutAppSuccess {
+			t.Errorf("Expected status to be %s, but got %s", ArgoRolloutAppSuccess, status)
+		}
+	})
+
+	t.Run("acceptSuspended is false", func(t *testing.T) {
+		application := Application{}
+		application.Status.Health.Status = "Suspended"
+		application.Status.Sync.Status = "Synced"
+
+		status := application.GetRolloutStatus([]string{}, "", false)
+		if status == ArgoRolloutAppSuccess {
+			t.Errorf("Expected status to not be %s", ArgoRolloutAppSuccess)
+		}
 	})
 }