From d02f132956e4295e52ce363de3dbaaeb1bb552f2 Mon Sep 17 00:00:00 2001 From: Bogdans Ozerkins Date: Wed, 26 Jul 2023 07:20:21 -0700 Subject: [PATCH] chore: argo status updater tests (#163) --- cmd/argo-watcher/argo_status_updater.go | 13 +- cmd/argo-watcher/argo_status_updater_test.go | 399 +++++++++++++++++++ cmd/argo-watcher/main.go | 23 +- cmd/argo-watcher/router.go | 4 +- cmd/argo-watcher/server.go | 2 +- docs/development.md | 31 +- internal/models/constants.go | 1 + 7 files changed, 456 insertions(+), 17 deletions(-) create mode 100644 cmd/argo-watcher/argo_status_updater_test.go diff --git a/cmd/argo-watcher/argo_status_updater.go b/cmd/argo-watcher/argo_status_updater.go index b0e1c7eb..bc96ee87 100644 --- a/cmd/argo-watcher/argo_status_updater.go +++ b/cmd/argo-watcher/argo_status_updater.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "strings" + "time" "github.com/avast/retry-go/v4" "github.com/rs/zerolog/log" @@ -16,12 +17,14 @@ const defaultErrorMessage string = "could not retrieve details" type ArgoStatusUpdater struct { argo Argo retryAttempts uint + retryDelay time.Duration registryProxyUrl string } -func (updater *ArgoStatusUpdater) Init(argo Argo, retryAttempts uint, registryProxyUrl string) { +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 } @@ -130,6 +133,8 @@ func (updater *ArgoStatusUpdater) checkWithRetry(task models.Task) (int, error) 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") } } @@ -149,7 +154,7 @@ func (updater *ArgoStatusUpdater) checkWithRetry(task models.Task) (int, error) return nil }, retry.DelayType(retry.FixedDelay), - retry.Delay(argoSyncRetryDelay), + retry.Delay(updater.retryDelay), retry.Attempts(updater.retryAttempts), retry.RetryIf(func(err error) bool { return errors.Is(err, errorArgoPlannedRetry) @@ -186,7 +191,7 @@ func (updater *ArgoStatusUpdater) handleAppNotFound(task models.Task, err error) 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()) - updater.argo.state.SetTaskStatus(task.Id, "aborted", reason) + updater.argo.state.SetTaskStatus(task.Id, models.StatusAborted, reason) } func (updater *ArgoStatusUpdater) handleDeploymentFailed(task models.Task, err error) { @@ -199,7 +204,7 @@ func (updater *ArgoStatusUpdater) handleDeploymentFailed(task models.Task, err e 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) - updater.argo.state.SetTaskStatus(task.Id, "deployed", "") + updater.argo.state.SetTaskStatus(task.Id, models.StatusDeployedMessage, "") } func (updater *ArgoStatusUpdater) handleAppNotAvailable(task models.Task, err error) { diff --git a/cmd/argo-watcher/argo_status_updater_test.go b/cmd/argo-watcher/argo_status_updater_test.go new file mode 100644 index 00000000..ae1050ee --- /dev/null +++ b/cmd/argo-watcher/argo_status_updater_test.go @@ -0,0 +1,399 @@ +package main + +import ( + "fmt" + "testing" + "time" + + "github.com/shini4i/argo-watcher/cmd/argo-watcher/mock" + "github.com/shini4i/argo-watcher/internal/models" + "go.uber.org/mock/gomock" +) + +func TestArgoStatusUpdaterCheck(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + t.Run("Status Updater - Application deployed", func(t *testing.T) { + // mocks + apiMock := mock.NewMockArgoApiInterface(ctrl) + metricsMock := mock.NewMockMetricsInterface(ctrl) + stateMock := mock.NewMockState(ctrl) + + // argo manager + argo := &Argo{} + argo.Init(stateMock, apiMock, metricsMock) + + // argo updater + updater := &ArgoStatusUpdater{} + updater.Init(*argo, 1, 0*time.Second, "test-registry") + + // prepare test data + task := models.Task{ + Id: "test-id", + App: "test-app", + Images: []models.Image{ + { + Image: "ghcr.io/shini4i/argo-watcher", + Tag: "dev", + }, + }, + } + + // application + application := models.Application{} + application.Status.Summary.Images = []string{"ghcr.io/shini4i/argo-watcher:dev"} + application.Status.Sync.Status = "Synced" + application.Status.Health.Status = "Healthy" + + // mock calls + apiMock.EXPECT().GetApplication(task.App).Return(&application, nil) + metricsMock.EXPECT().ResetFailedDeployment(task.App) + stateMock.EXPECT().SetTaskStatus(task.Id, models.StatusDeployedMessage, "") + + // run the rollout + updater.WaitForRollout(task) + }) + + t.Run("Status Updater - Application deployed with Retry", func(t *testing.T) { + // mocks + apiMock := mock.NewMockArgoApiInterface(ctrl) + metricsMock := mock.NewMockMetricsInterface(ctrl) + stateMock := mock.NewMockState(ctrl) + + // argo manager + argo := &Argo{} + argo.Init(stateMock, apiMock, metricsMock) + + // argo updater + updater := &ArgoStatusUpdater{} + updater.Init(*argo, 3, 0*time.Second, "test-registry") + + // prepare test data + task := models.Task{ + Id: "test-id", + App: "test-app", + Images: []models.Image{ + { + Image: "ghcr.io/shini4i/argo-watcher", + Tag: "dev", + }, + }, + } + + // unhealthyApp + unhealthyApp := models.Application{} + unhealthyApp.Status.Summary.Images = []string{"ghcr.io/shini4i/argo-watcher:dev"} + unhealthyApp.Status.Sync.Status = "Synced" + unhealthyApp.Status.Health.Status = "NotHealthy" + + // healthy app + healthyApp := models.Application{} + healthyApp.Status.Summary.Images = []string{"ghcr.io/shini4i/argo-watcher:dev"} + healthyApp.Status.Sync.Status = "Synced" + healthyApp.Status.Health.Status = "Healthy" + + // mock calls + apiMock.EXPECT().GetApplication(task.App).Return(&unhealthyApp, nil).Times(2) + apiMock.EXPECT().GetApplication(task.App).Return(&healthyApp, nil).Times(1) + metricsMock.EXPECT().ResetFailedDeployment(task.App) + stateMock.EXPECT().SetTaskStatus(task.Id, models.StatusDeployedMessage, "") + + // run the rollout + updater.WaitForRollout(task) + }) + + t.Run("Status Updater - Application deployed with Registry proxy", func(t *testing.T) { + // mocks + apiMock := mock.NewMockArgoApiInterface(ctrl) + metricsMock := mock.NewMockMetricsInterface(ctrl) + stateMock := mock.NewMockState(ctrl) + + // argo manager + argo := &Argo{} + argo.Init(stateMock, apiMock, metricsMock) + + // argo updater + updater := &ArgoStatusUpdater{} + updater.Init(*argo, 1, 0*time.Second, "test-registry") + + // prepare test data + task := models.Task{ + Id: "test-id", + App: "test-app", + Images: []models.Image{ + { + Image: "ghcr.io/shini4i/argo-watcher", + Tag: "dev", + }, + }, + } + + // application + application := models.Application{} + application.Status.Summary.Images = []string{"test-registry/ghcr.io/shini4i/argo-watcher:dev"} + application.Status.Sync.Status = "Synced" + application.Status.Health.Status = "Healthy" + + // mock calls + apiMock.EXPECT().GetApplication(task.App).Return(&application, nil) + metricsMock.EXPECT().ResetFailedDeployment(task.App) + stateMock.EXPECT().SetTaskStatus(task.Id, models.StatusDeployedMessage, "") + + // run the rollout + updater.WaitForRollout(task) + }) + + t.Run("Status Updater - Application deployed without Registry proxy", func(t *testing.T) { + // mocks + apiMock := mock.NewMockArgoApiInterface(ctrl) + metricsMock := mock.NewMockMetricsInterface(ctrl) + stateMock := mock.NewMockState(ctrl) + + // argo manager + argo := &Argo{} + argo.Init(stateMock, apiMock, metricsMock) + + // argo updater + updater := &ArgoStatusUpdater{} + updater.Init(*argo, 1, 0*time.Second, "") + + // prepare test data + task := models.Task{ + Id: "test-id", + App: "test-app", + Images: []models.Image{ + { + Image: "ghcr.io/shini4i/argo-watcher", + Tag: "dev", + }, + }, + } + + // application + application := models.Application{} + application.Status.Summary.Images = []string{"test-registry/ghcr.io/shini4i/argo-watcher:dev"} + application.Status.Sync.Status = "Synced" + application.Status.Health.Status = "Healthy" + + // mock calls + apiMock.EXPECT().GetApplication(task.App).Return(&application, nil).Times(2) + 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") + + // run the rollout + updater.WaitForRollout(task) + }) + + t.Run("Status Updater - Application not found", func(t *testing.T) { + // mocks + apiMock := mock.NewMockArgoApiInterface(ctrl) + metricsMock := mock.NewMockMetricsInterface(ctrl) + stateMock := mock.NewMockState(ctrl) + + // argo manager + argo := &Argo{} + argo.Init(stateMock, apiMock, metricsMock) + + // argo updater + updater := &ArgoStatusUpdater{} + updater.Init(*argo, 1, 0*time.Second, "test-registry") + + // prepare test data + task := models.Task{ + Id: "test-id", + App: "test-app", + } + + // mock calls + apiMock.EXPECT().GetApplication(task.App).Return(nil, fmt.Errorf("applications.argoproj.io \"test-app\" not found")) + stateMock.EXPECT().SetTaskStatus(task.Id, models.StatusAppNotFoundMessage, "ArgoCD API Error: applications.argoproj.io \"test-app\" not found") + + // run the rollout + updater.WaitForRollout(task) + }) + + t.Run("Status Updater - ArgoCD unavailable", func(t *testing.T) { + // mocks + apiMock := mock.NewMockArgoApiInterface(ctrl) + metricsMock := mock.NewMockMetricsInterface(ctrl) + stateMock := mock.NewMockState(ctrl) + + // argo manager + argo := &Argo{} + argo.Init(stateMock, apiMock, metricsMock) + + // argo updater + updater := &ArgoStatusUpdater{} + updater.Init(*argo, 1, 0*time.Second, "test-registry") + + // prepare test data + task := models.Task{ + Id: "test-id", + App: "test-app", + } + + // mock calls + apiMock.EXPECT().GetApplication(task.App).Return(nil, fmt.Errorf(argoUnavailableErrorMessage)) + stateMock.EXPECT().SetTaskStatus(task.Id, models.StatusAborted, "ArgoCD API Error: connect: connection refused") + + // run the rollout + updater.WaitForRollout(task) + }) + + t.Run("Status Updater - Application API error", func(t *testing.T) { + // mocks + apiMock := mock.NewMockArgoApiInterface(ctrl) + metricsMock := mock.NewMockMetricsInterface(ctrl) + stateMock := mock.NewMockState(ctrl) + + // argo manager + argo := &Argo{} + argo.Init(stateMock, apiMock, metricsMock) + + // argo updater + updater := &ArgoStatusUpdater{} + updater.Init(*argo, 1, 0*time.Second, "test-registry") + + // prepare test data + task := models.Task{ + Id: "test-id", + App: "test-app", + } + + // mock calls + apiMock.EXPECT().GetApplication(task.App).Return(nil, fmt.Errorf("Unexpected failure")) + metricsMock.EXPECT().AddFailedDeployment(task.App) + stateMock.EXPECT().SetTaskStatus(task.Id, models.StatusFailedMessage, "ArgoCD API Error: Unexpected failure") + + // run the rollout + updater.WaitForRollout(task) + }) + + t.Run("Status Updater - Application not available", func(t *testing.T) { + // mocks + apiMock := mock.NewMockArgoApiInterface(ctrl) + metricsMock := mock.NewMockMetricsInterface(ctrl) + stateMock := mock.NewMockState(ctrl) + + // argo manager + argo := &Argo{} + argo.Init(stateMock, apiMock, metricsMock) + + // argo updater + updater := &ArgoStatusUpdater{} + updater.Init(*argo, 1, 0*time.Second, "test-registry") + + // prepare test data + task := models.Task{ + Id: "test-id", + App: "test-app", + Images: []models.Image{ + { + Image: "ghcr.io/shini4i/argo-watcher", + Tag: "dev", + }, + }, + } + + // application + application := models.Application{} + application.Status.Summary.Images = []string{"test-image:v0.0.1"} + + // mock calls + apiMock.EXPECT().GetApplication(task.App).Return(&application, nil).Times(2) + 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") + + // run the rollout + updater.WaitForRollout(task) + }) + + t.Run("Status Updater - Application out of Sync", func(t *testing.T) { + // mocks + apiMock := mock.NewMockArgoApiInterface(ctrl) + metricsMock := mock.NewMockMetricsInterface(ctrl) + stateMock := mock.NewMockState(ctrl) + + // argo manager + argo := &Argo{} + argo.Init(stateMock, apiMock, metricsMock) + + // argo updater + updater := &ArgoStatusUpdater{} + updater.Init(*argo, 1, 0*time.Second, "test-registry") + + // prepare test data + task := models.Task{ + Id: "test-id", + App: "test-app", + Images: []models.Image{ + { + Image: "ghcr.io/shini4i/argo-watcher", + Tag: "dev", + }, + }, + } + + // application + application := models.Application{} + application.Status.Summary.Images = []string{"ghcr.io/shini4i/argo-watcher:dev"} + application.Status.Sync.Status = "Syncing" + application.Status.Health.Status = "Healthy" + application.Status.OperationState.Phase = "NotWorking" + application.Status.OperationState.Message = "Not working test app" + + // mock calls + apiMock.EXPECT().GetApplication(task.App).Return(&application, nil).Times(2) + 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") + + // run the rollout + updater.WaitForRollout(task) + }) + + t.Run("Status Updater - Application not healthy", func(t *testing.T) { + // mocks + apiMock := mock.NewMockArgoApiInterface(ctrl) + metricsMock := mock.NewMockMetricsInterface(ctrl) + stateMock := mock.NewMockState(ctrl) + + // argo manager + argo := &Argo{} + argo.Init(stateMock, apiMock, metricsMock) + + // argo updater + updater := &ArgoStatusUpdater{} + updater.Init(*argo, 1, 0*time.Second, "test-registry") + + // prepare test data + task := models.Task{ + Id: "test-id", + App: "test-app", + Images: []models.Image{ + { + Image: "ghcr.io/shini4i/argo-watcher", + Tag: "dev", + }, + }, + } + + // application + application := models.Application{} + application.Status.Summary.Images = []string{"ghcr.io/shini4i/argo-watcher:dev"} + application.Status.Sync.Status = "Synced" + application.Status.Health.Status = "NotHealthy" + + // mock calls + apiMock.EXPECT().GetApplication(task.App).Return(&application, nil).Times(2) + 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") + + // run the rollout + updater.WaitForRollout(task) + }) +} diff --git a/cmd/argo-watcher/main.go b/cmd/argo-watcher/main.go index e34ad969..54361f70 100644 --- a/cmd/argo-watcher/main.go +++ b/cmd/argo-watcher/main.go @@ -4,23 +4,28 @@ import ( "errors" "flag" "fmt" - "github.com/shini4i/argo-watcher/pkg/client" "os" + + "github.com/shini4i/argo-watcher/pkg/client" ) -var invalidModeError = errors.New("invalid mode") +var errorInvalidMode = errors.New("invalid mode") func runWatcher(serverFlag, clientFlag bool) error { - if serverFlag && clientFlag { - return invalidModeError - } else if serverFlag { + // start server if requested + if serverFlag && !clientFlag { serverWatcher() - } else if clientFlag { + return nil + } + + // start client if requested + if clientFlag && !serverFlag { client.ClientWatcher() - } else { - return invalidModeError + return nil } - return nil + + // return error. we must start client or server + return errorInvalidMode } func printUsage() { diff --git a/cmd/argo-watcher/router.go b/cmd/argo-watcher/router.go index 58f41f90..b316f6a7 100644 --- a/cmd/argo-watcher/router.go +++ b/cmd/argo-watcher/router.go @@ -19,7 +19,7 @@ import ( var version = "local" -// reference: https://www.alexedwards.net/blog/organising-database-access +// Env reference: https://www.alexedwards.net/blog/organising-database-access type Env struct { // environment configurations config *config.ServerConfig @@ -31,7 +31,7 @@ type Env struct { metrics *Metrics } -// initialize router. +// CreateRouter initialize router. func (env *Env) CreateRouter() *gin.Engine { docs.SwaggerInfo.Title = "Argo-Watcher API" docs.SwaggerInfo.Version = version diff --git a/cmd/argo-watcher/server.go b/cmd/argo-watcher/server.go index 227d7c5b..2aa58b22 100644 --- a/cmd/argo-watcher/server.go +++ b/cmd/argo-watcher/server.go @@ -54,7 +54,7 @@ func serverWatcher() { // initialize argo updater updater := &ArgoStatusUpdater{} - updater.Init(*argo, serverConfig.GetRetryAttempts(), serverConfig.RegistryProxyUrl) + updater.Init(*argo, serverConfig.GetRetryAttempts(), argoSyncRetryDelay, serverConfig.RegistryProxyUrl) // create environment env := &Env{config: serverConfig, argo: argo, metrics: metrics, updater: updater} diff --git a/docs/development.md b/docs/development.md index 4fda830c..ad5924e7 100644 --- a/docs/development.md +++ b/docs/development.md @@ -10,12 +10,14 @@ They can be installed by running: pre-commit install ``` +To compile the project locally, you would also need to generate mocks (for testing) and swagger docs (for api documentation). + ### Mock classes To generate mock classes for unit tests, first install `gomock` tool. ```shell -go install github.com/golang/mock/mockgen@v1.6.0 +go install go.uber.org/mock/mockgen@latest ``` Then run the mock generation from interfaces. @@ -24,6 +26,20 @@ Then run the mock generation from interfaces. make mocks ``` +### Swagger documentation + +To generate documentation dependencies, first install `swag` tool. + +```shell +go install github.com/swaggo/swag/cmd/swag@latest +``` + +Then run the swagger doc generation. + +```shell +make docs +``` + > Note: you need to run this only when you're changing the interfaces ## Back-End Development @@ -50,6 +66,19 @@ go mod tidy ARGO_URL=http://localhost:8081 STATE_TYPE=in-memory go run . ``` +### Running the unit tests + +Use the following snippets to run argo-watcher unit tests + +```shell +# go to backend directory +cd cmd/argo-watcher +# run all tests +go test -v +# run single test suite +go test -v -run TestArgoStatusUpdaterCheck +``` + ## Front-End Development To start developing front-end you will need diff --git a/internal/models/constants.go b/internal/models/constants.go index 43dd60b5..0e0ae5c9 100644 --- a/internal/models/constants.go +++ b/internal/models/constants.go @@ -4,6 +4,7 @@ const ( StatusAppNotFoundMessage = "app not found" StatusInProgressMessage = "in progress" StatusFailedMessage = "failed" + StatusAborted = "aborted" StatusArgoCDUnavailableMessage = "argocd is unavailable" StatusConnectionUnavailable = "cannot connect to database" StatusArgoCDFailedLogin = "failed to login to argocd"