diff --git a/cmd/argo-watcher/argo.go b/cmd/argo-watcher/argo.go index 136a53e4..12e4c280 100644 --- a/cmd/argo-watcher/argo.go +++ b/cmd/argo-watcher/argo.go @@ -12,8 +12,7 @@ import ( ) var ( - argoSyncRetryDelay = 15 * time.Second - errorArgoPlannedRetry = fmt.Errorf("planned retry") + argoSyncRetryDelay = 15 * time.Second ) const ( diff --git a/cmd/argo-watcher/argo_status_updater.go b/cmd/argo-watcher/argo_status_updater.go index e2ce857a..84bd81f2 100644 --- a/cmd/argo-watcher/argo_status_updater.go +++ b/cmd/argo-watcher/argo_status_updater.go @@ -8,254 +8,121 @@ import ( "github.com/avast/retry-go/v4" "github.com/rs/zerolog/log" - "github.com/shini4i/argo-watcher/internal/helpers" "github.com/shini4i/argo-watcher/internal/models" ) -const defaultErrorMessage string = "could not retrieve details" const failedToUpdateTaskStatusTemplate string = "Failed to change task status: %s" type ArgoStatusUpdater struct { argo Argo - retryAttempts uint - retryDelay time.Duration registryProxyUrl string + retryOptions []retry.Option } func (updater *ArgoStatusUpdater) Init(argo Argo, retryAttempts uint, retryDelay time.Duration, registryProxyUrl string) { updater.argo = argo - updater.retryAttempts = retryAttempts - updater.retryDelay = retryDelay updater.registryProxyUrl = registryProxyUrl + updater.retryOptions = []retry.Option{ + retry.DelayType(retry.FixedDelay), + retry.Attempts(retryAttempts), + retry.Delay(retryDelay), + retry.LastErrorOnly(true), + } } func (updater *ArgoStatusUpdater) WaitForRollout(task models.Task) { - // continuously check for application status change - status, err := updater.checkWithRetry(task) - - // application synced successfully - if status == ArgoAppSuccess { - updater.handleDeploymentSuccess(task) - return - } - - // we had some unexpected error with ArgoCD API - if status == ArgoAppFailed { + // wait for application to get into deployed status or timeout + application, err := updater.waitForApplicationDeployment(task) + + // handle application failure + if err != nil { + // deployment failed + updater.argo.metrics.AddFailedDeployment(task.App) + // update task status regarding failure updater.handleArgoAPIFailure(task, err) return } - // fetch application details - app, err := updater.argo.api.GetApplication(task.App) - - // handle application sync failure - switch status { - // not all images were deployed to the application - case ArgoAppNotAvailable: - // show list of missing images - var message string - // define details - if err != nil { - message = defaultErrorMessage - } else { - message = fmt.Sprintf( - "List of current images (last app check):\n"+ - "\t%s\n\n"+ - "List of expected images:\n"+ - "\t%s", - strings.Join(app.Status.Summary.Images, "\n\t"), - strings.Join(task.ListImages(), "\n\t"), - ) + // get application status + status := application.GetRolloutStatus(task.ListImages(), updater.registryProxyUrl) + if application.IsFinalRolloutStatus(status) { + log.Info().Str("id", task.Id).Msg("App is running on the excepted version.") + // deployment success + updater.argo.metrics.ResetFailedDeployment(task.App) + // update task status + errStatusChange := updater.argo.state.SetTaskStatus(task.Id, models.StatusDeployedMessage, "") + if errStatusChange != nil { + log.Error().Str("id", task.Id).Msgf(failedToUpdateTaskStatusTemplate, errStatusChange) } - // handle error - updater.handleAppNotAvailable(task, errors.New(message)) - // application sync status wasn't valid - case ArgoAppNotSynced: - // display sync status and last sync message - var message string - // define details - if err != nil { - message = defaultErrorMessage - } else { - message = fmt.Sprintf( - "App status \"%s\"\n"+ - "App message \"%s\"\n"+ - "Resources:\n"+ - "\t%s", - app.Status.OperationState.Phase, - app.Status.OperationState.Message, - strings.Join(app.ListSyncResultResources(), "\n\t"), - ) - } - // handle error - updater.handleAppOutOfSync(task, errors.New(message)) - // application is not in a healthy status - case ArgoAppNotHealthy: - // display current health of pods - var message string - // define details - if err != nil { - message = defaultErrorMessage - } else { - message = fmt.Sprintf( - "App sync status \"%s\"\n"+ - "App health status \"%s\"\n"+ - "Resources:\n"+ - "\t%s", - app.Status.Sync.Status, - app.Status.Health.Status, - strings.Join(app.ListUnhealthyResources(), "\n\t"), - ) + } else { + log.Info().Str("id", task.Id).Msg("App deployment failed.") + // deployment failed + updater.argo.metrics.AddFailedDeployment(task.App) + // generate failure reason + reason := fmt.Sprintf( + "Application deployment failed. Rollout status \"%s\"\n\n%s", + status, + application.GetRolloutMessage(status, task.ListImages()), + ) + // update task status + errStatusChange := updater.argo.state.SetTaskStatus(task.Id, models.StatusFailedMessage, reason) + if errStatusChange != nil { + log.Error().Str("id", task.Id).Msgf(failedToUpdateTaskStatusTemplate, errStatusChange) } - // handle error - updater.handleAppNotHealthy(task, errors.New(message)) - // handle unexpected status - default: - updater.handleDeploymentUnexpectedStatus(task, fmt.Errorf("received unexpected status \"%d\"", status)) } } -func (updater *ArgoStatusUpdater) checkWithRetry(task models.Task) (int, error) { - var lastStatus int - - err := retry.Do( - func() error { - app, err := updater.argo.api.GetApplication(task.App) - - if err != nil { - log.Warn().Str("app", task.App).Msg(err.Error()) - lastStatus = ArgoAppFailed - return err - } - - for _, image := range task.Images { - expected := fmt.Sprintf("%s:%s", image.Image, image.Tag) - if !helpers.ImagesContains(app.Status.Summary.Images, expected, updater.registryProxyUrl) { - log.Debug().Str("app", task.App).Str("id", task.Id).Msgf("%s is not available yet", expected) - lastStatus = ArgoAppNotAvailable - return errorArgoPlannedRetry - } else { - log.Debug().Str("app", task.App).Str("id", task.Id).Msgf("Expected image is in the app summary") - } - } +func (updater *ArgoStatusUpdater) waitForApplicationDeployment(task models.Task) (*models.Application, error) { + var application *models.Application + var err error - if app.Status.Sync.Status != "Synced" { - log.Debug().Str("id", task.Id).Msgf("%s is not synced yet", task.App) - lastStatus = ArgoAppNotSynced - return errorArgoPlannedRetry - } - - if app.Status.Health.Status != "Healthy" { - log.Debug().Str("id", task.Id).Msgf("%s is not healthy yet", task.App) - lastStatus = ArgoAppNotHealthy - return errorArgoPlannedRetry + // wait for application to get into deployed status or timeout + log.Debug().Str("id", task.Id).Msg("Waiting for rollout") + _ = retry.Do(func() error { + application, err = updater.argo.api.GetApplication(task.App) + if err != nil { + // check if ArgoCD didn't have the app + if task.IsAppNotFoundError(err) { + // no need to retry in such cases + return retry.Unrecoverable(err) } + // print application api failure here + log.Debug().Str("id", task.Id).Msgf("Failed fetching application status. Error: %s", err.Error()) + return err + } + // print application debug here + status := application.GetRolloutStatus(task.ListImages(), updater.registryProxyUrl) + if !application.IsFinalRolloutStatus(status) { + // print status debug here + log.Debug().Str("id", task.Id).Msgf("Application status is not final. Status received \"%s\"", status) + return errors.New("force retry") + } + // all good + log.Debug().Str("id", task.Id).Msgf("Application rollout finished") + return nil + }, updater.retryOptions...) - lastStatus = ArgoAppSuccess - return nil - }, - retry.DelayType(retry.FixedDelay), - retry.Delay(updater.retryDelay), - retry.Attempts(updater.retryAttempts), - retry.RetryIf(func(err error) bool { - return errors.Is(err, errorArgoPlannedRetry) - }), - retry.LastErrorOnly(true), - ) - - return lastStatus, err + // return application and latest error + return application, err } func (updater *ArgoStatusUpdater) handleArgoAPIFailure(task models.Task, err error) { - // notify user that app wasn't found - appNotFoundError := fmt.Sprintf("applications.argoproj.io \"%s\" not found", task.App) - if strings.Contains(err.Error(), appNotFoundError) { - updater.handleAppNotFound(task, err) - return - } - // notify user that ArgoCD API isn't available - if strings.Contains(err.Error(), argoUnavailableErrorMessage) { - updater.handleArgoUnavailable(task, err) - return - } - - // notify of unexpected error - updater.handleDeploymentFailed(task, err) -} + var apiFailureStatus string = models.StatusFailedMessage -func (updater *ArgoStatusUpdater) handleAppNotFound(task models.Task, err error) { - log.Info().Str("id", task.Id).Msgf("Application %s does not exist.", task.App) - reason := fmt.Sprintf(ArgoAPIErrorTemplate, err.Error()) - errStatusChange := updater.argo.state.SetTaskStatus(task.Id, models.StatusAppNotFoundMessage, reason) - if errStatusChange != nil { - log.Error().Str("id", task.Id).Msgf(failedToUpdateTaskStatusTemplate, errStatusChange) + // check if ArgoCD didn't have the app + if task.IsAppNotFoundError(err) { + apiFailureStatus = models.StatusAppNotFoundMessage } -} - -func (updater *ArgoStatusUpdater) handleArgoUnavailable(task models.Task, err error) { - log.Error().Str("id", task.Id).Msg("ArgoCD is not available. Aborting.") - reason := fmt.Sprintf(ArgoAPIErrorTemplate, err.Error()) - errStatusChange := updater.argo.state.SetTaskStatus(task.Id, models.StatusAborted, reason) - if errStatusChange != nil { - log.Error().Str("id", task.Id).Msgf(failedToUpdateTaskStatusTemplate, errStatusChange) + // check if ArgoCD was unavailable + if strings.Contains(err.Error(), argoUnavailableErrorMessage) { + apiFailureStatus = models.StatusAborted } -} -func (updater *ArgoStatusUpdater) handleDeploymentFailed(task models.Task, err error) { - log.Warn().Str("id", task.Id).Msgf("Deployment failed. Aborting with error: %s", err) - updater.argo.metrics.AddFailedDeployment(task.App) + // write debug reason reason := fmt.Sprintf(ArgoAPIErrorTemplate, err.Error()) - errStatusChange := updater.argo.state.SetTaskStatus(task.Id, models.StatusFailedMessage, reason) - if errStatusChange != nil { - log.Error().Str("id", task.Id).Msgf(failedToUpdateTaskStatusTemplate, errStatusChange) - } -} - -func (updater *ArgoStatusUpdater) handleDeploymentSuccess(task models.Task) { - log.Info().Str("id", task.Id).Msg("App is running on the excepted version.") - updater.argo.metrics.ResetFailedDeployment(task.App) - errStatusChange := updater.argo.state.SetTaskStatus(task.Id, models.StatusDeployedMessage, "") - if errStatusChange != nil { - log.Error().Str("id", task.Id).Msgf(failedToUpdateTaskStatusTemplate, errStatusChange) - } -} - -func (updater *ArgoStatusUpdater) handleAppNotAvailable(task models.Task, err error) { - log.Warn().Str("id", task.Id).Msgf("Deployment failed. Application not available\n%s", err.Error()) - updater.argo.metrics.AddFailedDeployment(task.App) - reason := fmt.Sprintf("Application not available\n\n%s", err.Error()) - errStatusChange := updater.argo.state.SetTaskStatus(task.Id, models.StatusFailedMessage, reason) - if errStatusChange != nil { - log.Error().Str("id", task.Id).Msgf(failedToUpdateTaskStatusTemplate, errStatusChange) - } -} - -func (updater *ArgoStatusUpdater) handleAppNotHealthy(task models.Task, err error) { - log.Warn().Str("id", task.Id).Msgf("Deployment failed. Application not healthy\n%s", err.Error()) - updater.argo.metrics.AddFailedDeployment(task.App) - reason := fmt.Sprintf("Application not healthy\n\n%s", err.Error()) - errStatusChange := updater.argo.state.SetTaskStatus(task.Id, models.StatusFailedMessage, reason) - if errStatusChange != nil { - log.Error().Str("id", task.Id).Msgf(failedToUpdateTaskStatusTemplate, errStatusChange) - } -} - -func (updater *ArgoStatusUpdater) handleAppOutOfSync(task models.Task, err error) { - log.Warn().Str("id", task.Id).Msgf("Deployment failed. Application out of sync\n%s", err.Error()) - updater.argo.metrics.AddFailedDeployment(task.App) - reason := fmt.Sprintf("Application out of sync\n\n%s", err.Error()) - errStatusChange := updater.argo.state.SetTaskStatus(task.Id, models.StatusFailedMessage, reason) - if errStatusChange != nil { - log.Error().Str("id", task.Id).Msgf(failedToUpdateTaskStatusTemplate, errStatusChange) - } -} + log.Warn().Str("id", task.Id).Msgf("Deployment failed with status \"%s\". Aborting with error: %s", apiFailureStatus, reason) -func (updater *ArgoStatusUpdater) handleDeploymentUnexpectedStatus(task models.Task, err error) { - log.Error().Str("id", task.Id).Msg("Deployment timed out with unexpected status. Aborting.") - log.Error().Str("id", task.Id).Msgf("Deployment error\n%s", err.Error()) - updater.argo.metrics.AddFailedDeployment(task.App) - reason := fmt.Sprintf("Deployment timeout\n\n%s", err.Error()) - errStatusChange := updater.argo.state.SetTaskStatus(task.Id, models.StatusFailedMessage, reason) + errStatusChange := updater.argo.state.SetTaskStatus(task.Id, apiFailureStatus, reason) if errStatusChange != nil { log.Error().Str("id", task.Id).Msgf(failedToUpdateTaskStatusTemplate, errStatusChange) } diff --git a/cmd/argo-watcher/argo_status_updater_test.go b/cmd/argo-watcher/argo_status_updater_test.go index ae1050ee..b5324617 100644 --- a/cmd/argo-watcher/argo_status_updater_test.go +++ b/cmd/argo-watcher/argo_status_updater_test.go @@ -177,10 +177,10 @@ func TestArgoStatusUpdaterCheck(t *testing.T) { application.Status.Health.Status = "Healthy" // mock calls - apiMock.EXPECT().GetApplication(task.App).Return(&application, nil).Times(2) + apiMock.EXPECT().GetApplication(task.App).Return(&application, nil) metricsMock.EXPECT().AddFailedDeployment(task.App) stateMock.EXPECT().SetTaskStatus(task.Id, models.StatusFailedMessage, - "Application not available\n\nList of current images (last app check):\n\ttest-registry/ghcr.io/shini4i/argo-watcher:dev\n\nList of expected images:\n\tghcr.io/shini4i/argo-watcher:dev") + "Application deployment failed. Rollout status \"not available\"\n\nList of current images (last app check):\n\ttest-registry/ghcr.io/shini4i/argo-watcher:dev\n\nList of expected images:\n\tghcr.io/shini4i/argo-watcher:dev") // run the rollout updater.WaitForRollout(task) @@ -208,6 +208,7 @@ func TestArgoStatusUpdaterCheck(t *testing.T) { // mock calls apiMock.EXPECT().GetApplication(task.App).Return(nil, fmt.Errorf("applications.argoproj.io \"test-app\" not found")) + metricsMock.EXPECT().AddFailedDeployment(task.App) stateMock.EXPECT().SetTaskStatus(task.Id, models.StatusAppNotFoundMessage, "ArgoCD API Error: applications.argoproj.io \"test-app\" not found") // run the rollout @@ -236,6 +237,7 @@ func TestArgoStatusUpdaterCheck(t *testing.T) { // mock calls apiMock.EXPECT().GetApplication(task.App).Return(nil, fmt.Errorf(argoUnavailableErrorMessage)) + metricsMock.EXPECT().AddFailedDeployment(task.App) stateMock.EXPECT().SetTaskStatus(task.Id, models.StatusAborted, "ArgoCD API Error: connect: connection refused") // run the rollout @@ -302,10 +304,10 @@ func TestArgoStatusUpdaterCheck(t *testing.T) { application.Status.Summary.Images = []string{"test-image:v0.0.1"} // mock calls - apiMock.EXPECT().GetApplication(task.App).Return(&application, nil).Times(2) + apiMock.EXPECT().GetApplication(task.App).Return(&application, nil) metricsMock.EXPECT().AddFailedDeployment(task.App) stateMock.EXPECT().SetTaskStatus(task.Id, models.StatusFailedMessage, - "Application not available\n\nList of current images (last app check):\n\ttest-image:v0.0.1\n\nList of expected images:\n\tghcr.io/shini4i/argo-watcher:dev") + "Application deployment failed. Rollout status \"not available\"\n\nList of current images (last app check):\n\ttest-image:v0.0.1\n\nList of expected images:\n\tghcr.io/shini4i/argo-watcher:dev") // run the rollout updater.WaitForRollout(task) @@ -346,10 +348,10 @@ func TestArgoStatusUpdaterCheck(t *testing.T) { application.Status.OperationState.Message = "Not working test app" // mock calls - apiMock.EXPECT().GetApplication(task.App).Return(&application, nil).Times(2) + apiMock.EXPECT().GetApplication(task.App).Return(&application, nil) metricsMock.EXPECT().AddFailedDeployment(task.App) stateMock.EXPECT().SetTaskStatus(task.Id, models.StatusFailedMessage, - "Application out of sync\n\nApp status \"NotWorking\"\nApp message \"Not working test app\"\nResources:\n\t") + "Application deployment failed. Rollout status \"not synced\"\n\nApp status \"NotWorking\"\nApp message \"Not working test app\"\nResources:\n\t") // run the rollout updater.WaitForRollout(task) @@ -388,10 +390,10 @@ func TestArgoStatusUpdaterCheck(t *testing.T) { application.Status.Health.Status = "NotHealthy" // mock calls - apiMock.EXPECT().GetApplication(task.App).Return(&application, nil).Times(2) + apiMock.EXPECT().GetApplication(task.App).Return(&application, nil) metricsMock.EXPECT().AddFailedDeployment(task.App) stateMock.EXPECT().SetTaskStatus(task.Id, models.StatusFailedMessage, - "Application not healthy\n\nApp sync status \"Synced\"\nApp health status \"NotHealthy\"\nResources:\n\t") + "Application deployment failed. Rollout status \"not healthy\"\n\nApp sync status \"Synced\"\nApp health status \"NotHealthy\"\nResources:\n\t") // run the rollout updater.WaitForRollout(task) diff --git a/internal/models/argo.go b/internal/models/argo.go index f427727e..9d02dc12 100644 --- a/internal/models/argo.go +++ b/internal/models/argo.go @@ -1,6 +1,39 @@ package models -import "fmt" +import ( + "fmt" + "strings" + + "github.com/shini4i/argo-watcher/internal/helpers" +) + +const ( + ArgoRolloutAppSuccess = "success" + ArgoRolloutAppNotSynced = "not synced" + ArgoRolloutAppNotAvailable = "not available" + ArgoRolloutAppNotHealthy = "not healthy" +) + +type ApplicationOperationResource struct { + HookPhase string `json:"hookPhase"` // example: Failed + HookType string `json:"hookType"` // example: PreSync + Kind string `json:"kind"` // example: Pod | Job + Message string `json:"message"` // example: Job has reached the specified backoff limit + Status string `json:"status"` // example: Synced + SyncPhase string `json:"syncPhase"` // example: PreSync + Name string `json:"name"` // example: app-migrations + Namespace string `json:"namespace"` // example: app +} + +type ApplicationResource struct { + Kind string `json:"kind"` // example: Pod | Job + Name string `json:"name"` // example: app-migrations + Namespace string `json:"namespace"` // example: app + Health struct { + Message string `json:"message"` // example: Job has reached the specified backoff limit + Status string `json:"status"` // example: Synced + } `json:"health"` +} type Application struct { Status struct { @@ -11,28 +44,11 @@ type Application struct { Phase string `json:"phase"` Message string `json:"message"` SyncResult struct { - Resources []struct { - HookPhase string `json:"hookPhase"` // example: Failed - HookType string `json:"hookType"` // example: PreSync - Kind string `json:"kind"` // example: Pod | Job - Message string `json:"message"` // example: Job has reached the specified backoff limit - Status string `json:"status"` // example: Synced - SyncPhase string `json:"syncPhase"` // example: PreSync - Name string `json:"name"` // example: app-migrations - Namespace string `json:"namespace"` // example: app - } `json:"resources"` + Resources []ApplicationOperationResource `json:"resources"` } `json:"syncResult"` } `json:"operationState"` - Resources []struct { - Kind string `json:"kind"` // example: Pod | Job - Name string `json:"name"` // example: app-migrations - Namespace string `json:"namespace"` // example: app - Health struct { - Message string `json:"message"` // example: Job has reached the specified backoff limit - Status string `json:"status"` // example: Synced - } `json:"health"` - } `json:"resources"` - Summary struct { + Resources []ApplicationResource `json:"resources"` + Summary struct { Images []string `json:"images"` } Sync struct { @@ -41,6 +57,82 @@ type Application struct { } `json:"status"` } +// Calculates application rollout status depending on the expected images and proxy configuration. +func (app *Application) GetRolloutStatus(rolloutImages []string, registryProxyUrl string) string { + // check if all the images rolled out + for _, image := range rolloutImages { + if !helpers.ImagesContains(app.Status.Summary.Images, image, registryProxyUrl) { + return ArgoRolloutAppNotAvailable + } + } + + // verify app sync status + if app.Status.Sync.Status != "Synced" { + return ArgoRolloutAppNotSynced + } + + // verify app health status + if app.Status.Health.Status != "Healthy" { + return ArgoRolloutAppNotHealthy + } + + // all good + return ArgoRolloutAppSuccess +} + +// Generate rollout failure message. +func (app *Application) GetRolloutMessage(status string, rolloutImages []string) string { + // handle application sync failure + switch status { + // not all images were deployed to the application + case ArgoRolloutAppNotAvailable: + // define details + return fmt.Sprintf( + "List of current images (last app check):\n"+ + "\t%s\n\n"+ + "List of expected images:\n"+ + "\t%s", + strings.Join(app.Status.Summary.Images, "\n\t"), + strings.Join(rolloutImages, "\n\t"), + ) + // application sync status wasn't valid + case ArgoRolloutAppNotSynced: + // display sync status and last sync message + return fmt.Sprintf( + "App status \"%s\"\n"+ + "App message \"%s\"\n"+ + "Resources:\n"+ + "\t%s", + app.Status.OperationState.Phase, + app.Status.OperationState.Message, + strings.Join(app.ListSyncResultResources(), "\n\t"), + ) + // application is not in a healthy status + case ArgoRolloutAppNotHealthy: + // display current health of pods + return fmt.Sprintf( + "App sync status \"%s\"\n"+ + "App health status \"%s\"\n"+ + "Resources:\n"+ + "\t%s", + app.Status.Sync.Status, + app.Status.Health.Status, + strings.Join(app.ListUnhealthyResources(), "\n\t"), + ) + } + + // handle unexpected status + return fmt.Sprintf( + "received unexpected rollout status \"%s\"", + status, + ) +} + +// Check if rollout status is final. +func (app *Application) IsFinalRolloutStatus(status string) bool { + return status == ArgoRolloutAppSuccess +} + // ListSyncResultResources returns a list of strings representing the sync result resources of the application. // Each string in the list contains information about the resource's kind, name, hook type, hook phase, and message. // The information is formatted as "{kind}({name}) {hookType} {hookPhase} with message {message}". diff --git a/internal/models/argo_test.go b/internal/models/argo_test.go index 2b4361a6..943af66d 100644 --- a/internal/models/argo_test.go +++ b/internal/models/argo_test.go @@ -61,3 +61,147 @@ func TestListUnhealthyResources(t *testing.T) { // Assert that the result matches the expected unhealthy resources assert.Equal(t, expectedResources, unhealthyResources) } + +func TestArgoRolloutStatus(t *testing.T) { + t.Run("Rollout status - ArgoRolloutAppNotAvailable", func(t *testing.T) { + // define application + application := Application{} + application.Status.Summary.Images = []string{"ghcr.io/shini4i/argo-watcher:version1"} + // define expected images + images := []string{"ghcr.io/shini4i/argo-watcher:version2"} + registryProxyUrl := "" + // test status + assert.Equal(t, ArgoRolloutAppNotAvailable, application.GetRolloutStatus(images, registryProxyUrl)) + }) + + t.Run("Rollout status - ArgoRolloutAppNotSynced", func(t *testing.T) { + // define application + application := Application{} + application.Status.Summary.Images = []string{"ghcr.io/shini4i/argo-watcher:version1"} + application.Status.Sync.Status = "Syncing" + // define expected images + images := []string{"ghcr.io/shini4i/argo-watcher:version1"} + registryProxyUrl := "" + // test status + assert.Equal(t, ArgoRolloutAppNotSynced, application.GetRolloutStatus(images, registryProxyUrl)) + }) + + t.Run("Rollout status - ArgoRolloutAppNotHealthy", func(t *testing.T) { + // define application + application := Application{} + application.Status.Summary.Images = []string{"ghcr.io/shini4i/argo-watcher:version1"} + application.Status.Sync.Status = "Synced" + application.Status.Health.Status = "NotHealthy" + // define expected images + images := []string{"ghcr.io/shini4i/argo-watcher:version1"} + registryProxyUrl := "" + // test status + assert.Equal(t, ArgoRolloutAppNotHealthy, application.GetRolloutStatus(images, registryProxyUrl)) + }) + + t.Run("Rollout status - ArgoRolloutAppSuccess", func(t *testing.T) { + // define application + application := Application{} + application.Status.Summary.Images = []string{"ghcr.io/shini4i/argo-watcher:version1"} + application.Status.Sync.Status = "Synced" + application.Status.Health.Status = "Healthy" + // define expected images + images := []string{"ghcr.io/shini4i/argo-watcher:version1"} + registryProxyUrl := "" + // test status + assert.Equal(t, ArgoRolloutAppSuccess, application.GetRolloutStatus(images, registryProxyUrl)) + }) +} + +func TestArgoRolloutMessage(t *testing.T) { + + t.Run("Rollout message - ArgoRolloutAppNotAvailable", func(t *testing.T) { + // define application + application := Application{} + application.Status.Summary.Images = []string{"ghcr.io/shini4i/argo-watcher:version1"} + // define expected images + images := []string{"ghcr.io/shini4i/argo-watcher:version2"} + assert.Equal(t, + "List of current images (last app check):\n\tghcr.io/shini4i/argo-watcher:version1\n\nList of expected images:\n\tghcr.io/shini4i/argo-watcher:version2", + application.GetRolloutMessage(ArgoRolloutAppNotAvailable, images)) + }) + + t.Run("Rollout message - ArgoRolloutAppNotSynced", func(t *testing.T) { + // define application + application := Application{} + application.Status.Summary.Images = []string{"ghcr.io/shini4i/argo-watcher:version1"} + application.Status.Sync.Status = "Syncing" + application.Status.Health.Status = "Healthy" + application.Status.OperationState.Phase = "NotWorking" + application.Status.OperationState.Message = "Not working test app" + application.Status.OperationState.SyncResult.Resources = make([]ApplicationOperationResource, 2) + // first resource + application.Status.OperationState.SyncResult.Resources[0].HookPhase = "Succeeded" + application.Status.OperationState.SyncResult.Resources[0].HookType = "PreSync" + application.Status.OperationState.SyncResult.Resources[0].Kind = "Pod" + application.Status.OperationState.SyncResult.Resources[0].Message = "" + application.Status.OperationState.SyncResult.Resources[0].Status = "Synced" + application.Status.OperationState.SyncResult.Resources[0].SyncPhase = "PreSync" + application.Status.OperationState.SyncResult.Resources[0].Name = "app-migrations" + application.Status.OperationState.SyncResult.Resources[0].Namespace = "app" + // second resource + application.Status.OperationState.SyncResult.Resources[1].HookPhase = "Failed" + application.Status.OperationState.SyncResult.Resources[1].HookType = "PostSync" + application.Status.OperationState.SyncResult.Resources[1].Kind = "Job" + application.Status.OperationState.SyncResult.Resources[1].Message = "Job has reached the specified backoff limit" + application.Status.OperationState.SyncResult.Resources[1].Status = "Synced" + application.Status.OperationState.SyncResult.Resources[1].SyncPhase = "PostSync" + application.Status.OperationState.SyncResult.Resources[1].Name = "app-job" + application.Status.OperationState.SyncResult.Resources[1].Namespace = "app" + // define expected images + images := []string{"ghcr.io/shini4i/argo-watcher:version1"} + assert.Equal(t, + "App status \"NotWorking\"\nApp message \"Not working test app\"\nResources:\n\tPod(app-migrations) PreSync Succeeded with message \n\tJob(app-job) PostSync Failed with message Job has reached the specified backoff limit", + application.GetRolloutMessage(ArgoRolloutAppNotSynced, images)) + }) + + t.Run("Rollout message - ArgoRolloutAppNotHealthy", func(t *testing.T) { + // define application + application := Application{} + application.Status.Summary.Images = []string{"ghcr.io/shini4i/argo-watcher:version1"} + application.Status.Sync.Status = "Syncing" + application.Status.Health.Status = "NotHealthy" + application.Status.OperationState.Phase = "NotWorking" + application.Status.OperationState.Message = "Not working test app" + application.Status.Resources = make([]ApplicationResource, 2) + // first resource + application.Status.Resources[0].Kind = "Pod" + application.Status.Resources[0].Name = "app-pod" + application.Status.Resources[0].Namespace = "app" + application.Status.Resources[0].Health.Message = "" + application.Status.Resources[0].Health.Status = "Synced" + // second resource + application.Status.Resources[1].Kind = "Job" + application.Status.Resources[1].Name = "app-job" + application.Status.Resources[1].Namespace = "app" + application.Status.Resources[1].Health.Message = "Job has reached the specified backoff limit" + application.Status.Resources[1].Health.Status = "Unhealthy" + // define expected images + images := []string{"ghcr.io/shini4i/argo-watcher:version1"} + assert.Equal(t, + "App sync status \"Syncing\"\nApp health status \"NotHealthy\"\nResources:\n\tPod(app-pod) Synced\n\tJob(app-job) Unhealthy with message Job has reached the specified backoff limit", + application.GetRolloutMessage(ArgoRolloutAppNotHealthy, images)) + }) + + t.Run("Rollout message - default", func(t *testing.T) { + // define application + application := Application{} + application.Status.Summary.Images = []string{"ghcr.io/shini4i/argo-watcher:version1"} + // define expected images + images := []string{"ghcr.io/shini4i/argo-watcher:version2"} + assert.Equal(t, "received unexpected rollout status \"unexpected status\"", application.GetRolloutMessage("unexpected status", images)) + }) +} + +func TestApplication_IsFinalRollout(t *testing.T) { + application := Application{} + assert.Equal(t, true, application.IsFinalRolloutStatus(ArgoRolloutAppSuccess)) + assert.Equal(t, false, application.IsFinalRolloutStatus(ArgoRolloutAppNotAvailable)) + assert.Equal(t, false, application.IsFinalRolloutStatus(ArgoRolloutAppNotHealthy)) + assert.Equal(t, false, application.IsFinalRolloutStatus(ArgoRolloutAppNotSynced)) +} diff --git a/internal/models/task.go b/internal/models/task.go index cded6b07..eed893b4 100644 --- a/internal/models/task.go +++ b/internal/models/task.go @@ -1,6 +1,9 @@ package models -import "fmt" +import ( + "fmt" + "strings" +) type Image struct { Image string `json:"image" example:"ghcr.io/shini4i/argo-watcher"` @@ -30,6 +33,12 @@ func (task *Task) ListImages() []string { return list } +// Check if app not found error. +func (task *Task) IsAppNotFoundError(err error) bool { + var appNotFoundError string = fmt.Sprintf("applications.argoproj.io \"%s\" not found", task.App) + return strings.Contains(err.Error(), appNotFoundError) +} + type TasksResponse struct { Tasks []Task `json:"tasks"` Error string `json:"error,omitempty"` diff --git a/internal/models/task_test.go b/internal/models/task_test.go index ca19d853..1aa03c48 100644 --- a/internal/models/task_test.go +++ b/internal/models/task_test.go @@ -1,6 +1,7 @@ package models import ( + "errors" "testing" "github.com/stretchr/testify/assert" @@ -25,3 +26,28 @@ func TestTask_ListImages(t *testing.T) { assert.Equal(t, expected, result, "List of images does not match") } + +func TestTask_ListImages_Empty(t *testing.T) { + task := Task{ + Images: []Image{}, + } + + expected := []string{} + result := task.ListImages() + + assert.Equal(t, expected, result, "List of images does not match") +} + +func TestTask_IsAppNotFoundError(t *testing.T) { + task := Task{ + App: "test", + } + assert.Equal(t, true, task.IsAppNotFoundError(errors.New("applications.argoproj.io \"test\" not found"))) +} + +func TestTask_IsAppNotFoundError_Fail(t *testing.T) { + task := Task{ + App: "test", + } + assert.Equal(t, false, task.IsAppNotFoundError(errors.New("random but very important error"))) +}