diff --git a/cmd/argo-watcher/argocd/argo_status_updater.go b/cmd/argo-watcher/argocd/argo_status_updater.go index 21e05b03..8c1fa652 100644 --- a/cmd/argo-watcher/argocd/argo_status_updater.go +++ b/cmd/argo-watcher/argocd/argo_status_updater.go @@ -9,6 +9,9 @@ import ( "sync" "time" + "github.com/shini4i/argo-watcher/cmd/argo-watcher/config" + "github.com/shini4i/argo-watcher/cmd/argo-watcher/notifications" + "github.com/shini4i/argo-watcher/internal/helpers" "github.com/avast/retry-go/v4" @@ -34,9 +37,10 @@ type ArgoStatusUpdater struct { retryOptions []retry.Option mutex MutexMap acceptSuspended bool + webhookService *notifications.WebhookService } -func (updater *ArgoStatusUpdater) Init(argo Argo, retryAttempts uint, retryDelay time.Duration, registryProxyUrl string, acceptSuspended bool) { +func (updater *ArgoStatusUpdater) Init(argo Argo, retryAttempts uint, retryDelay time.Duration, registryProxyUrl string, acceptSuspended bool, webhookConfig *config.WebhookConfig) { updater.argo = argo updater.registryProxyUrl = registryProxyUrl updater.retryOptions = []retry.Option{ @@ -46,6 +50,7 @@ func (updater *ArgoStatusUpdater) Init(argo Argo, retryAttempts uint, retryDelay retry.LastErrorOnly(true), } updater.acceptSuspended = acceptSuspended + updater.webhookService = notifications.NewWebhookService(webhookConfig) } func (updater *ArgoStatusUpdater) collectInitialAppStatus(task *models.Task) error { @@ -68,6 +73,9 @@ func (updater *ArgoStatusUpdater) collectInitialAppStatus(task *models.Task) err } func (updater *ArgoStatusUpdater) WaitForRollout(task models.Task) { + // notify about the deployment start + sendWebhookEvent(task, updater.webhookService) + // wait for application to get into deployed status or timeout application, err := updater.waitForApplicationDeployment(task) @@ -87,10 +95,11 @@ func (updater *ArgoStatusUpdater) WaitForRollout(task models.Task) { // 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) + if err := updater.argo.State.SetTaskStatus(task.Id, models.StatusDeployedMessage, ""); err != nil { + log.Error().Str("id", task.Id).Msgf(failedToUpdateTaskStatusTemplate, err) } + // setting task status to handle further notifications + task.Status = models.StatusDeployedMessage } else { log.Info().Str("id", task.Id).Msg("App deployment failed.") // deployment failed @@ -102,11 +111,15 @@ func (updater *ArgoStatusUpdater) WaitForRollout(task models.Task) { 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) + if err := updater.argo.State.SetTaskStatus(task.Id, models.StatusFailedMessage, reason); err != nil { + log.Error().Str("id", task.Id).Msgf(failedToUpdateTaskStatusTemplate, err) } + // setting task status to handle further notifications + task.Status = models.StatusFailedMessage } + + // send webhook event about the deployment result + sendWebhookEvent(task, updater.webhookService) } func (updater *ArgoStatusUpdater) waitForApplicationDeployment(task models.Task) (*models.Application, error) { @@ -238,3 +251,11 @@ func (updater *ArgoStatusUpdater) handleArgoAPIFailure(task models.Task, err err log.Error().Str("id", task.Id).Msgf(failedToUpdateTaskStatusTemplate, err) } } + +func sendWebhookEvent(task models.Task, webhookService *notifications.WebhookService) { + if webhookService.Enabled { + if err := webhookService.SendWebhook(task); err != nil { + log.Error().Str("id", task.Id).Msgf("Failed to send webhook. Error: %s", err.Error()) + } + } +} diff --git a/cmd/argo-watcher/argocd/argo_status_updater_test.go b/cmd/argo-watcher/argocd/argo_status_updater_test.go index d0a700d3..fb0804ff 100644 --- a/cmd/argo-watcher/argocd/argo_status_updater_test.go +++ b/cmd/argo-watcher/argocd/argo_status_updater_test.go @@ -6,6 +6,8 @@ import ( "testing" "time" + "github.com/shini4i/argo-watcher/cmd/argo-watcher/config" + "github.com/stretchr/testify/assert" "github.com/shini4i/argo-watcher/cmd/argo-watcher/mock" @@ -13,6 +15,12 @@ import ( "go.uber.org/mock/gomock" ) +var ( + mockWebhookConfig = &config.WebhookConfig{ + Enabled: false, + } +) + func TestArgoStatusUpdaterCheck(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -29,7 +37,7 @@ func TestArgoStatusUpdaterCheck(t *testing.T) { // argo updater updater := &ArgoStatusUpdater{} - updater.Init(*argo, 1, 0*time.Second, "test-registry", false) + updater.Init(*argo, 1, 0*time.Second, "test-registry", false, mockWebhookConfig) // prepare test data task := models.Task{ @@ -70,7 +78,7 @@ func TestArgoStatusUpdaterCheck(t *testing.T) { // argo updater updater := &ArgoStatusUpdater{} - updater.Init(*argo, 3, 0*time.Second, "test-registry", false) + updater.Init(*argo, 3, 0*time.Second, "test-registry", false, mockWebhookConfig) // prepare test data task := models.Task{ @@ -118,7 +126,7 @@ func TestArgoStatusUpdaterCheck(t *testing.T) { // argo updater updater := &ArgoStatusUpdater{} - updater.Init(*argo, 1, 0*time.Second, "test-registry", false) + updater.Init(*argo, 1, 0*time.Second, "test-registry", false, mockWebhookConfig) // prepare test data task := models.Task{ @@ -159,7 +167,7 @@ func TestArgoStatusUpdaterCheck(t *testing.T) { // argo updater updater := &ArgoStatusUpdater{} - updater.Init(*argo, 1, 0*time.Second, "", false) + updater.Init(*argo, 1, 0*time.Second, "", false, mockWebhookConfig) // prepare test data task := models.Task{ @@ -201,7 +209,7 @@ func TestArgoStatusUpdaterCheck(t *testing.T) { // argo updater updater := &ArgoStatusUpdater{} - updater.Init(*argo, 1, 0*time.Second, "test-registry", false) + updater.Init(*argo, 1, 0*time.Second, "test-registry", false, mockWebhookConfig) // prepare test data task := models.Task{ @@ -230,7 +238,7 @@ func TestArgoStatusUpdaterCheck(t *testing.T) { // argo updater updater := &ArgoStatusUpdater{} - updater.Init(*argo, 1, 0*time.Second, "test-registry", false) + updater.Init(*argo, 1, 0*time.Second, "test-registry", false, mockWebhookConfig) // prepare test data task := models.Task{ @@ -259,7 +267,7 @@ func TestArgoStatusUpdaterCheck(t *testing.T) { // argo updater updater := &ArgoStatusUpdater{} - updater.Init(*argo, 1, 0*time.Second, "test-registry", false) + updater.Init(*argo, 1, 0*time.Second, "test-registry", false, mockWebhookConfig) // prepare test data task := models.Task{ @@ -288,7 +296,7 @@ func TestArgoStatusUpdaterCheck(t *testing.T) { // argo updater updater := &ArgoStatusUpdater{} - updater.Init(*argo, 1, 0*time.Second, "test-registry", false) + updater.Init(*argo, 1, 0*time.Second, "test-registry", false, mockWebhookConfig) // prepare test data task := models.Task{ @@ -328,7 +336,7 @@ func TestArgoStatusUpdaterCheck(t *testing.T) { // argo updater updater := &ArgoStatusUpdater{} - updater.Init(*argo, 1, 0*time.Second, "test-registry", false) + updater.Init(*argo, 1, 0*time.Second, "test-registry", false, mockWebhookConfig) // prepare test data task := models.Task{ @@ -372,7 +380,7 @@ func TestArgoStatusUpdaterCheck(t *testing.T) { // argo updater updater := &ArgoStatusUpdater{} - updater.Init(*argo, 1, 0*time.Second, "test-registry", false) + updater.Init(*argo, 1, 0*time.Second, "test-registry", false, mockWebhookConfig) // prepare test data task := models.Task{ diff --git a/cmd/argo-watcher/config/config.go b/cmd/argo-watcher/config/config.go index d9fd0234..d408eec0 100644 --- a/cmd/argo-watcher/config/config.go +++ b/cmd/argo-watcher/config/config.go @@ -30,9 +30,17 @@ type DatabaseConfig struct { DSN string `env:"DB_DSN,expand" envDefault:"host=${DB_HOST} port=${DB_PORT} user=${DB_USER} password=${DB_PASSWORD} dbname=${DB_NAME} sslmode=${DB_SSL_MODE} TimeZone=${DB_TIMEZONE}"` } +type WebhookConfig struct { + Enabled bool `env:"WEBHOOK_ENABLED" envDefault:"false" json:"enabled"` + Url string `env:"WEBHOOK_URL" json:"url,omitempty"` + Format string `env:"WEBHOOK_FORMAT" json:"format,omitempty"` + AuthorizationHeader string `env:"WEBHOOK_AUTHORIZATION_HEADER_NAME" envDefault:"Authorization" json:"authorization_header,omitempty"` + Token string `env:"WEBHOOK_AUTHORIZATION_HEADER_VALUE" envDefault:"" json:"-"` +} + 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. + 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 @@ -51,6 +59,7 @@ type ServerConfig struct { Keycloak KeycloakConfig `json:"keycloak,omitempty"` ScheduledLockdownEnabled bool `env:"SCHEDULED_LOCKDOWN_ENABLED" envDefault:"false" json:"scheduled_lockdown_enabled"` LockdownSchedule LockdownSchedules `env:"LOCKDOWN_SCHEDULE" json:"-"` + Webhook WebhookConfig `json:"webhook,omitempty"` } // NewServerConfig parses the server configuration from environment variables using the envconfig package. diff --git a/cmd/argo-watcher/notifications/webhook.go b/cmd/argo-watcher/notifications/webhook.go new file mode 100644 index 00000000..3b599c18 --- /dev/null +++ b/cmd/argo-watcher/notifications/webhook.go @@ -0,0 +1,70 @@ +package notifications + +import ( + "bytes" + "fmt" + "io" + "net/http" + "text/template" + + "github.com/rs/zerolog/log" + + "github.com/shini4i/argo-watcher/cmd/argo-watcher/config" + "github.com/shini4i/argo-watcher/internal/models" +) + +type WebhookService struct { + Enabled bool + config *config.WebhookConfig + client *http.Client +} + +func NewWebhookService(webhookConfig *config.WebhookConfig) *WebhookService { + return &WebhookService{ + Enabled: webhookConfig.Enabled, + config: webhookConfig, + client: &http.Client{}, + } +} + +func (service *WebhookService) SendWebhook(task models.Task) error { + tmpl, err := template.New("webhook").Parse(service.config.Format) + if err != nil { + return err + } + + var payload bytes.Buffer + if err := tmpl.Execute(&payload, task); err != nil { + return err + } + + log.Debug().Str("id", task.Id).Msgf("Sending webhook payload: %s", payload.String()) + + req, err := http.NewRequest("POST", service.config.Url, &payload) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + if service.config.Token != "" { + req.Header.Set(service.config.AuthorizationHeader, service.config.Token) + } + + resp, err := service.client.Do(req) + if err != nil { + return err + } + + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + log.Error().Err(err).Msg("Failed to close response body") + } + }(resp.Body) + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("received non-OK status code: %v", resp.StatusCode) + } + + return nil +} diff --git a/cmd/argo-watcher/notifications/webhook_test.go b/cmd/argo-watcher/notifications/webhook_test.go new file mode 100644 index 00000000..d5f62a27 --- /dev/null +++ b/cmd/argo-watcher/notifications/webhook_test.go @@ -0,0 +1,91 @@ +package notifications + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/shini4i/argo-watcher/cmd/argo-watcher/config" + "github.com/shini4i/argo-watcher/internal/models" + "github.com/stretchr/testify/assert" +) + +type TestWebhookPayload struct { + Id string `json:"id"` + App string `json:"app"` + Status string `json:"status"` +} + +var mockTask = models.Task{ + Id: "test-id", + App: "test-app", + Status: "test-status", +} + +const expectedAuthToken = "Bearer the-test-token" + +func setupTestServer(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := r.Header.Get("Authorization") + assert.Equal(t, expectedAuthToken, token) + + body, _ := io.ReadAll(r.Body) + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + t.Error(err) + } + }(r.Body) + + var payload TestWebhookPayload + err := json.Unmarshal(body, &payload) + assert.NoError(t, err) + checkPayload(t, payload) + })) +} + +func setupErrorTestServer() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) +} + +func checkPayload(t *testing.T, payload TestWebhookPayload) { + assert.Equal(t, mockTask.Id, payload.Id) + assert.Equal(t, mockTask.App, payload.App) + assert.Equal(t, mockTask.Status, payload.Status) +} + +func TestSendWebhook(t *testing.T) { + t.Run("Test webhook payload", func(t *testing.T) { + testServer := setupTestServer(t) + defer testServer.Close() + + webhookConfig := config.WebhookConfig{ + Url: testServer.URL, + Format: `{"id": "{{.Id}}","app": "{{.App}}","status": "{{.Status}}"}`, + AuthorizationHeader: "Authorization", + Token: expectedAuthToken, + } + + service := NewWebhookService(&webhookConfig) + err := service.SendWebhook(mockTask) + assert.NoError(t, err) + }) + + t.Run("Test error response", func(t *testing.T) { + testServer := setupErrorTestServer() + defer testServer.Close() + + webhookConfig := config.WebhookConfig{ + Url: testServer.URL, + Format: `{"id": "{{.Id}}","app": "{{.App}}","status": "{{.Status}}"}`, + } + + service := NewWebhookService(&webhookConfig) + err := service.SendWebhook(mockTask) + assert.Error(t, err) + }) +} diff --git a/cmd/argo-watcher/server/server.go b/cmd/argo-watcher/server/server.go index 1e54a846..ece111dc 100644 --- a/cmd/argo-watcher/server/server.go +++ b/cmd/argo-watcher/server/server.go @@ -74,6 +74,7 @@ func RunServer() { argocd.ArgoSyncRetryDelay, serverConfig.RegistryProxyUrl, serverConfig.AcceptSuspendedApp, + &serverConfig.Webhook, ) // create environment