From 42be5834afdd0c88fc54cbba097b01917491a705 Mon Sep 17 00:00:00 2001 From: EyJunge1 <149941075+EyJunge1@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:36:16 +0200 Subject: [PATCH] feat(alerts): named project destinations instead of instance-wide fan-out Replace instance-wide fan-out with named project alerts, project defaults, and per-job/schedule selection. Existing projects keep their previous behavior through migration. --- .dredd/hooks/capabilities.go | 19 + .dredd/hooks/helpers.go | 19 + .dredd/hooks/main.go | 13 +- api-docs.yml | 227 ++++++++- api/projects/alerts.go | 180 +++++++ api/projects/project.go | 19 +- api/router.go | 11 + db/Alert.go | 230 +++++++++ db/AlertSnapshot.go | 49 ++ db/Alert_test.go | 164 +++++++ db/Event.go | 1 + db/ExportEntityType.go | 4 + db/Migration.go | 1 + db/Schedule.go | 22 +- db/Store.go | 30 ++ db/Task.go | 2 + db/Template.go | 34 ++ db/sql/SqlDb.go | 32 ++ db/sql/alert.go | 423 ++++++++++++++++ db/sql/alert_test.go | 217 +++++++++ db/sql/migration.go | 2 + db/sql/migration_2_19_14_test.go | 22 +- db/sql/migration_2_20_6.go | 168 +++++++ db/sql/migration_2_20_6_test.go | 174 +++++++ db/sql/migrations/v2.20.6.err.sql | 15 + db/sql/migrations/v2.20.6.sql | 60 +++ db/sql/schedule.go | 140 +++++- db/sql/template.go | 135 +++++- db/sql/template_test.go | 5 +- db/sql/template_vault.go | 27 +- services/export/Alert.go | 91 ++++ services/export/Alert_test.go | 43 ++ services/export/Event.go | 4 +- services/export/Exporter.go | 2 + services/export/Schedule.go | 7 +- services/export/Task.go | 9 +- services/export/Template.go | 7 +- services/project/backup.go | 41 +- services/project/backup_test.go | 126 +++++ services/project/restore.go | 52 +- services/project/types.go | 17 +- services/tasks/TaskPool.go | 4 + services/tasks/TaskRunner_logging.go | 13 +- services/tasks/alert.go | 642 ++++++++++++++++++++++++- services/tasks/alert_resolve.go | 60 +++ services/tasks/alert_resolve_test.go | 91 ++++ services/tasks/alert_test.go | 145 +++++- services/tasks/alert_test_sender.go | 70 ++- services/tasks/templates/email.tmpl | 2 +- services/tasks/templates/telegram.tmpl | 9 +- web/src/App.vue | 7 + web/src/components/AlertForm.vue | 263 ++++++++++ web/src/components/ObjectRefsView.vue | 2 + web/src/components/ProjectForm.vue | 16 - web/src/components/ScheduleForm.vue | 75 ++- web/src/components/TemplateForm.vue | 82 +++- web/src/lang/cs.js | 3 + web/src/lang/de.js | 3 + web/src/lang/en.js | 38 +- web/src/lang/es.js | 3 + web/src/lang/fr.js | 3 + web/src/lang/it.js | 3 + web/src/lang/ja.js | 3 + web/src/lang/ko.js | 3 + web/src/lang/nl.js | 3 + web/src/lang/pl.js | 3 + web/src/lang/pt.js | 3 + web/src/lang/pt_br.js | 3 + web/src/lang/ru.js | 3 + web/src/lang/uk.js | 3 + web/src/lang/zh_cn.js | 3 + web/src/lang/zh_tw.js | 3 + web/src/router/index.js | 5 + web/src/views/project/Alerts.vue | 202 ++++++++ web/src/views/project/Settings.vue | 49 +- 75 files changed, 4451 insertions(+), 213 deletions(-) create mode 100644 api/projects/alerts.go create mode 100644 db/Alert.go create mode 100644 db/AlertSnapshot.go create mode 100644 db/Alert_test.go create mode 100644 db/sql/alert.go create mode 100644 db/sql/alert_test.go create mode 100644 db/sql/migration_2_20_6.go create mode 100644 db/sql/migration_2_20_6_test.go create mode 100644 db/sql/migrations/v2.20.6.err.sql create mode 100644 db/sql/migrations/v2.20.6.sql create mode 100644 services/export/Alert.go create mode 100644 services/export/Alert_test.go create mode 100644 services/tasks/alert_resolve.go create mode 100644 services/tasks/alert_resolve_test.go create mode 100644 web/src/components/AlertForm.vue create mode 100644 web/src/views/project/Alerts.vue diff --git a/.dredd/hooks/capabilities.go b/.dredd/hooks/capabilities.go index 224916f1d4..206e558600 100644 --- a/.dredd/hooks/capabilities.go +++ b/.dredd/hooks/capabilities.go @@ -40,6 +40,7 @@ var integrationMatchID int var workflowID int var workflowRunID int var workflowNodeID int +var projectAlert *db.Alert var capabilities = map[string][]string{ "user": {}, @@ -51,6 +52,7 @@ var capabilities = map[string][]string{ "task": {"template"}, "schedule": {"template"}, "view": {}, + "alert": {"project"}, "integration": {"project", "template"}, "integrationextractvalue": {"integration"}, "integrationmatcher": {"integration"}, @@ -97,6 +99,8 @@ func resolveCapability(caps []string, resolved []string, uid string) { schedule = addSchedule() case "view": view = addView() + case "alert": + projectAlert = addAlert() case "user": userPathTestUser = addUser() case "project": @@ -256,6 +260,12 @@ var pathSubPatterns = []func() string{ func() string { return strconv.Itoa(workflowNodeID) }, // node_id, x-example: 20 + func() string { + if projectAlert == nil { + return "0" + } + return strconv.Itoa(projectAlert.ID) + }, // alert_id, x-example: 21 } // alterRequestPath with the above slice of functions @@ -300,6 +310,15 @@ func alterRequestBody(t *trans.Transaction) { bodyFieldProcessor("environment_id", environmentID, &request) bodyFieldProcessor("environment_ids", []int{environmentID}, &request) + // Dredd fills integer arrays with a dummy ID. Never reuse a leftover + // projectAlert here: that alert belongs to an earlier fixture project. + bodyFieldProcessor("alert_ids", []int{}, &request) + if typ, ok := request["type"].(string); ok && typ == "telegram" { + chat, _ := request["chat_id"].(string) + if strings.TrimSpace(chat) == "" { + request["chat_id"] = "12345" + } + } bodyFieldProcessor("inventory_id", inventoryID, &request) bodyFieldProcessor("repository_id", repoID, &request) bodyFieldProcessor("template_id", templateID, &request) diff --git a/.dredd/hooks/helpers.go b/.dredd/hooks/helpers.go index 01aade0a0b..7a7104ad2b 100644 --- a/.dredd/hooks/helpers.go +++ b/.dredd/hooks/helpers.go @@ -63,6 +63,10 @@ func truncateAll() { "project__user", "user", "project__view", + "project__alert", + "project__template_alert", + "project__schedule_alert", + "task__alert_send", "project__integration", "project__integration_extract_value", "project__integration_matcher", @@ -203,6 +207,21 @@ func addView() *db.View { return &view } +func addAlert() *db.Alert { + chatID := "12345" + alert, err := store.CreateAlert(db.Alert{ + ProjectID: userProject.ID, + Name: "ITA-" + getUUID(), + Type: db.AlertTypeTelegram, + Enabled: true, + ChatID: &chatID, + }) + if err != nil { + panic(err) + } + return &alert +} + func addInvite() *db.ProjectInvite { invite, err := store.CreateProjectInvite(db.ProjectInvite{ ProjectID: userProject.ID, diff --git a/.dredd/hooks/main.go b/.dredd/hooks/main.go index 566126eb72..a49de05441 100644 --- a/.dredd/hooks/main.go +++ b/.dredd/hooks/main.go @@ -202,8 +202,19 @@ func main() { h.Before("project > /api/project/{project_id}/views/{view_id} > Updates view > 204 > application/json", capabilityWrapper("view")) h.Before("project > /api/project/{project_id}/views/{view_id} > Removes view > 204 > application/json", capabilityWrapper("view")) + h.Before("project > /api/project/{project_id}/alerts > Create alert > 201 > application/json", func(t *trans.Transaction) { + // project_id must be present so setupObjectsAndPaths can replace it + // with the fixture project (same pattern as other POST bodies). + t.Request.Body = `{"name":"ITA-dredd","type":"telegram","enabled":true,"project_id":1,"chat_id":"12345"}` + }) + h.Before("project > /api/project/{project_id}/alerts/{alert_id} > Get alert > 200 > application/json", capabilityWrapper("alert")) + h.Before("project > /api/project/{project_id}/alerts/{alert_id} > Update alert > 204 > application/json", capabilityWrapper("alert")) + h.Before("project > /api/project/{project_id}/alerts/{alert_id} > Delete alert > 204 > application/json", capabilityWrapper("alert")) + h.Before("project > /api/project/{project_id}/alerts/{alert_id}/refs > Get objects that reference this alert > 200 > application/json", capabilityWrapper("alert")) + h.Before("project > /api/project/{project_id}/alerts/{alert_id}/test > Send a test message for this alert > 204 > application/json", skipTest) + h.Before("project > /api/project/{project_id}/backup > Get backup > 200 > application/json", func(t *trans.Transaction) { - addCapabilities([]string{"repository", "inventory", "environment", "view", "template"}) + addCapabilities([]string{"repository", "inventory", "environment", "view", "template", "alert"}) }) // global runners (admin) diff --git a/api-docs.yml b/api-docs.yml index eb52356839..03fc3aa5f9 100644 --- a/api-docs.yml +++ b/api-docs.yml @@ -972,6 +972,20 @@ definitions: type: boolean suppress_error_alerts: type: boolean + alert_mode: + type: string + enum: [default, ids] + description: default uses the project's default alerts; ids uses alert_ids (empty means silent). + alert_ids: + type: array + items: + type: integer + example: [] + description: Used when alert_mode is ids. Empty means this job sends no alerts. + alert_on_success: + type: boolean + alert_on_error: + type: boolean app: type: string example: ansible @@ -1043,6 +1057,20 @@ definitions: type: boolean suppress_error_alerts: type: boolean + alert_mode: + type: string + enum: [default, ids] + description: default uses the project's default alerts; ids uses alert_ids (empty means silent). + alert_ids: + type: array + items: + type: integer + example: [] + description: Used when alert_mode is ids. Empty means this job sends no alerts. + alert_on_success: + type: boolean + alert_on_error: + type: boolean app: type: string git_branch: @@ -1149,6 +1177,21 @@ definitions: enum: ['', 'run_at'] task_params: $ref: '#/definitions/TaskPrams' + alert_mode: + type: string + enum: [inherit, ids] + description: inherit uses the template alert list; ids uses alert_ids (may be empty). + alert_ids: + type: array + items: + type: integer + example: [] + alert_on_success: + type: boolean + x-nullable: true + alert_on_error: + type: boolean + x-nullable: true Schedule: type: object @@ -1173,6 +1216,21 @@ definitions: enum: ['', 'run_at'] task_params: $ref: '#/definitions/TaskPrams' + alert_mode: + type: string + enum: [inherit, ids] + description: inherit uses the template alert list; ids uses alert_ids (may be empty). + alert_ids: + type: array + items: + type: integer + example: [] + alert_on_success: + type: boolean + x-nullable: true + alert_on_error: + type: boolean + x-nullable: true ViewRequest: type: object @@ -1211,6 +1269,44 @@ definitions: sort_reverse: type: boolean + Alert: + type: object + properties: + id: + type: integer + project_id: + type: integer + name: + type: string + example: Ops Telegram + type: + type: string + enum: [email, telegram, slack, teams, rocketchat, dingtalk, gotify] + example: telegram + enabled: + type: boolean + example: true + is_default: + type: boolean + description: When true, jobs with alert_mode=default send this destination. + chat_id: + type: string + example: '12345' + thread_id: + type: string + pattern: '^[1-9][0-9]*$' + description: Optional positive Telegram forum topic ID (message_thread_id). + example: '42' + url: + type: string + token: + type: string + description: Write-only Gotify token. Omitted in responses. Omit or send null on update to keep the stored token. + recipients: + type: string + body: + type: string + Runner: type: object properties: @@ -1612,6 +1708,13 @@ parameters: type: integer required: true x-example: 10 + alert_id: + name: alert_id + description: alert ID + in: path + type: integer + required: true + x-example: 21 integration_id: name: integration_id description: integration ID @@ -3427,6 +3530,128 @@ paths: 204: description: view removed + /project/{project_id}/alerts: + parameters: + - $ref: "#/parameters/project_id" + get: + tags: + - project + summary: Get alerts + responses: + 200: + description: alerts + schema: + type: array + items: + $ref: "#/definitions/Alert" + post: + tags: + - project + summary: Create alert + parameters: + - name: alert + in: body + required: true + schema: + $ref: "#/definitions/Alert" + responses: + 201: + description: alert created + schema: + $ref: "#/definitions/Alert" + /project/{project_id}/alerts/defaults: + parameters: + - $ref: "#/parameters/project_id" + get: + tags: + - project + summary: Get default message templates per alert type + responses: + 200: + description: default bodies keyed by alert type + schema: + type: object + properties: + email: + type: string + telegram: + type: string + slack: + type: string + teams: + type: string + rocketchat: + type: string + dingtalk: + type: string + gotify: + type: string + /project/{project_id}/alerts/{alert_id}: + parameters: + - $ref: "#/parameters/project_id" + - $ref: "#/parameters/alert_id" + get: + tags: + - project + summary: Get alert + responses: + 200: + description: alert + schema: + $ref: "#/definitions/Alert" + put: + tags: + - project + summary: Update alert + parameters: + - name: alert + in: body + required: true + schema: + $ref: "#/definitions/Alert" + responses: + 204: + description: alert updated + delete: + tags: + - project + summary: Delete alert + responses: + 204: + description: alert removed + /project/{project_id}/alerts/{alert_id}/refs: + parameters: + - $ref: "#/parameters/project_id" + - $ref: "#/parameters/alert_id" + get: + tags: + - project + summary: Get objects that reference this alert + responses: + 200: + description: referrers + schema: + type: object + properties: + templates: + type: array + items: + type: object + schedules: + type: array + items: + type: object + /project/{project_id}/alerts/{alert_id}/test: + parameters: + - $ref: "#/parameters/project_id" + - $ref: "#/parameters/alert_id" + post: + tags: + - project + summary: Send a test message for this alert + responses: + 204: + description: test sent # tasks /project/{project_id}/tasks: @@ -3600,7 +3825,7 @@ paths: - $ref: "#/parameters/project_id" responses: 409: - description: Alerts not enabled for the project + description: No enabled project alerts exist # 204: # description: Test notification dispatched (or alerts disabled) diff --git a/api/projects/alerts.go b/api/projects/alerts.go new file mode 100644 index 0000000000..492ef9c401 --- /dev/null +++ b/api/projects/alerts.go @@ -0,0 +1,180 @@ +package projects + +import ( + "fmt" + "net/http" + + "github.com/semaphoreui/semaphore/api/helpers" + "github.com/semaphoreui/semaphore/db" + "github.com/semaphoreui/semaphore/services/tasks" +) + +func AlertMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + project := helpers.GetFromContext(r, "project").(db.Project) + alertID, ok := helpers.GetIntParamOrAbort("alert_id", w, r) + if !ok { + return + } + + alert, err := helpers.Store(r).GetAlert(project.ID, alertID) + if err != nil { + helpers.WriteError(w, err) + return + } + + r = helpers.SetContextValue(r, "alert", alert) + next.ServeHTTP(w, r) + }) +} + +func GetAlertDefaults(w http.ResponseWriter, r *http.Request) { + bodies, err := tasks.DefaultAlertBodies() + if err != nil { + helpers.WriteError(w, err) + return + } + helpers.WriteJSON(w, http.StatusOK, bodies) +} + +func GetAlerts(w http.ResponseWriter, r *http.Request) { + if alert := helpers.GetFromContext(r, "alert"); alert != nil { + helpers.WriteJSON(w, http.StatusOK, alert.(db.Alert)) + return + } + + project := helpers.GetFromContext(r, "project").(db.Project) + alerts, err := helpers.Store(r).GetAlerts(project.ID, helpers.QueryParams(r.URL)) + if err != nil { + helpers.WriteError(w, err) + return + } + + helpers.WriteJSON(w, http.StatusOK, alerts) +} + +func AddAlert(w http.ResponseWriter, r *http.Request) { + project := helpers.GetFromContext(r, "project").(db.Project) + var alert db.Alert + if !helpers.Bind(w, r, &alert) { + return + } + + if alert.ProjectID != project.ID { + helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ + "error": "Project ID in body and URL must be the same", + }) + return + } + + alert.Normalize() + if err := alert.Validate(); err != nil { + helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ + "error": err.Error(), + }) + return + } + + newAlert, err := helpers.Store(r).CreateAlert(alert) + if err != nil { + helpers.WriteError(w, err) + return + } + + helpers.EventLog(r, helpers.EventLogCreate, helpers.EventLogItem{ + UserID: helpers.UserFromContext(r).ID, + ProjectID: newAlert.ProjectID, + ObjectType: db.EventAlert, + ObjectID: newAlert.ID, + Description: fmt.Sprintf("Alert %s created", alert.Name), + }) + + helpers.WriteJSON(w, http.StatusCreated, newAlert) +} + +func UpdateAlert(w http.ResponseWriter, r *http.Request) { + old := helpers.GetFromContext(r, "alert").(db.Alert) + var alert db.Alert + if !helpers.Bind(w, r, &alert) { + return + } + + if alert.ID != old.ID { + helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ + "error": "Alert ID in URL and in body must be the same", + }) + return + } + + alert.ProjectID = old.ProjectID + if alert.Type == db.AlertTypeGotify && alert.Token == nil { + alert.Token = old.Token + } + alert.Normalize() + if alert.Type == db.AlertTypeGotify && alert.Token == nil { + alert.Token = old.Token + } + if err := alert.Validate(); err != nil { + helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ + "error": err.Error(), + }) + return + } + + if err := helpers.Store(r).UpdateAlert(alert); err != nil { + helpers.WriteError(w, err) + return + } + + helpers.EventLog(r, helpers.EventLogUpdate, helpers.EventLogItem{ + UserID: helpers.UserFromContext(r).ID, + ProjectID: old.ProjectID, + ObjectType: db.EventAlert, + ObjectID: old.ID, + Description: fmt.Sprintf("Alert %s updated", alert.Name), + }) + + w.WriteHeader(http.StatusNoContent) +} + +func RemoveAlert(w http.ResponseWriter, r *http.Request) { + alert := helpers.GetFromContext(r, "alert").(db.Alert) + + err := helpers.Store(r).DeleteAlert(alert.ProjectID, alert.ID) + if err != nil { + helpers.WriteError(w, err) + return + } + + helpers.EventLog(r, helpers.EventLogDelete, helpers.EventLogItem{ + UserID: helpers.UserFromContext(r).ID, + ProjectID: alert.ProjectID, + ObjectType: db.EventAlert, + ObjectID: alert.ID, + Description: fmt.Sprintf("Alert %s deleted", alert.Name), + }) + + w.WriteHeader(http.StatusNoContent) +} + +func GetAlertRefs(w http.ResponseWriter, r *http.Request) { + alert := helpers.GetFromContext(r, "alert").(db.Alert) + refs, err := helpers.Store(r).GetAlertRefs(alert.ProjectID, alert.ID) + if err != nil { + helpers.WriteError(w, err) + return + } + helpers.WriteJSON(w, http.StatusOK, refs) +} + +func TestAlert(w http.ResponseWriter, r *http.Request) { + alert := helpers.GetFromContext(r, "alert").(db.Alert) + project := helpers.GetFromContext(r, "project").(db.Project) + + if err := tasks.SendAlertTest(project, alert, helpers.Store(r)); err != nil { + helpers.WriteError(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/api/projects/project.go b/api/projects/project.go index d21c24a311..7b7ad3d529 100644 --- a/api/projects/project.go +++ b/api/projects/project.go @@ -108,14 +108,27 @@ type ProjectController struct { // SendTestNotification triggers sending a test notification to enabled messengers for this project. func (c *ProjectController) SendTestNotification(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) + store := helpers.Store(r) - // Respect project.Alert flag: if disabled, still return 204 without sending - if !project.Alert { + alerts, err := store.GetAlerts(project.ID, db.RetrieveQueryParams{}) + if err != nil { + helpers.WriteError(w, err) + return + } + + hasEnabled := false + for _, alert := range alerts { + if alert.Enabled { + hasEnabled = true + break + } + } + if !hasEnabled { w.WriteHeader(http.StatusConflict) return } - err := tasks.SendProjectTestAlerts(project, helpers.Store(r)) + err = tasks.SendProjectTestAlerts(project, store) if err != nil { helpers.WriteError(w, err) return diff --git a/api/router.go b/api/router.go index 9b6d19719a..9e6f22037e 100644 --- a/api/router.go +++ b/api/router.go @@ -348,6 +348,9 @@ func Route( projectUserAPI.Path("/integrations").HandlerFunc(projects.GetIntegrations).Methods("GET", "HEAD") projectUserAPI.Path("/integrations").HandlerFunc(projects.AddIntegration).Methods("POST") + projectUserAPI.Path("/alerts").HandlerFunc(projects.GetAlerts).Methods("GET", "HEAD") + projectUserAPI.Path("/alerts").HandlerFunc(projects.AddAlert).Methods("POST") + projectUserAPI.Path("/alerts/defaults").HandlerFunc(projects.GetAlertDefaults).Methods("GET", "HEAD") projectUserAPI.Path("/backup").HandlerFunc(backupController.GetBackup).Methods("GET", "HEAD") projectUserAPI.Path("/notifications/test").HandlerFunc(projectController.SendTestNotification).Methods("POST") @@ -528,6 +531,14 @@ func Route( projectViewManagement.HandleFunc("/{view_id}", projects.RemoveView).Methods("DELETE") projectViewManagement.HandleFunc("/{view_id}/templates", projects.GetViewTemplates).Methods("GET", "HEAD") + projectAlertManagement := projectUserAPI.PathPrefix("/alerts").Subrouter() + projectAlertManagement.Use(projects.AlertMiddleware) + projectAlertManagement.HandleFunc("/{alert_id}", projects.GetAlerts).Methods("GET", "HEAD") + projectAlertManagement.HandleFunc("/{alert_id}", projects.UpdateAlert).Methods("PUT") + projectAlertManagement.HandleFunc("/{alert_id}", projects.RemoveAlert).Methods("DELETE") + projectAlertManagement.HandleFunc("/{alert_id}/refs", projects.GetAlertRefs).Methods("GET", "HEAD") + projectAlertManagement.HandleFunc("/{alert_id}/test", projects.TestAlert).Methods("POST") + projectIntegrationsAliasAPI := projectUserAPI.PathPrefix("/integrations").Subrouter() projectIntegrationsAliasAPI.Use(projects.ProjectMiddleware) projectIntegrationsAliasAPI.HandleFunc("/aliases", projects.GetIntegrationAlias).Methods("GET", "HEAD") diff --git a/db/Alert.go b/db/Alert.go new file mode 100644 index 0000000000..4861b01eae --- /dev/null +++ b/db/Alert.go @@ -0,0 +1,230 @@ +package db + +import ( + "encoding/json" + "net" + "net/url" + "strconv" + "strings" + + "github.com/semaphoreui/semaphore/pkg/common_errors" +) + +type AlertType string + +const ( + AlertTypeEmail AlertType = "email" + AlertTypeTelegram AlertType = "telegram" + AlertTypeSlack AlertType = "slack" + AlertTypeTeams AlertType = "teams" + AlertTypeRocketChat AlertType = "rocketchat" + AlertTypeDingTalk AlertType = "dingtalk" + AlertTypeGotify AlertType = "gotify" +) + +const ( + AlertModeInherit = "inherit" + AlertModeDefault = "default" + AlertModeIDs = "ids" +) + +// Alert is a named project notification: channel type + destination + message body. +type Alert struct { + ID int `db:"id" json:"id" backup:"-"` + ProjectID int `db:"project_id" json:"project_id" backup:"-"` + Name string `db:"name" json:"name"` + Type AlertType `db:"type" json:"type"` + Enabled bool `db:"enabled" json:"enabled"` + IsDefault bool `db:"is_default" json:"is_default"` + ChatID *string `db:"chat_id" json:"chat_id,omitempty"` + ThreadID *string `db:"thread_id" json:"thread_id,omitempty"` + URL *string `db:"url" json:"url,omitempty"` + Token *string `db:"token" json:"token,omitempty"` + Recipients *string `db:"recipients" json:"recipients,omitempty"` + KeyID *int `db:"key_id" json:"key_id,omitempty" backup:"-"` + Body *string `db:"body" json:"body,omitempty"` +} + +func (a *Alert) Validate() error { + if a.Name == "" { + return common_errors.NewValidationError("alert name can not be empty") + } + switch a.Type { + case AlertTypeEmail, AlertTypeTelegram, AlertTypeSlack, AlertTypeTeams, + AlertTypeRocketChat, AlertTypeDingTalk, AlertTypeGotify: + default: + return common_errors.NewValidationError("invalid alert type: " + string(a.Type)) + } + if err := ValidateAlertURL(stringValue(a.URL)); err != nil { + return err + } + switch a.Type { + case AlertTypeTelegram: + if stringValue(a.ChatID) == "" { + return common_errors.NewValidationError("telegram chat id can not be empty") + } + if err := ValidateTelegramThreadID(stringValue(a.ThreadID)); err != nil { + return err + } + case AlertTypeSlack, AlertTypeTeams, AlertTypeRocketChat, AlertTypeDingTalk: + if stringValue(a.URL) == "" { + return common_errors.NewValidationError("alert URL can not be empty") + } + case AlertTypeGotify: + if stringValue(a.URL) != "" && stringValue(a.Token) == "" { + return common_errors.NewValidationError("gotify token is missing for the alert URL") + } + } + return nil +} + +// Normalize turns blank optional strings into nil so the DB stores NULL +// instead of empty strings from the UI, and drops destination fields that +// do not apply to the selected type. +func (a *Alert) Normalize() { + a.Name = strings.TrimSpace(a.Name) + a.ChatID = emptyToNil(a.ChatID) + a.ThreadID = emptyToNil(a.ThreadID) + a.URL = emptyToNil(a.URL) + a.Token = emptyToNil(a.Token) + a.Recipients = emptyToNil(a.Recipients) + a.Body = emptyToNil(a.Body) + + switch a.Type { + case AlertTypeTelegram: + a.URL = nil + a.Token = nil + a.Recipients = nil + case AlertTypeEmail: + a.ChatID = nil + a.ThreadID = nil + a.URL = nil + a.Token = nil + case AlertTypeSlack, AlertTypeTeams, AlertTypeRocketChat, AlertTypeDingTalk: + a.ChatID = nil + a.ThreadID = nil + a.Token = nil + a.Recipients = nil + case AlertTypeGotify: + a.ChatID = nil + a.ThreadID = nil + a.Recipients = nil + } +} + +// MarshalJSON omits token so list/get/create responses never echo the secret. +// Backup uses the backup/db tags via reflection, not this method. +func (a Alert) MarshalJSON() ([]byte, error) { + type alertJSON Alert + out := alertJSON(a) + out.Token = nil + return json.Marshal(out) +} + +// ValidateAlertURL allows empty (Gotify instance pair) and http(s) destinations. +// Loopback, unspecified, and link-local hosts are rejected to limit SSRF +// from project webhooks. Private RFC1918 addresses stay allowed so a +// self-hosted Gotify or Rocket.Chat still works. +func ValidateAlertURL(raw string) error { + return validateAlertURL(raw, false) +} + +func ValidateGotifyURL(raw string) error { + return ValidateAlertURL(raw) +} + +func ValidateTelegramThreadID(raw string) error { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + id, err := strconv.ParseInt(raw, 10, 64) + if err != nil || id < 1 { + return common_errors.NewValidationError("telegram thread id must be a positive integer") + } + return nil +} + +func validateAlertURL(raw string, requireHTTPS bool) error { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return common_errors.NewValidationError("invalid alert URL") + } + scheme := strings.ToLower(u.Scheme) + if requireHTTPS { + if scheme != "https" { + return common_errors.NewValidationError("gotify URL must be https") + } + } else if scheme != "http" && scheme != "https" { + return common_errors.NewValidationError("alert URL must be http or https") + } + host := strings.ToLower(u.Hostname()) + if host == "localhost" || host == "metadata" || host == "metadata.google.internal" || + strings.HasSuffix(host, ".metadata.google.internal") { + return common_errors.NewValidationError("alert URL host is not allowed") + } + if ip := net.ParseIP(host); ip != nil && AlertIPForbidden(ip) { + return common_errors.NewValidationError("alert URL host is not allowed") + } + return nil +} + +// AlertIPForbidden reports whether a resolved address must not be dialed for +// user-controlled alert webhooks. +func AlertIPForbidden(ip net.IP) bool { + if ip == nil { + return true + } + return ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() +} + +// AllowedAlertIPs returns the subset of addresses that alertHTTPClient may dial. +func AllowedAlertIPs(ips []net.IP) []net.IP { + var out []net.IP + for _, ip := range ips { + if !AlertIPForbidden(ip) { + out = append(out, ip) + } + } + return out +} + +func stringValue(s *string) string { + if s == nil { + return "" + } + return strings.TrimSpace(*s) +} + +func emptyToNil(s *string) *string { + if s == nil || strings.TrimSpace(*s) == "" { + return nil + } + trimmed := strings.TrimSpace(*s) + return &trimmed +} + +func (a Alert) GetID() int { + return a.ID +} + +func (a Alert) GetName() string { + return a.Name +} + +// ResolveAlertOn prefers the new alert_on_* field. If it is omitted, the +// legacy suppress_* flag is inverted so older API clients keep working. +func ResolveAlertOn(on *bool, suppress bool) bool { + if on != nil { + return *on + } + return !suppress +} + +func BoolPtr(v bool) *bool { + return &v +} diff --git a/db/AlertSnapshot.go b/db/AlertSnapshot.go new file mode 100644 index 0000000000..b9fc096a94 --- /dev/null +++ b/db/AlertSnapshot.go @@ -0,0 +1,49 @@ +package db + +import ( + "database/sql/driver" + "encoding/json" + "errors" +) + +// AlertSnapshot is the resolved alerting decision stored on a task at creation +// time so every HA node sends the same alerts even if bindings change later. +type AlertSnapshot struct { + AlertIDs []int `json:"alert_ids"` + OnSuccess bool `json:"on_success"` + OnError bool `json:"on_error"` +} + +func (s *AlertSnapshot) Scan(value any) error { + if value == nil { + *s = AlertSnapshot{} + return nil + } + switch v := value.(type) { + case []byte: + if len(v) == 0 { + *s = AlertSnapshot{} + return nil + } + return json.Unmarshal(v, s) + case string: + if v == "" { + *s = AlertSnapshot{} + return nil + } + return json.Unmarshal([]byte(v), s) + default: + return errors.New("unsupported type for AlertSnapshot") + } +} + +func (s *AlertSnapshot) Value() (driver.Value, error) { + if s == nil { + return nil, nil + } + b, err := json.Marshal(s) + if err != nil { + return nil, err + } + return string(b), nil +} diff --git a/db/Alert_test.go b/db/Alert_test.go new file mode 100644 index 0000000000..0303a35d76 --- /dev/null +++ b/db/Alert_test.go @@ -0,0 +1,164 @@ +package db + +import ( + "encoding/json" + "net" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveAlertOn(t *testing.T) { + assert.True(t, ResolveAlertOn(nil, false)) + assert.False(t, ResolveAlertOn(nil, true)) + assert.True(t, ResolveAlertOn(BoolPtr(true), true)) + assert.False(t, ResolveAlertOn(BoolPtr(false), false)) +} + +func TestAlertValidate(t *testing.T) { + alert := Alert{Name: "", Type: AlertTypeTelegram} + assert.Error(t, alert.Validate()) + + alert.Name = "ops" + alert.Type = "discord" + assert.Error(t, alert.Validate()) + + alert.Type = AlertTypeTelegram + assert.Error(t, alert.Validate()) + chatID := "12345" + alert.ChatID = &chatID + assert.NoError(t, alert.Validate()) + + bad := "javascript:alert(1)" + alert.URL = &bad + assert.Error(t, alert.Validate()) + + meta := "http://169.254.169.254/latest/meta-data" + alert.URL = &meta + assert.Error(t, alert.Validate()) + + okURL := "https://hooks.slack.com/services/T/B/X" + alert.URL = &okURL + assert.NoError(t, alert.Validate()) + + httpGotify := "http://gotify.example.com" + gotify := Alert{Name: "gotify", Type: AlertTypeGotify, URL: &httpGotify} + assert.Error(t, gotify.Validate()) + + httpsGotify := "https://gotify.example.com" + gotify.URL = &httpsGotify + assert.Error(t, gotify.Validate()) + + token := "secret" + gotify.Token = &token + assert.NoError(t, gotify.Validate()) + + gotify.URL = nil + gotify.Token = nil + assert.NoError(t, gotify.Validate()) + + gotify.URL = &httpGotify + gotify.Token = &token + assert.NoError(t, gotify.Validate()) + + badThread := "general" + tg := Alert{Name: "tg", Type: AlertTypeTelegram, ChatID: &chatID, ThreadID: &badThread} + assert.Error(t, tg.Validate()) + okThread := "12" + tg.ThreadID = &okThread + assert.NoError(t, tg.Validate()) +} + +func TestValidateTelegramThreadID(t *testing.T) { + tests := []struct { + name string + threadID string + wantError bool + }{ + {name: "empty is optional", threadID: ""}, + {name: "whitespace is optional", threadID: " "}, + {name: "positive integer", threadID: "42"}, + {name: "zero", threadID: "0", wantError: true}, + {name: "negative integer", threadID: "-1", wantError: true}, + {name: "decimal", threadID: "1.5", wantError: true}, + {name: "text", threadID: "general", wantError: true}, + {name: "integer overflow", threadID: "9223372036854775808", wantError: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateTelegramThreadID(tt.threadID) + if tt.wantError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestValidateAlertURL(t *testing.T) { + assert.NoError(t, ValidateAlertURL("")) + assert.NoError(t, ValidateAlertURL("https://example.com/hook")) + assert.NoError(t, ValidateAlertURL("http://10.0.0.8/hooks")) + assert.Error(t, ValidateAlertURL("ftp://example.com/hook")) + assert.Error(t, ValidateAlertURL("http://metadata.google.internal/")) + assert.Error(t, ValidateAlertURL("http://localhost/hook")) + assert.Error(t, ValidateAlertURL("http://127.0.0.1/hook")) + assert.Error(t, ValidateAlertURL("http://[::1]/hook")) + assert.Error(t, ValidateAlertURL("http://169.254.169.254/latest/meta-data")) + assert.NoError(t, ValidateGotifyURL("http://gotify.example.com")) + assert.NoError(t, ValidateGotifyURL("https://gotify.example.com")) + assert.NoError(t, ValidateGotifyURL("")) +} + +func TestAllowedAlertIPs(t *testing.T) { + assert.True(t, AlertIPForbidden(net.ParseIP("127.0.0.1"))) + assert.True(t, AlertIPForbidden(net.ParseIP("::1"))) + assert.True(t, AlertIPForbidden(net.ParseIP("169.254.169.254"))) + assert.True(t, AlertIPForbidden(net.ParseIP("0.0.0.0"))) + assert.False(t, AlertIPForbidden(net.ParseIP("10.0.0.8"))) + assert.False(t, AlertIPForbidden(net.ParseIP("1.1.1.1"))) + + allowed := AllowedAlertIPs([]net.IP{ + net.ParseIP("127.0.0.1"), + net.ParseIP("10.0.0.8"), + net.ParseIP("169.254.169.254"), + }) + require.Len(t, allowed, 1) + assert.Equal(t, "10.0.0.8", allowed[0].String()) +} + +func TestAlert_TokenOmittedFromJSON(t *testing.T) { + token := "secret-token" + raw, err := json.Marshal(Alert{ + ID: 3, + Name: "Gotify", + Type: AlertTypeGotify, + Enabled: true, + Token: &token, + }) + require.NoError(t, err) + assert.NotContains(t, string(raw), "secret-token") + assert.NotContains(t, string(raw), `"token"`) +} + +func TestAlertNormalize(t *testing.T) { + blank := " " + url := "https://hooks.slack.com/x" + alert := Alert{ + Name: " Ops ", + Type: AlertTypeSlack, + ChatID: &blank, + Body: &blank, + URL: &url, + Token: &blank, + } + alert.Normalize() + assert.Equal(t, "Ops", alert.Name) + assert.Nil(t, alert.ChatID) + assert.Nil(t, alert.Token) + assert.Nil(t, alert.Recipients) + assert.Equal(t, "https://hooks.slack.com/x", *alert.URL) +} diff --git a/db/Event.go b/db/Event.go index a9d7a198a0..1e349d3ba9 100644 --- a/db/Event.go +++ b/db/Event.go @@ -64,6 +64,7 @@ const ( EventWorkflow EventObjectType = "workflow" EventUser EventObjectType = "user" EventView EventObjectType = "view" + EventAlert EventObjectType = "alert" EventIntegration EventObjectType = "integration" EventIntegrationExtractValue EventObjectType = "integrationextractvalue" EventIntegrationMatcher EventObjectType = "integrationmatcher" diff --git a/db/ExportEntityType.go b/db/ExportEntityType.go index 3b61af7424..6b6e1a5c74 100644 --- a/db/ExportEntityType.go +++ b/db/ExportEntityType.go @@ -58,6 +58,10 @@ func (e View) GetDbKey() string { return NewKeyFromInt(e.ID) } +func (e Alert) GetDbKey() string { + return NewKeyFromInt(e.ID) +} + func (e IntegrationAlias) GetDbKey() string { return NewKeyFromInt(e.ID) } diff --git a/db/Migration.go b/db/Migration.go index fdc40c0450..a882fdc4b7 100644 --- a/db/Migration.go +++ b/db/Migration.go @@ -139,6 +139,7 @@ func GetMigrations(dialect string) []Migration { {Version: "2.20.3"}, {Version: "2.20.4"}, {Version: "2.20.5"}, + {Version: "2.20.6"}, } return append(initScripts, commonScripts...) diff --git a/db/Schedule.go b/db/Schedule.go index 4da845337d..1f5b8c830f 100644 --- a/db/Schedule.go +++ b/db/Schedule.go @@ -1,6 +1,10 @@ package db -import "time" +import ( + "time" + + "github.com/semaphoreui/semaphore/pkg/common_errors" +) const ( ScheduleTypeCron = "" @@ -23,9 +27,25 @@ type Schedule struct { TaskParamsID *int `db:"task_params_id" json:"-" backup:"-"` TaskParams *TaskParams `db:"-" json:"task_params,omitempty" backup:"task_params"` + + // AlertMode is inherit (use the template list) or ids (use AlertIDs, which may be empty). + AlertMode string `db:"alert_mode" json:"alert_mode"` + AlertIDs []int `db:"-" json:"alert_ids" backup:"-"` + AlertOnSuccess *bool `db:"alert_on_success" json:"alert_on_success"` + AlertOnError *bool `db:"alert_on_error" json:"alert_on_error"` } type ScheduleWithTpl struct { Schedule TemplateName string `db:"tpl_name" json:"tpl_name"` } + +func (s *Schedule) NormalizeAlerts() error { + if s.AlertMode == "" { + s.AlertMode = AlertModeInherit + } + if s.AlertMode != AlertModeInherit && s.AlertMode != AlertModeIDs { + return common_errors.NewValidationError("alert_mode must be inherit or ids") + } + return nil +} diff --git a/db/Store.go b/db/Store.go index 1012b7cd5b..4a5534009e 100644 --- a/db/Store.go +++ b/db/Store.go @@ -484,6 +484,27 @@ type ScheduleManager interface { DeleteSchedule(projectID int, scheduleID int) error } +// AlertManager handles project alert destinations. +type AlertManager interface { + GetAlert(projectID int, alertID int) (Alert, error) + GetAlerts(projectID int, params RetrieveQueryParams) ([]Alert, error) + GetDefaultAlertIDs(projectID int) ([]int, error) + CreateAlert(alert Alert) (Alert, error) + UpdateAlert(alert Alert) error + DeleteAlert(projectID int, alertID int) error + GetAlertRefs(projectID int, alertID int) (ObjectReferrers, error) + + GetTemplateAlerts(projectID int, templateID int) ([]int, error) + UpdateTemplateAlerts(projectID int, templateID int, alertIDs []int) error + GetScheduleAlerts(projectID int, scheduleID int) ([]int, error) + UpdateScheduleAlerts(projectID int, scheduleID int, alertIDs []int) error + + // ClaimAlertSend records a send for (task, alert, event). It returns + // claimed=false when that triple was already recorded, so HA nodes do + // not send the same notification twice. + ClaimAlertSend(taskID int, alertID int, event string) (claimed bool, err error) +} + // ViewManager handles view-related operations type ViewManager interface { GetView(projectID int, viewID int) (View, error) @@ -590,6 +611,7 @@ type Store interface { TaskManager ScheduleManager ViewManager + AlertManager RunnerManager EventManager SecretStorageRepository @@ -792,6 +814,14 @@ var ViewProps = ObjectProps{ DefaultSortingColumn: "position", } +var AlertProps = ObjectProps{ + TableName: "project__alert", + Type: reflect.TypeFor[Alert](), + PrimaryColumnName: "id", + DefaultSortingColumn: "name", + SortableColumns: []string{"name", "type"}, +} + var GlobalRunnerProps = ObjectProps{ TableName: "runner", Type: reflect.TypeFor[Runner](), diff --git a/db/Task.go b/db/Task.go index b147051e8f..7be6d56edf 100644 --- a/db/Task.go +++ b/db/Task.go @@ -92,6 +92,8 @@ type Task struct { // Limit is deprecated, use Params.Limit instead Limit string `db:"-" json:"limit"` + + AlertSnapshot *AlertSnapshot `db:"alert_snapshot" json:"alert_snapshot,omitempty"` } func (task *Task) ExtractParams(target any) (err error) { diff --git a/db/Template.go b/db/Template.go index 23fcc4086c..b585e0589c 100644 --- a/db/Template.go +++ b/db/Template.go @@ -340,6 +340,16 @@ type Template struct { SuppressSuccessAlerts bool `db:"suppress_success_alerts" json:"suppress_success_alerts,omitempty"` SuppressErrorAlerts bool `db:"suppress_error_alerts" json:"suppress_error_alerts,omitempty"` + // AlertMode is default (project default alerts) or ids (AlertIDs, which may be empty). + AlertMode string `db:"alert_mode" json:"alert_mode"` + // AlertIDs are used when AlertMode is ids. Empty means this job sends nothing. + // nil on update means the client omitted the field and existing bindings stay. + AlertIDs []int `db:"-" json:"alert_ids" backup:"-"` + // AlertOnSuccess / AlertOnError are pointers so older clients that only + // send suppress_* still work (see ResolveAlertOn). + AlertOnSuccess *bool `db:"alert_on_success" json:"alert_on_success"` + AlertOnError *bool `db:"alert_on_error" json:"alert_on_error"` + App TemplateApp `db:"app" json:"app,omitempty"` Tasks int `db:"tasks" json:"tasks" backup:"-"` @@ -406,6 +416,20 @@ func (tpl *Template) CanOverrideInventory() (ok bool, err error) { return } +func (tpl *Template) NormalizeAlerts() error { + if tpl.AlertMode == "" { + if len(tpl.AlertIDs) > 0 { + tpl.AlertMode = AlertModeIDs + } else { + tpl.AlertMode = AlertModeDefault + } + } + if tpl.AlertMode != AlertModeDefault && tpl.AlertMode != AlertModeIDs { + return common_errors.NewValidationError("alert_mode must be default or ids") + } + return nil +} + func (tpl *Template) Validate() error { if tpl.RunnerTag != nil && *tpl.RunnerTag == "" { return common_errors.NewValidationError("template runner tag can not be empty") @@ -515,6 +539,16 @@ func FillTemplate(d Store, template *Template) (err error) { } template.EnvironmentIDs = envIDs + var alertIDs []int + alertIDs, err = d.GetTemplateAlerts(template.ProjectID, template.ID) + if err != nil { + return + } + template.AlertIDs = alertIDs + if template.AlertMode == "" { + template.AlertMode = AlertModeDefault + } + var tasks []TaskWithTpl tasks, err = d.GetTemplateTasks(template.ProjectID, template.ID, RetrieveQueryParams{Count: 1}) if err != nil { diff --git a/db/sql/SqlDb.go b/db/sql/SqlDb.go index b6edbdbb07..321a151e10 100644 --- a/db/sql/SqlDb.go +++ b/db/sql/SqlDb.go @@ -239,6 +239,34 @@ func (d *SqlDbConnection) Insert(primaryKeyColumnName string, query string, args return int(insertId), nil } +func (d *SqlDbConnection) InsertTx(tx *gorp.Transaction, primaryKeyColumnName string, query string, args ...any) (int, error) { + var insertId int64 + + formattedArgs := formatArgs(args) + + switch d.sql.Dialect.(type) { + case gorp.PostgresDialect: + if primaryKeyColumnName != "" { + id, err := tx.SelectInt(d.PrepareQuery(query+" returning "+primaryKeyColumnName), formattedArgs...) + return int(id), err + } + _, err := tx.Exec(d.PrepareQuery(query), formattedArgs...) + return 0, err + default: + res, err := tx.Exec(d.PrepareQuery(query), formattedArgs...) + if err != nil { + return 0, err + } + + insertId, err = res.LastInsertId() + if err != nil { + return 0, err + } + } + + return int(insertId), nil +} + func (d *SqlDbConnection) Exec(query string, args ...any) (sql.Result, error) { q := d.PrepareQuery(query) return d.sql.Exec(q, args...) @@ -443,6 +471,10 @@ func (d *SqlDb) insert(primaryKeyColumnName string, query string, args ...any) ( return d.connection.Insert(primaryKeyColumnName, query, args...) } +func (d *SqlDb) insertTx(tx *gorp.Transaction, primaryKeyColumnName string, query string, args ...any) (int, error) { + return d.connection.InsertTx(tx, primaryKeyColumnName, query, args...) +} + func (d *SqlDb) exec(query string, args ...any) (sql.Result, error) { return d.connection.Exec(query, args...) } diff --git a/db/sql/alert.go b/db/sql/alert.go new file mode 100644 index 0000000000..f4ca109aa5 --- /dev/null +++ b/db/sql/alert.go @@ -0,0 +1,423 @@ +package sql + +import ( + "strings" + + "github.com/go-gorp/gorp/v3" + "github.com/semaphoreui/semaphore/db" + "github.com/semaphoreui/semaphore/pkg/common_errors" + "github.com/semaphoreui/semaphore/pkg/task_logger" + "github.com/semaphoreui/semaphore/pkg/tz" +) + +func (d *SqlDb) GetAlert(projectID int, alertID int) (alert db.Alert, err error) { + err = d.getObject(projectID, db.AlertProps, alertID, &alert) + return +} + +func (d *SqlDb) GetAlerts(projectID int, params db.RetrieveQueryParams) (alerts []db.Alert, err error) { + alerts = make([]db.Alert, 0) + err = d.getObjects(projectID, db.AlertProps, params, nil, &alerts) + if alerts == nil { + alerts = make([]db.Alert, 0) + } + return +} + +func (d *SqlDb) GetDefaultAlertIDs(projectID int) (alertIDs []int, err error) { + alertIDs = make([]int, 0) + + var rows []struct { + ID int `db:"id"` + } + _, err = d.selectAll( + &rows, + "select id from project__alert where project_id=? and is_default=? order by id", + projectID, + true, + ) + if err != nil { + return + } + for _, r := range rows { + alertIDs = append(alertIDs, r.ID) + } + return +} + +func (d *SqlDb) CreateAlert(alert db.Alert) (newAlert db.Alert, err error) { + alert.Normalize() + if err = alert.Validate(); err != nil { + return + } + + if err = d.validateAlertNameIsFree(alert.ProjectID, 0, alert.Name); err != nil { + return + } + + insertID, err := d.insert( + "id", + "insert into project__alert "+ + "(project_id, name, `type`, enabled, is_default, chat_id, thread_id, url, token, recipients, key_id, body) "+ + "values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + alert.ProjectID, + alert.Name, + alert.Type, + alert.Enabled, + alert.IsDefault, + alert.ChatID, + alert.ThreadID, + alert.URL, + alert.Token, + alert.Recipients, + alert.KeyID, + alert.Body, + ) + if err != nil { + return + } + + newAlert = alert + newAlert.ID = insertID + return +} + +func (d *SqlDb) UpdateAlert(alert db.Alert) error { + alert.Normalize() + + old, err := d.GetAlert(alert.ProjectID, alert.ID) + if err != nil { + return err + } + if alert.Type == db.AlertTypeGotify && alert.Token == nil { + alert.Token = old.Token + } + + if err := alert.Validate(); err != nil { + return err + } + + if err := d.validateAlertNameIsFree(alert.ProjectID, alert.ID, alert.Name); err != nil { + return err + } + + _, err = d.exec( + "update project__alert set name=?, `type`=?, enabled=?, is_default=?, chat_id=?, thread_id=?, "+ + "url=?, token=?, recipients=?, key_id=?, body=? where project_id=? and id=?", + alert.Name, + alert.Type, + alert.Enabled, + alert.IsDefault, + alert.ChatID, + alert.ThreadID, + alert.URL, + alert.Token, + alert.Recipients, + alert.KeyID, + alert.Body, + alert.ProjectID, + alert.ID, + ) + return err +} + +func (d *SqlDb) DeleteAlert(projectID int, alertID int) error { + refs, err := d.GetAlertRefs(projectID, alertID) + if err != nil { + return err + } + if len(refs.Templates) > 0 || len(refs.Schedules) > 0 { + return common_errors.NewValidationError("alert is used by templates or schedules") + } + used, err := d.alertReferencedByActiveTasks(projectID, alertID) + if err != nil { + return err + } + if used { + return common_errors.NewValidationError("alert is used by running tasks") + } + return d.deleteObject(projectID, db.AlertProps, alertID) +} + +func (d *SqlDb) GetAlertRefs(projectID int, alertID int) (refs db.ObjectReferrers, err error) { + refs.Templates = make([]db.ObjectReferrer, 0) + refs.Schedules = make([]db.ObjectReferrer, 0) + refs.Inventories = make([]db.ObjectReferrer, 0) + refs.Repositories = make([]db.ObjectReferrer, 0) + refs.Integrations = make([]db.ObjectReferrer, 0) + refs.AccessKeys = make([]db.ObjectReferrer, 0) + + _, err = d.selectAll( + &refs.Templates, + "select t.id, t.name from project__template t "+ + "join project__template_alert ta on ta.template_id = t.id "+ + "where ta.project_id=? and ta.alert_id=?", + projectID, + alertID, + ) + if err != nil { + return + } + + _, err = d.selectAll( + &refs.Schedules, + "select s.id, s.name from project__schedule s "+ + "join project__schedule_alert sa on sa.schedule_id = s.id "+ + "where sa.project_id=? and sa.alert_id=?", + projectID, + alertID, + ) + if err != nil { + return + } + + alert, alertErr := d.GetAlert(projectID, alertID) + if alertErr != nil { + return refs, alertErr + } + if !alert.IsDefault { + return + } + + var defaults []db.ObjectReferrer + _, err = d.selectAll( + &defaults, + "select id, name from project__template where project_id=? and alert_mode=?", + projectID, + db.AlertModeDefault, + ) + if err != nil { + return + } + + seen := make(map[int]bool) + for _, ref := range refs.Templates { + seen[ref.ID] = true + } + for _, ref := range defaults { + if seen[ref.ID] { + continue + } + refs.Templates = append(refs.Templates, ref) + } + return +} + +func (d *SqlDb) alertReferencedByActiveTasks(projectID int, alertID int) (bool, error) { + unfinished := task_logger.UnfinishedTaskStatuses() + placeholders := make([]string, len(unfinished)) + args := make([]any, 0, 1+len(unfinished)) + args = append(args, projectID) + for i, status := range unfinished { + placeholders[i] = "?" + args = append(args, status) + } + + var tasks []db.Task + _, err := d.selectAll( + &tasks, + "select id, alert_snapshot from task where project_id=? and status in ("+strings.Join(placeholders, ",")+")", + args..., + ) + if err != nil { + return false, err + } + for _, task := range tasks { + if task.AlertSnapshot == nil { + continue + } + for _, id := range task.AlertSnapshot.AlertIDs { + if id == alertID { + return true, nil + } + } + } + return false, nil +} + +func (d *SqlDb) GetTemplateAlerts(projectID int, templateID int) (alertIDs []int, err error) { + return d.getLinkedAlertIDs( + "select alert_id from project__template_alert where project_id=? and template_id=? order by alert_id", + projectID, + templateID, + ) +} + +func (d *SqlDb) UpdateTemplateAlerts(projectID int, templateID int, alertIDs []int) error { + return d.replaceLinkedAlerts( + "delete from project__template_alert where project_id=? and template_id=?", + "insert into project__template_alert (project_id, template_id, alert_id) values (?, ?, ?)", + projectID, + templateID, + alertIDs, + ) +} + +func (d *SqlDb) GetScheduleAlerts(projectID int, scheduleID int) (alertIDs []int, err error) { + return d.getLinkedAlertIDs( + "select alert_id from project__schedule_alert where project_id=? and schedule_id=? order by alert_id", + projectID, + scheduleID, + ) +} + +func (d *SqlDb) UpdateScheduleAlerts(projectID int, scheduleID int, alertIDs []int) error { + return d.replaceLinkedAlerts( + "delete from project__schedule_alert where project_id=? and schedule_id=?", + "insert into project__schedule_alert (project_id, schedule_id, alert_id) values (?, ?, ?)", + projectID, + scheduleID, + alertIDs, + ) +} + +func (d *SqlDb) getLinkedAlertIDs(query string, projectID int, ownerID int) (alertIDs []int, err error) { + alertIDs = make([]int, 0) + + var rows []struct { + AlertID int `db:"alert_id"` + } + + _, err = d.selectAll(&rows, query, projectID, ownerID) + if err != nil { + return + } + + for _, r := range rows { + alertIDs = append(alertIDs, r.AlertID) + } + return +} + +func (d *SqlDb) replaceLinkedAlerts(deleteQuery, insertQuery string, projectID int, ownerID int, alertIDs []int) (err error) { + if err = d.validateAlertIDs(projectID, alertIDs); err != nil { + return + } + + tx, err := d.Sql().Begin() + if err != nil { + return + } + + if err = d.replaceLinkedAlertsInTx(tx, deleteQuery, insertQuery, projectID, ownerID, alertIDs); err != nil { + _ = tx.Rollback() + return + } + + return tx.Commit() +} + +func (d *SqlDb) updateTemplateAlertsInTx(tx *gorp.Transaction, projectID int, templateID int, alertIDs []int) error { + return d.replaceLinkedAlertsInTx( + tx, + "delete from project__template_alert where project_id=? and template_id=?", + "insert into project__template_alert (project_id, template_id, alert_id) values (?, ?, ?)", + projectID, + templateID, + alertIDs, + ) +} + +func (d *SqlDb) updateScheduleAlertsInTx(tx *gorp.Transaction, projectID int, scheduleID int, alertIDs []int) error { + return d.replaceLinkedAlertsInTx( + tx, + "delete from project__schedule_alert where project_id=? and schedule_id=?", + "insert into project__schedule_alert (project_id, schedule_id, alert_id) values (?, ?, ?)", + projectID, + scheduleID, + alertIDs, + ) +} + +func (d *SqlDb) replaceLinkedAlertsInTx( + tx *gorp.Transaction, + deleteQuery string, + insertQuery string, + projectID int, + ownerID int, + alertIDs []int, +) error { + _, err := tx.Exec(d.PrepareQuery(deleteQuery), projectID, ownerID) + if err != nil { + return err + } + + seen := make(map[int]bool) + for _, alertID := range alertIDs { + if seen[alertID] { + continue + } + seen[alertID] = true + + _, err = tx.Exec(d.PrepareQuery(insertQuery), projectID, ownerID, alertID) + if err != nil { + return err + } + } + return nil +} + +func (d *SqlDb) ClaimAlertSend(taskID int, alertID int, event string) (bool, error) { + _, err := d.exec( + "insert into task__alert_send (task_id, alert_id, event, created) values (?, ?, ?, ?)", + taskID, + alertID, + event, + tz.Now(), + ) + if err == nil { + return true, nil + } + if isUniqueViolation(err) { + return false, nil + } + return false, err +} + +func isUniqueViolation(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "unique") || + strings.Contains(msg, "duplicate") +} + +func (d *SqlDb) validateAlertIDs(projectID int, alertIDs []int) error { + for _, alertID := range alertIDs { + if _, err := d.GetAlert(projectID, alertID); err != nil { + return common_errors.NewValidationError("alert does not belong to this project") + } + } + return nil +} + +func (d *SqlDb) validateAlertNameIsFree(projectID int, alertID int, name string) error { + var count int + err := d.selectOne( + &count, + "select count(*) from project__alert where project_id=? and name=? and id<>?", + projectID, + name, + alertID, + ) + if err != nil { + return err + } + if count > 0 { + return common_errors.NewValidationError("alert with name " + name + " already exists") + } + return nil +} + +func (d *SqlDb) fillScheduleAlerts(projectID int, schedule *db.Schedule) error { + ids, err := d.GetScheduleAlerts(projectID, schedule.ID) + if err != nil { + return err + } + schedule.AlertIDs = ids + if schedule.AlertMode == "" { + schedule.AlertMode = db.AlertModeInherit + } + return nil +} diff --git a/db/sql/alert_test.go b/db/sql/alert_test.go new file mode 100644 index 0000000000..fae4a69eb9 --- /dev/null +++ b/db/sql/alert_test.go @@ -0,0 +1,217 @@ +package sql + +import ( + "testing" + + "github.com/semaphoreui/semaphore/db" + "github.com/semaphoreui/semaphore/pkg/task_logger" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetAlerts_EmptySlice(t *testing.T) { + store := InitConfigCreateTestStore() + project, err := store.CreateProject(db.Project{Name: "empty-alerts"}) + require.NoError(t, err) + + alerts, err := store.GetAlerts(project.ID, db.RetrieveQueryParams{}) + require.NoError(t, err) + require.NotNil(t, alerts) + assert.Empty(t, alerts) +} + +func TestUpdateAlert_PreservesGotifyToken(t *testing.T) { + store := InitConfigCreateTestStore() + project, err := store.CreateProject(db.Project{Name: "tok"}) + require.NoError(t, err) + + token := "secret-token" + created, err := store.CreateAlert(db.Alert{ + ProjectID: project.ID, + Name: "Gotify", + Type: db.AlertTypeGotify, + Enabled: true, + Token: &token, + }) + require.NoError(t, err) + + created.Token = nil + created.Name = "Gotify prod" + require.NoError(t, store.UpdateAlert(created)) + + loaded, err := store.GetAlert(project.ID, created.ID) + require.NoError(t, err) + require.NotNil(t, loaded.Token) + assert.Equal(t, "secret-token", *loaded.Token) + assert.Equal(t, "Gotify prod", loaded.Name) +} + +func TestCreateAlert_GotifyURLRequiresToken(t *testing.T) { + store := InitConfigCreateTestStore() + project, err := store.CreateProject(db.Project{Name: "gotify-url"}) + require.NoError(t, err) + + url := "https://gotify.example.com" + _, err = store.CreateAlert(db.Alert{ + ProjectID: project.ID, + Name: "Gotify", + Type: db.AlertTypeGotify, + Enabled: true, + URL: &url, + }) + assert.Error(t, err) +} + +func TestAlertCRUDAndRefs(t *testing.T) { + store := InitConfigCreateTestStore() + projectID, repositoryID := newTemplateTestProject(t, store) + + chatID := "12345" + created, err := store.CreateAlert(db.Alert{ + ProjectID: projectID, + Name: "Ops Telegram", + Type: db.AlertTypeTelegram, + Enabled: true, + ChatID: &chatID, + }) + require.NoError(t, err) + assert.NotZero(t, created.ID) + + _, err = store.CreateAlert(db.Alert{ + ProjectID: projectID, + Name: "Ops Telegram", + Type: db.AlertTypeSlack, + Enabled: true, + }) + assert.Error(t, err) + + alerts, err := store.GetAlerts(projectID, db.RetrieveQueryParams{}) + require.NoError(t, err) + require.Len(t, alerts, 1) + assert.Equal(t, "Ops Telegram", alerts[0].Name) + + tpl, err := store.CreateTemplate(db.Template{ + ProjectID: projectID, + RepositoryID: repositoryID, + Name: "deploy", + Playbook: "site.yml", + AlertIDs: []int{created.ID}, + AlertOnSuccess: db.BoolPtr(true), + AlertOnError: db.BoolPtr(false), + }) + require.NoError(t, err) + assert.Equal(t, []int{created.ID}, tpl.AlertIDs) + require.NotNil(t, tpl.AlertOnSuccess) + assert.True(t, *tpl.AlertOnSuccess) + require.NotNil(t, tpl.AlertOnError) + assert.False(t, *tpl.AlertOnError) + + refs, err := store.GetAlertRefs(projectID, created.ID) + require.NoError(t, err) + require.Len(t, refs.Templates, 1) + assert.Equal(t, tpl.ID, refs.Templates[0].ID) + assert.Empty(t, refs.Schedules) + + err = store.DeleteAlert(projectID, created.ID) + assert.Error(t, err) + + require.NoError(t, store.UpdateTemplateAlerts(projectID, tpl.ID, nil)) + require.NoError(t, store.DeleteAlert(projectID, created.ID)) + + _, err = store.GetAlert(projectID, created.ID) + assert.Error(t, err) +} + +func TestTaskAlertSnapshotRoundTrip(t *testing.T) { + store := InitConfigCreateTestStore() + projectID, repositoryID := newTemplateTestProject(t, store) + + tpl, err := store.CreateTemplate(db.Template{ + ProjectID: projectID, + RepositoryID: repositoryID, + Name: "job", + Playbook: "site.yml", + }) + require.NoError(t, err) + + snap := db.AlertSnapshot{AlertIDs: []int{4, 9}, OnSuccess: false, OnError: true} + created, err := store.CreateTask(db.Task{ + ProjectID: projectID, + TemplateID: tpl.ID, + Status: task_logger.TaskWaitingStatus, + AlertSnapshot: &snap, + }, 0) + require.NoError(t, err) + + loaded, err := store.GetTask(projectID, created.ID) + require.NoError(t, err) + require.NotNil(t, loaded.AlertSnapshot) + assert.Equal(t, []int{4, 9}, loaded.AlertSnapshot.AlertIDs) + assert.False(t, loaded.AlertSnapshot.OnSuccess) + assert.True(t, loaded.AlertSnapshot.OnError) +} + +func TestUpdateTemplate_OmitsAlertIDsLeavesBindings(t *testing.T) { + store := InitConfigCreateTestStore() + projectID, repositoryID := newTemplateTestProject(t, store) + + hook := "https://hooks.example.com/x" + alert, err := store.CreateAlert(db.Alert{ + ProjectID: projectID, + Name: "Ops", + Type: db.AlertTypeSlack, + Enabled: true, + URL: &hook, + }) + require.NoError(t, err) + + tpl, err := store.CreateTemplate(db.Template{ + ProjectID: projectID, + RepositoryID: repositoryID, + Name: "bound", + Playbook: "site.yml", + AlertIDs: []int{alert.ID}, + }) + require.NoError(t, err) + + tpl.AlertIDs = nil + tpl.Name = "bound-renamed" + require.NoError(t, store.UpdateTemplate(tpl)) + + loaded, err := store.GetTemplate(projectID, tpl.ID) + require.NoError(t, err) + assert.Equal(t, "bound-renamed", loaded.Name) + assert.Equal(t, []int{alert.ID}, loaded.AlertIDs) +} + +func TestClaimAlertSend_ExactlyOnce(t *testing.T) { + store := InitConfigCreateTestStore() + projectID, repositoryID := newTemplateTestProject(t, store) + + tpl, err := store.CreateTemplate(db.Template{ + ProjectID: projectID, + RepositoryID: repositoryID, + Name: "job", + Playbook: "site.yml", + }) + require.NoError(t, err) + + task, err := store.CreateTask(db.Task{ + ProjectID: projectID, + TemplateID: tpl.ID, + Status: task_logger.TaskWaitingStatus, + }, 0) + require.NoError(t, err) + + first, err := store.ClaimAlertSend(task.ID, 7, "error") + require.NoError(t, err) + assert.True(t, first) + + second, err := store.ClaimAlertSend(task.ID, 7, "error") + require.NoError(t, err) + assert.False(t, second) + + otherEvent, err := store.ClaimAlertSend(task.ID, 7, "success") + require.NoError(t, err) + assert.True(t, otherEvent) +} diff --git a/db/sql/migration.go b/db/sql/migration.go index cf8f4598f3..00cb0007e5 100644 --- a/db/sql/migration.go +++ b/db/sql/migration.go @@ -282,6 +282,8 @@ func (d *SqlDb) ApplyMigration(migration db.Migration) error { err = migration_2_8_42{db: d}.PostApply(tx) case "2.19.11": err = migration_2_19_11{db: d}.PostApply(tx) + case "2.20.6": + err = migration_2_20_6{db: d}.PostApply(tx) } if err != nil { diff --git a/db/sql/migration_2_19_14_test.go b/db/sql/migration_2_19_14_test.go index 06e1e9e312..d4a5cccacc 100644 --- a/db/sql/migration_2_19_14_test.go +++ b/db/sql/migration_2_19_14_test.go @@ -59,18 +59,16 @@ func TestMigration_2_19_14_DataSurvivesRebuild(t *testing.T) { projectID, repo.ID) require.NoError(t, err) - task, err := store.CreateTask(db.Task{ - TemplateID: templateID, - ProjectID: projectID, - Status: "success", - Playbook: "site.yml", - UserID: &user.ID, - Created: now, - }, 0) + // SqlDb.CreateTask writes alert_snapshot, which appears only in 2.20.6, + // so seed the task with SQL matching the 2.19.12 schema. + taskID, err := store.insert("id", + "insert into task (template_id, project_id, status, playbook, environment, user_id, created, message, commit_message) "+ + "values (?, ?, 'success', 'site.yml', '', ?, ?, '', '')", + templateID, projectID, user.ID, now) require.NoError(t, err) _, err = store.CreateTaskOutput(db.TaskOutput{ - TaskID: task.ID, + TaskID: taskID, Time: now, Output: "ok", }) @@ -90,12 +88,12 @@ func TestMigration_2_19_14_DataSurvivesRebuild(t *testing.T) { require.NoError(t, err) assert.Equal(t, user.ID, survivedSession.UserID) - survivedTask, err := store.GetTask(projectID, task.ID) + survivedTask, err := store.GetTask(projectID, taskID) require.NoError(t, err) require.NotNil(t, survivedTask.UserID) assert.Equal(t, user.ID, *survivedTask.UserID) - outputs, err := store.GetTaskOutputs(projectID, task.ID, db.RetrieveQueryParams{}) + outputs, err := store.GetTaskOutputs(projectID, taskID, db.RetrieveQueryParams{}) require.NoError(t, err) assert.Len(t, outputs, 1) @@ -107,7 +105,7 @@ func TestMigration_2_19_14_DataSurvivesRebuild(t *testing.T) { _, err = store.GetSession(user.ID, session.ID) assert.ErrorIs(t, err, db.ErrNotFound) - survivedTask, err = store.GetTask(projectID, task.ID) + survivedTask, err = store.GetTask(projectID, taskID) require.NoError(t, err) assert.Nil(t, survivedTask.UserID) } diff --git a/db/sql/migration_2_20_6.go b/db/sql/migration_2_20_6.go new file mode 100644 index 0000000000..ce6828ac30 --- /dev/null +++ b/db/sql/migration_2_20_6.go @@ -0,0 +1,168 @@ +package sql + +import ( + "github.com/go-gorp/gorp/v3" + "github.com/semaphoreui/semaphore/db" + "github.com/semaphoreui/semaphore/util" +) + +type migration_2_20_6 struct { + db *SqlDb +} + +type migrationProjectAlert struct { + ID int `db:"id"` + Alert bool `db:"alert"` + Chat string `db:"alert_chat"` +} + +type migrationTemplateAlert struct { + ID int `db:"id"` + ProjectID int `db:"project_id"` + SuppressSuccessAlerts bool `db:"suppress_success_alerts"` + SuppressErrorAlerts bool `db:"suppress_error_alerts"` +} + +func (m migration_2_20_6) insertAlert( + tx *gorp.Transaction, + projectID int, + name string, + alertType db.AlertType, + chatID string, + url string, + token string, + isDefault bool, +) (int64, error) { + var chat, webhook, tok *string + if chatID != "" { + chat = &chatID + } + if url != "" { + webhook = &url + } + if token != "" { + tok = &token + } + + insertQuery := "insert into project__alert " + + "(project_id, name, `type`, enabled, is_default, chat_id, url, token) values (?, ?, ?, ?, ?, ?, ?, ?)" + + switch m.db.Sql().Dialect.(type) { + case gorp.PostgresDialect: + return tx.SelectInt( + m.db.PrepareQuery(insertQuery+" returning id"), + projectID, name, string(alertType), true, isDefault, chat, webhook, tok, + ) + default: + res, err := tx.Exec( + m.db.PrepareQuery(insertQuery), + projectID, name, string(alertType), true, isDefault, chat, webhook, tok, + ) + if err != nil { + return 0, err + } + return res.LastInsertId() + } +} + +func (m migration_2_20_6) PostApply(tx *gorp.Transaction) error { + if util.Config == nil { + return nil + } + + var projects []migrationProjectAlert + _, err := tx.Select( + &projects, + m.db.PrepareQuery("select id, alert, coalesce(alert_chat, '') as alert_chat from project"), + ) + if err != nil { + return err + } + + cfg := util.Config + + for _, p := range projects { + seeds := []seed{ + {cfg.EmailAlert, "Email", db.AlertTypeEmail, "", "", ""}, + {cfg.TelegramAlert, "Telegram", db.AlertTypeTelegram, firstNonEmpty(p.Chat, cfg.TelegramChat), "", ""}, + {cfg.SlackAlert, "Slack", db.AlertTypeSlack, "", cfg.SlackUrl, ""}, + {cfg.MicrosoftTeamsAlert, "Microsoft Teams", db.AlertTypeTeams, "", cfg.MicrosoftTeamsUrl, ""}, + {cfg.RocketChatAlert, "Rocket.Chat", db.AlertTypeRocketChat, "", cfg.RocketChatUrl, ""}, + {cfg.DingTalkAlert, "DingTalk", db.AlertTypeDingTalk, "", cfg.DingTalkUrl, ""}, + {cfg.GotifyAlert, "Gotify", db.AlertTypeGotify, "", "", ""}, + } + + var created []int64 + for _, s := range seeds { + if !seedHasDestination(s) { + continue + } + id, err2 := m.insertAlert(tx, p.ID, s.name, s.typ, s.chat, s.url, s.token, p.Alert) + if err2 != nil { + return err2 + } + created = append(created, id) + } + + var templates []migrationTemplateAlert + _, err = tx.Select( + &templates, + m.db.PrepareQuery( + "select id, project_id, suppress_success_alerts, suppress_error_alerts "+ + "from project__template where project_id=?", + ), + p.ID, + ) + if err != nil { + return err + } + + for _, tpl := range templates { + mode := db.AlertModeDefault + if !p.Alert || len(created) == 0 || (tpl.SuppressSuccessAlerts && tpl.SuppressErrorAlerts) { + mode = db.AlertModeIDs + } + _, err = tx.Exec( + m.db.PrepareQuery("update project__template set alert_mode=? where id=? and project_id=?"), + mode, tpl.ID, p.ID, + ) + if err != nil { + return err + } + } + } + + return nil +} + +type seed struct { + enabled bool + name string + typ db.AlertType + chat string + url string + token string +} + +func seedHasDestination(s seed) bool { + if !s.enabled { + return false + } + switch s.typ { + case db.AlertTypeTelegram: + return s.chat != "" + case db.AlertTypeSlack, db.AlertTypeTeams, db.AlertTypeRocketChat, db.AlertTypeDingTalk: + return s.url != "" && db.ValidateAlertURL(s.url) == nil + default: + return true + } +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} diff --git a/db/sql/migration_2_20_6_test.go b/db/sql/migration_2_20_6_test.go new file mode 100644 index 0000000000..95889acd40 --- /dev/null +++ b/db/sql/migration_2_20_6_test.go @@ -0,0 +1,174 @@ +package sql + +import ( + "testing" + + "github.com/semaphoreui/semaphore/db" + "github.com/semaphoreui/semaphore/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSeedHasDestination(t *testing.T) { + assert.False(t, seedHasDestination(seed{enabled: true, typ: db.AlertTypeTelegram})) + assert.True(t, seedHasDestination(seed{enabled: true, typ: db.AlertTypeTelegram, chat: "1"})) + assert.False(t, seedHasDestination(seed{enabled: true, typ: db.AlertTypeSlack})) + assert.False(t, seedHasDestination(seed{enabled: true, typ: db.AlertTypeSlack, url: "javascript:x"})) + assert.True(t, seedHasDestination(seed{enabled: true, typ: db.AlertTypeSlack, url: "https://hooks.example/x"})) + assert.True(t, seedHasDestination(seed{enabled: true, typ: db.AlertTypeGotify})) + assert.False(t, seedHasDestination(seed{enabled: false, typ: db.AlertTypeGotify})) +} + +func TestMigration_2_20_6_CopiesInstanceChannelsOnce(t *testing.T) { + store := InitConfigCreateTestStore() + util.Config.TelegramAlert = true + util.Config.TelegramChat = "cfg-chat" + util.Config.SlackAlert = true + util.Config.SlackUrl = "https://hooks.example/slack" + util.Config.GotifyAlert = true + util.Config.GotifyUrl = "https://gotify.example/push" + util.Config.GotifyToken = "instance-gotify-token" + + chat := "project-chat" + alertingProject, err := store.CreateProject(db.Project{ + Name: "alerting", + Alert: true, + AlertChat: &chat, + }) + require.NoError(t, err) + + quietProject, err := store.CreateProject(db.Project{Name: "quiet"}) + require.NoError(t, err) + + _, alertingRepo := newTemplateTestProjectAt(t, store, alertingProject.ID) + _, quietRepo := newTemplateTestProjectAt(t, store, quietProject.ID) + + alertingTpl, err := store.CreateTemplate(db.Template{ + ProjectID: alertingProject.ID, + RepositoryID: alertingRepo, + Name: "nightly", + Playbook: "site.yml", + }) + require.NoError(t, err) + + silencedTpl, err := store.CreateTemplate(db.Template{ + ProjectID: alertingProject.ID, + RepositoryID: alertingRepo, + Name: "silent", + Playbook: "site.yml", + SuppressSuccessAlerts: true, + SuppressErrorAlerts: true, + }) + require.NoError(t, err) + + quietTpl, err := store.CreateTemplate(db.Template{ + ProjectID: quietProject.ID, + RepositoryID: quietRepo, + Name: "manual", + Playbook: "site.yml", + }) + require.NoError(t, err) + + tx, err := store.Sql().Begin() + require.NoError(t, err) + require.NoError(t, migration_2_20_6{db: store}.PostApply(tx)) + require.NoError(t, tx.Commit()) + + alerts, err := store.GetAlerts(alertingProject.ID, db.RetrieveQueryParams{}) + require.NoError(t, err) + require.Len(t, alerts, 3) + + var telegram db.Alert + var gotify db.Alert + for _, alert := range alerts { + if alert.Type == db.AlertTypeTelegram { + telegram = alert + } + if alert.Type == db.AlertTypeGotify { + gotify = alert + } + } + require.Equal(t, db.AlertTypeTelegram, telegram.Type) + require.NotNil(t, telegram.ChatID) + assert.Equal(t, "project-chat", *telegram.ChatID) + assert.True(t, telegram.IsDefault) + require.Equal(t, db.AlertTypeGotify, gotify.Type) + assert.Nil(t, gotify.URL) + assert.Nil(t, gotify.Token) + assert.True(t, gotify.IsDefault) + + alertingTpl, err = store.GetTemplate(alertingProject.ID, alertingTpl.ID) + require.NoError(t, err) + assert.Equal(t, db.AlertModeDefault, alertingTpl.AlertMode) + assert.Empty(t, alertingTpl.AlertIDs) + + silencedTpl, err = store.GetTemplate(alertingProject.ID, silencedTpl.ID) + require.NoError(t, err) + assert.Equal(t, db.AlertModeIDs, silencedTpl.AlertMode) + assert.Empty(t, silencedTpl.AlertIDs) + + defaults, err := store.GetDefaultAlertIDs(alertingProject.ID) + require.NoError(t, err) + assert.Len(t, defaults, 3) + + quietAlerts, err := store.GetAlerts(quietProject.ID, db.RetrieveQueryParams{}) + require.NoError(t, err) + assert.Len(t, quietAlerts, 3) + assert.False(t, quietAlerts[0].IsDefault) + + quietTpl, err = store.GetTemplate(quietProject.ID, quietTpl.ID) + require.NoError(t, err) + assert.Equal(t, db.AlertModeIDs, quietTpl.AlertMode) + assert.Empty(t, quietTpl.AlertIDs) + + quietDefaults, err := store.GetDefaultAlertIDs(quietProject.ID) + require.NoError(t, err) + assert.Empty(t, quietDefaults) +} + +func TestMigration_2_20_6_SkipsIncompleteDestinations(t *testing.T) { + store := InitConfigCreateTestStore() + util.Config.TelegramAlert = true + util.Config.TelegramChat = "" + util.Config.SlackAlert = true + util.Config.SlackUrl = "" + util.Config.GotifyAlert = true + util.Config.GotifyUrl = "https://gotify.example/push" + util.Config.GotifyToken = "instance-gotify-token" + + project, err := store.CreateProject(db.Project{Name: "partial"}) + require.NoError(t, err) + + tx, err := store.Sql().Begin() + require.NoError(t, err) + require.NoError(t, migration_2_20_6{db: store}.PostApply(tx)) + require.NoError(t, tx.Commit()) + + alerts, err := store.GetAlerts(project.ID, db.RetrieveQueryParams{}) + require.NoError(t, err) + require.Len(t, alerts, 1) + assert.Equal(t, db.AlertTypeGotify, alerts[0].Type) + assert.Nil(t, alerts[0].URL) + assert.Nil(t, alerts[0].Token) +} + +func newTemplateTestProjectAt(t *testing.T, store *SqlDb, projectID int) (int, int) { + t.Helper() + + key, err := store.CreateAccessKey(db.AccessKey{ + ProjectID: &projectID, + Type: db.AccessKeyNone, + }) + require.NoError(t, err) + + repo, err := store.CreateRepository(db.Repository{ + ProjectID: projectID, + Name: "repo", + GitURL: "https://example.com/repo.git", + GitBranch: "main", + SSHKeyID: key.ID, + }) + require.NoError(t, err) + + return projectID, repo.ID +} diff --git a/db/sql/migrations/v2.20.6.err.sql b/db/sql/migrations/v2.20.6.err.sql new file mode 100644 index 0000000000..215fba9c5f --- /dev/null +++ b/db/sql/migrations/v2.20.6.err.sql @@ -0,0 +1,15 @@ +drop table `task__alert_send`; + +alter table `task` drop column `alert_snapshot`; + +alter table `project__schedule` drop column `alert_on_error`; +alter table `project__schedule` drop column `alert_on_success`; +alter table `project__schedule` drop column `alert_mode`; + +alter table `project__template` drop column `alert_on_error`; +alter table `project__template` drop column `alert_on_success`; +alter table `project__template` drop column `alert_mode`; + +drop table `project__schedule_alert`; +drop table `project__template_alert`; +drop table `project__alert`; diff --git a/db/sql/migrations/v2.20.6.sql b/db/sql/migrations/v2.20.6.sql new file mode 100644 index 0000000000..a589de3342 --- /dev/null +++ b/db/sql/migrations/v2.20.6.sql @@ -0,0 +1,60 @@ +create table `project__alert` ( + `id` integer primary key autoincrement, + `project_id` int not null, + `name` varchar(255) not null, + `type` varchar(20) not null, + `enabled` boolean not null default true, + `is_default` boolean not null default false, + `chat_id` varchar(100), + `thread_id` varchar(50), + `url` text, + `token` varchar(255), + `recipients` text, + `key_id` int, + `body` longtext, + unique (`project_id`, `name`), + foreign key (`project_id`) references `project`(`id`) on delete cascade, + foreign key (`key_id`) references `access_key`(`id`) on delete set null +); + +create table `project__template_alert` ( + `project_id` int not null, + `template_id` int not null, + `alert_id` int not null, + primary key (`template_id`, `alert_id`), + foreign key (`project_id`) references `project`(`id`) on delete cascade, + foreign key (`template_id`) references `project__template`(`id`) on delete cascade, + foreign key (`alert_id`) references `project__alert`(`id`) on delete restrict +); + +create table `project__schedule_alert` ( + `project_id` int not null, + `schedule_id` int not null, + `alert_id` int not null, + primary key (`schedule_id`, `alert_id`), + foreign key (`project_id`) references `project`(`id`) on delete cascade, + foreign key (`schedule_id`) references `project__schedule`(`id`) on delete cascade, + foreign key (`alert_id`) references `project__alert`(`id`) on delete restrict +); + +alter table `project__template` add column `alert_on_success` boolean not null default true; +alter table `project__template` add column `alert_on_error` boolean not null default true; +alter table `project__template` add column `alert_mode` varchar(20) not null default 'default'; + +update `project__template` set `alert_on_success` = case when `suppress_success_alerts` then false else true end; +update `project__template` set `alert_on_error` = case when `suppress_error_alerts` then false else true end; + +alter table `project__schedule` add column `alert_mode` varchar(20) not null default 'inherit'; +alter table `project__schedule` add column `alert_on_success` boolean null; +alter table `project__schedule` add column `alert_on_error` boolean null; + +alter table `task` add column `alert_snapshot` longtext; + +create table `task__alert_send` ( + `task_id` int not null, + `alert_id` int not null, + `event` varchar(20) not null, + `created` datetime not null, + primary key (`task_id`, `alert_id`, `event`), + foreign key (`task_id`) references `task`(`id`) on delete cascade +); diff --git a/db/sql/schedule.go b/db/sql/schedule.go index 86aa837068..38d8497d46 100644 --- a/db/sql/schedule.go +++ b/db/sql/schedule.go @@ -6,25 +6,37 @@ import ( ) func (d *SqlDb) CreateSchedule(schedule db.Schedule) (newSchedule db.Schedule, err error) { + if schedule.Type == "" { + schedule.Type = db.ScheduleTypeCron + } + if err = schedule.NormalizeAlerts(); err != nil { + return + } + if err = d.validateAlertIDs(schedule.ProjectID, schedule.AlertIDs); err != nil { + return + } + + tx, err := d.Sql().Begin() + if err != nil { + return + } if schedule.TaskParams != nil { params := schedule.TaskParams params.ProjectID = schedule.ProjectID - err = d.Sql().Insert(params) + err = tx.Insert(params) if err != nil { + _ = tx.Rollback() return } schedule.TaskParamsID = ¶ms.ID } - if schedule.Type == "" { - schedule.Type = db.ScheduleTypeCron - } - - insertID, err := d.insert( + insertID, err := d.insertTx( + tx, "id", - "insert into project__schedule (project_id, template_id, cron_format, repository_id, `name`, `active`, run_at, `type`, task_params_id, delete_after_run)"+ - "values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "insert into project__schedule (project_id, template_id, cron_format, repository_id, `name`, `active`, run_at, `type`, task_params_id, delete_after_run, alert_mode, alert_on_success, alert_on_error)"+ + "values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", schedule.ProjectID, schedule.TemplateID, schedule.CronFormat, @@ -34,15 +46,28 @@ func (d *SqlDb) CreateSchedule(schedule db.Schedule) (newSchedule db.Schedule, e schedule.RunAt, schedule.Type, schedule.TaskParamsID, - schedule.DeleteAfterRun) + schedule.DeleteAfterRun, + schedule.AlertMode, + schedule.AlertOnSuccess, + schedule.AlertOnError) if err != nil { + _ = tx.Rollback() + return + } + + if err = d.updateScheduleAlertsInTx(tx, schedule.ProjectID, insertID, schedule.AlertIDs); err != nil { + _ = tx.Rollback() + return + } + + if err = tx.Commit(); err != nil { return } newSchedule = schedule newSchedule.ID = insertID - + err = d.fillScheduleAlerts(newSchedule.ProjectID, &newSchedule) return } @@ -57,36 +82,62 @@ func (d *SqlDb) SetScheduleLastCommitHash(projectID int, scheduleID int, lastCom } func (d *SqlDb) UpdateSchedule(schedule db.Schedule) (err error) { + if schedule.Type == "" { + schedule.Type = db.ScheduleTypeCron + } + if schedule.AlertMode == "" { + var curr db.Schedule + if err = d.getObject(schedule.ProjectID, db.ScheduleProps, schedule.ID, &curr); err != nil { + return + } + schedule.AlertMode = curr.AlertMode + if schedule.AlertOnSuccess == nil { + schedule.AlertOnSuccess = curr.AlertOnSuccess + } + if schedule.AlertOnError == nil { + schedule.AlertOnError = curr.AlertOnError + } + } + if err = schedule.NormalizeAlerts(); err != nil { + return + } + if schedule.AlertIDs != nil { + if err = d.validateAlertIDs(schedule.ProjectID, schedule.AlertIDs); err != nil { + return + } + } + var curr db.Schedule if schedule.TaskParams != nil { - var curr db.Schedule err = d.getObject(schedule.ProjectID, db.ScheduleProps, schedule.ID, &curr) if err != nil { return } + } + + tx, err := d.Sql().Begin() + if err != nil { + return + } + if schedule.TaskParams != nil { params := schedule.TaskParams params.ProjectID = schedule.ProjectID if curr.TaskParamsID == nil { - err = d.Sql().Insert(params) + err = tx.Insert(params) } else { params.ID = *curr.TaskParamsID - _, err = d.Sql().Update(params) + _, err = tx.Update(params) } - if err != nil { + _ = tx.Rollback() return } - schedule.TaskParamsID = ¶ms.ID } - if schedule.Type == "" { - schedule.Type = db.ScheduleTypeCron - } - - _, err = d.exec("update project__schedule set "+ + _, err = d.execTx(tx, "update project__schedule set "+ "cron_format=?, "+ "repository_id=?, "+ "template_id=?, "+ @@ -96,7 +147,10 @@ func (d *SqlDb) UpdateSchedule(schedule db.Schedule) (err error) { "`type`=?, "+ "last_commit_hash = NULL, "+ "task_params_id=?, "+ - "delete_after_run=? "+ + "delete_after_run=?, "+ + "alert_mode=?, "+ + "alert_on_success=?, "+ + "alert_on_error=? "+ "where project_id=? and id=?", schedule.CronFormat, schedule.RepositoryID, @@ -107,10 +161,31 @@ func (d *SqlDb) UpdateSchedule(schedule db.Schedule) (err error) { schedule.Type, schedule.TaskParamsID, schedule.DeleteAfterRun, + schedule.AlertMode, + schedule.AlertOnSuccess, + schedule.AlertOnError, schedule.ProjectID, schedule.ID) + if err != nil { + _ = tx.Rollback() + return + } - return + // inherit never keeps custom bindings, so delete refs stay accurate. + // nil alert_ids on ids mode means the client omitted them (legacy PUT). + if schedule.AlertMode != db.AlertModeIDs { + if err = d.updateScheduleAlertsInTx(tx, schedule.ProjectID, schedule.ID, nil); err != nil { + _ = tx.Rollback() + return + } + } else if schedule.AlertIDs != nil { + if err = d.updateScheduleAlertsInTx(tx, schedule.ProjectID, schedule.ID, schedule.AlertIDs); err != nil { + _ = tx.Rollback() + return + } + } + + return tx.Commit() } func (d *SqlDb) GetSchedule(projectID int, scheduleID int) (schedule db.Schedule, err error) { @@ -134,6 +209,7 @@ func (d *SqlDb) GetSchedule(projectID int, scheduleID int) (schedule db.Schedule schedule.TaskParams = &taskParams } + err = d.fillScheduleAlerts(projectID, &schedule) return } @@ -175,6 +251,9 @@ func (d *SqlDb) GetProjectSchedules(projectID int, includeTaskParams bool, inclu repoFilter+ "ps.project_id=?", projectID) + if err != nil { + return + } if includeTaskParams { for i := range schedules { @@ -191,6 +270,13 @@ func (d *SqlDb) GetProjectSchedules(projectID int, includeTaskParams bool, inclu } } + for i := range schedules { + err = d.fillScheduleAlerts(projectID, &schedules[i].Schedule) + if err != nil { + return nil, err + } + } + return } @@ -211,6 +297,16 @@ func (d *SqlDb) GetTemplateSchedules(projectID int, templateID int, onlyCommitCh } _, err = d.selectAll(&schedules, query, args...) + if err != nil { + return + } + + for i := range schedules { + err = d.fillScheduleAlerts(projectID, &schedules[i]) + if err != nil { + return + } + } return } diff --git a/db/sql/template.go b/db/sql/template.go index 3f00ab07b6..2e7fbf949c 100644 --- a/db/sql/template.go +++ b/db/sql/template.go @@ -5,6 +5,7 @@ import ( "errors" sq "github.com/Masterminds/squirrel" + "github.com/go-gorp/gorp/v3" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/common_errors" log "github.com/sirupsen/logrus" @@ -44,6 +45,19 @@ func (d *SqlDb) CreateTemplate(tmpl db.Template) (db.Template, error) { } tmpl.ApplyLegacyEnvironmentField() + onSuccess := db.ResolveAlertOn(tmpl.AlertOnSuccess, tmpl.SuppressSuccessAlerts) + onError := db.ResolveAlertOn(tmpl.AlertOnError, tmpl.SuppressErrorAlerts) + tmpl.AlertOnSuccess = db.BoolPtr(onSuccess) + tmpl.AlertOnError = db.BoolPtr(onError) + tmpl.SuppressSuccessAlerts = !onSuccess + tmpl.SuppressErrorAlerts = !onError + + if err := tmpl.NormalizeAlerts(); err != nil { + return db.Template{}, err + } + if err := d.validateAlertIDs(tmpl.ProjectID, tmpl.AlertIDs); err != nil { + return db.Template{}, err + } query, args, err := sq.Insert("project__template"). SetMap(map[string]any{ @@ -64,6 +78,9 @@ func (d *SqlDb) CreateTemplate(tmpl db.Template) (db.Template, error) { "survey_vars": db.ObjectToJSON(tmpl.SurveyVars), "suppress_success_alerts": tmpl.SuppressSuccessAlerts, "suppress_error_alerts": tmpl.SuppressErrorAlerts, + "alert_on_success": onSuccess, + "alert_on_error": onError, + "alert_mode": tmpl.AlertMode, "app": tmpl.App, "git_branch": tmpl.GitBranch, "runner_tag": tmpl.RunnerTag, @@ -78,18 +95,37 @@ func (d *SqlDb) CreateTemplate(tmpl db.Template) (db.Template, error) { return db.Template{}, err } - tmplId, err := d.insert("id", query, args...) + tx, err := d.Sql().Begin() if err != nil { return db.Template{}, err } - err = d.UpdateTemplateVaults(tmpl.ProjectID, tmplId, tmpl.Vaults) + tmplId, err := d.insertTx(tx, "id", query, args...) if err != nil { + _ = tx.Rollback() return db.Template{}, err } - err = d.UpdateTemplateEnvironments(tmpl.ProjectID, tmplId, tmpl.EnvironmentIDs) - if err != nil { + if err = d.updateTemplateVaultsInTx(tx, tmpl.ProjectID, tmplId, tmpl.Vaults); err != nil { + _ = tx.Rollback() + return db.Template{}, err + } + + if err = d.updateTemplateEnvironmentsInTx(tx, tmpl.ProjectID, tmplId, tmpl.EnvironmentIDs); err != nil { + _ = tx.Rollback() + return db.Template{}, err + } + + createAlertIDs := tmpl.AlertIDs + if tmpl.AlertMode != db.AlertModeIDs { + createAlertIDs = nil + } + if err = d.updateTemplateAlertsInTx(tx, tmpl.ProjectID, tmplId, createAlertIDs); err != nil { + _ = tx.Rollback() + return db.Template{}, err + } + + if err = tx.Commit(); err != nil { return db.Template{}, err } @@ -111,6 +147,34 @@ func (d *SqlDb) UpdateTemplate(tmpl db.Template) error { return err } + onSuccess := db.ResolveAlertOn(tmpl.AlertOnSuccess, tmpl.SuppressSuccessAlerts) + onError := db.ResolveAlertOn(tmpl.AlertOnError, tmpl.SuppressErrorAlerts) + tmpl.AlertOnSuccess = db.BoolPtr(onSuccess) + tmpl.AlertOnError = db.BoolPtr(onError) + tmpl.SuppressSuccessAlerts = !onSuccess + tmpl.SuppressErrorAlerts = !onError + + if tmpl.AlertMode == "" { + var curr db.Template + if err = d.getObject(tmpl.ProjectID, db.TemplateProps, tmpl.ID, &curr); err != nil { + return err + } + if tmpl.AlertIDs != nil { + tmpl.AlertMode = db.AlertModeIDs + } else { + tmpl.AlertMode = curr.AlertMode + } + } + if err = tmpl.NormalizeAlerts(); err != nil { + return err + } + + if tmpl.AlertIDs != nil { + if err = d.validateAlertIDs(tmpl.ProjectID, tmpl.AlertIDs); err != nil { + return err + } + } + query, args, err := sq.Update("project__template"). SetMap(map[string]any{ "inventory_id": tmpl.InventoryID, @@ -129,6 +193,9 @@ func (d *SqlDb) UpdateTemplate(tmpl db.Template) error { "survey_vars": db.ObjectToJSON(tmpl.SurveyVars), "suppress_success_alerts": tmpl.SuppressSuccessAlerts, "suppress_error_alerts": tmpl.SuppressErrorAlerts, + "alert_on_success": onSuccess, + "alert_on_error": onError, + "alert_mode": tmpl.AlertMode, "app": tmpl.App, "`git_branch`": tmpl.GitBranch, "task_params": tmpl.TaskParams, @@ -147,18 +214,41 @@ func (d *SqlDb) UpdateTemplate(tmpl db.Template) error { return err } - _, err = d.exec(query, args...) + tx, err := d.Sql().Begin() if err != nil { return err } - err = d.UpdateTemplateVaults(tmpl.ProjectID, tmpl.ID, tmpl.Vaults) - if err != nil { + if _, err = d.execTx(tx, query, args...); err != nil { + _ = tx.Rollback() + return err + } + + if err = d.updateTemplateVaultsInTx(tx, tmpl.ProjectID, tmpl.ID, tmpl.Vaults); err != nil { + _ = tx.Rollback() return err } tmpl.ApplyLegacyEnvironmentField() - return d.UpdateTemplateEnvironments(tmpl.ProjectID, tmpl.ID, tmpl.EnvironmentIDs) + if err = d.updateTemplateEnvironmentsInTx(tx, tmpl.ProjectID, tmpl.ID, tmpl.EnvironmentIDs); err != nil { + _ = tx.Rollback() + return err + } + // nil means the client omitted alert_ids (legacy PUT). [] clears bindings. + // default mode never keeps custom bindings, so refs stay accurate. + if tmpl.AlertMode != db.AlertModeIDs { + if err = d.updateTemplateAlertsInTx(tx, tmpl.ProjectID, tmpl.ID, nil); err != nil { + _ = tx.Rollback() + return err + } + } else if tmpl.AlertIDs != nil { + if err = d.updateTemplateAlertsInTx(tx, tmpl.ProjectID, tmpl.ID, tmpl.AlertIDs); err != nil { + _ = tx.Rollback() + return err + } + } + + return tx.Commit() } func (d *SqlDb) GetTemplateEnvironments(projectID int, templateID int) (environmentIDs []int, err error) { @@ -187,8 +277,20 @@ func (d *SqlDb) GetTemplateEnvironments(projectID int, templateID int) (environm return } -func (d *SqlDb) UpdateTemplateEnvironments(projectID int, templateID int, environmentIDs []int) (err error) { - _, err = d.exec( +func (d *SqlDb) UpdateTemplateEnvironments(projectID int, templateID int, environmentIDs []int) error { + tx, err := d.Sql().Begin() + if err != nil { + return err + } + if err = d.updateTemplateEnvironmentsInTx(tx, projectID, templateID, environmentIDs); err != nil { + _ = tx.Rollback() + return err + } + return tx.Commit() +} + +func (d *SqlDb) updateTemplateEnvironmentsInTx(tx *gorp.Transaction, projectID int, templateID int, environmentIDs []int) (err error) { + _, err = d.execTx(tx, "delete from project__template_environment where project_id=? and template_id=?", projectID, templateID, @@ -204,7 +306,7 @@ func (d *SqlDb) UpdateTemplateEnvironments(projectID int, templateID int, enviro } seen[envID] = true - _, err = d.exec( + _, err = d.execTx(tx, "insert into project__template_environment (project_id, template_id, environment_id) values (?, ?, ?)", projectID, templateID, @@ -287,6 +389,9 @@ func (d *SqlDb) getTemplates( "pt.executor_image", "pt.suppress_success_alerts", "pt.suppress_error_alerts", + "pt.alert_on_success", + "pt.alert_on_error", + "pt.alert_mode", "(SELECT `id` FROM `task` WHERE template_id = pt.id ORDER BY `id` DESC LIMIT 1) last_task_id", } @@ -421,6 +526,14 @@ func (d *SqlDb) getTemplates( return } + template.AlertIDs, err = d.GetTemplateAlerts(projectID, template.ID) + if err != nil { + return + } + if template.AlertMode == "" { + template.AlertMode = db.AlertModeDefault + } + // For backward compatibility if len(template.EnvironmentIDs) > 0 { template.EnvironmentID = template.EnvironmentIDs[0] diff --git a/db/sql/template_test.go b/db/sql/template_test.go index 8bdf57ce36..eb60e5002c 100644 --- a/db/sql/template_test.go +++ b/db/sql/template_test.go @@ -266,12 +266,15 @@ func TestTemplateSuppressAlertsRoundTrip(t *testing.T) { require.Len(t, listed, 1) assert.True(t, listed[0].SuppressSuccessAlerts) assert.True(t, listed[0].SuppressErrorAlerts) + assert.Equal(t, db.AlertModeDefault, listed[0].AlertMode) - loaded.SuppressErrorAlerts = false + loaded.AlertOnError = db.BoolPtr(true) require.NoError(t, store.UpdateTemplate(loaded)) loaded, err = store.GetTemplate(projectID, created.ID) require.NoError(t, err) assert.True(t, loaded.SuppressSuccessAlerts) assert.False(t, loaded.SuppressErrorAlerts) + require.NotNil(t, loaded.AlertOnError) + assert.True(t, *loaded.AlertOnError) } diff --git a/db/sql/template_vault.go b/db/sql/template_vault.go index d614454565..13b6a72d38 100644 --- a/db/sql/template_vault.go +++ b/db/sql/template_vault.go @@ -4,6 +4,7 @@ import ( "strconv" "strings" + "github.com/go-gorp/gorp/v3" "github.com/semaphoreui/semaphore/db" ) @@ -43,6 +44,18 @@ func (d *SqlDb) CreateTemplateVault(vault db.TemplateVault) (newVault db.Templat } func (d *SqlDb) UpdateTemplateVaults(projectID int, templateID int, vaults []db.TemplateVault) (err error) { + tx, err := d.Sql().Begin() + if err != nil { + return + } + if err = d.updateTemplateVaultsInTx(tx, projectID, templateID, vaults); err != nil { + _ = tx.Rollback() + return + } + return tx.Commit() +} + +func (d *SqlDb) updateTemplateVaultsInTx(tx *gorp.Transaction, projectID int, templateID int, vaults []db.TemplateVault) (err error) { if vaults == nil { vaults = []db.TemplateVault{} } @@ -56,19 +69,14 @@ func (d *SqlDb) UpdateTemplateVaults(projectID int, templateID int, vaults []db. vault.VaultKeyID = nil } if vault.ID == 0 { - // Insert new vaults var vaultId int - vaultId, err = d.insert("id", "insert into project__template_vault (project_id, template_id, vault_key_id, name, type, script) values (?, ?, ?, ?, ?, ?)", projectID, templateID, vault.VaultKeyID, vault.Name, vault.Type, vault.Script) + vaultId, err = d.insertTx(tx, "id", "insert into project__template_vault (project_id, template_id, vault_key_id, name, type, script) values (?, ?, ?, ?, ?, ?)", projectID, templateID, vault.VaultKeyID, vault.Name, vault.Type, vault.Script) if err != nil { return } vaultIDs = append(vaultIDs, strconv.Itoa(vaultId)) } else { - // Update existing vaults. The WHERE clause is scoped to the target - // project and template so a body-supplied vault ID belonging to - // another project/template cannot be reparented or overwritten; - // such an ID makes the update a no-op. - _, err = d.exec("update project__template_vault set vault_key_id=?, name=?, type=?, script=? where id=? and project_id=? and template_id=?", vault.VaultKeyID, vault.Name, vault.Type, vault.Script, vault.ID, projectID, templateID) + _, err = d.execTx(tx, "update project__template_vault set vault_key_id=?, name=?, type=?, script=? where id=? and project_id=? and template_id=?", vault.VaultKeyID, vault.Name, vault.Type, vault.Script, vault.ID, projectID, templateID) vaultIDs = append(vaultIDs, strconv.Itoa(vault.ID)) } if err != nil { @@ -76,11 +84,10 @@ func (d *SqlDb) UpdateTemplateVaults(projectID int, templateID int, vaults []db. } } - // Delete removed vaults if len(vaultIDs) == 0 { - _, err = d.exec("delete from project__template_vault where project_id=? and template_id=?", projectID, templateID) + _, err = d.execTx(tx, "delete from project__template_vault where project_id=? and template_id=?", projectID, templateID) } else { - _, err = d.exec("delete from project__template_vault where project_id=? and template_id=? and id not in ("+strings.Join(vaultIDs, ",")+")", projectID, templateID) + _, err = d.execTx(tx, "delete from project__template_vault where project_id=? and template_id=? and id not in ("+strings.Join(vaultIDs, ",")+")", projectID, templateID) } return diff --git a/services/export/Alert.go b/services/export/Alert.go new file mode 100644 index 0000000000..190ab45cd4 --- /dev/null +++ b/services/export/Alert.go @@ -0,0 +1,91 @@ +package export + +import ( + "strconv" + + "github.com/semaphoreui/semaphore/db" +) + +type AlertExporter struct { + ValueMap[db.Alert] +} + +func (e *AlertExporter) load(store db.Store, exporter DataExporter, progress Progress) error { + projs, err := exporter.getLoadedKeysInt(Project, GlobalScope) + if err != nil { + return err + } + + for _, proj := range projs { + alerts, err := store.GetAlerts(proj, db.RetrieveQueryParams{}) + if err != nil { + return err + } + + err = e.appendValues(alerts, strconv.Itoa(proj)) + if err != nil { + return err + } + } + return nil +} + +func (e *AlertExporter) restore(store db.Store, exporter DataExporter, progress Progress) (err error) { + return e.restoreValues(store, exporter, progress, e) +} + +func (e *AlertExporter) restoreValue(val EntityObject[db.Alert], store db.Store, exporter DataExporter) (err error) { + old := val.value + + old.ProjectID, err = exporter.getNewKeyInt(Project, GlobalScope, old.ProjectID) + if err != nil { + return err + } + + old.KeyID, err = exporter.getNewKeyIntRef(AccessKey, val.scope, old.KeyID, e) + if err != nil { + return err + } + + newObj, err := store.CreateAlert(old) + if err != nil { + return err + } + + return exporter.mapKeys(e.getName(), val.scope, old.GetDbKey(), newObj.GetDbKey()) +} + +func (e *AlertExporter) exportDependsOn() []string { + return []string{Project} +} + +func (e *AlertExporter) importDependsOn() []string { + return []string{Project, AccessKey} +} + +func (e *AlertExporter) getName() string { + return Alert +} + +func mapAlertIDs(mapper KeyMapper, scope string, ids []int) ([]int, error) { + return mapAlertIDsOpt(mapper, scope, ids, false) +} + +func mapAlertIDsOpt(mapper KeyMapper, scope string, ids []int, skipMissing bool) ([]int, error) { + if len(ids) == 0 { + return ids, nil + } + + mapped := make([]int, 0, len(ids)) + for _, id := range ids { + newID, err := mapper.getNewKeyInt(Alert, scope, id) + if err != nil { + if skipMissing { + continue + } + return nil, err + } + mapped = append(mapped, newID) + } + return mapped, nil +} diff --git a/services/export/Alert_test.go b/services/export/Alert_test.go new file mode 100644 index 0000000000..fc360d7af9 --- /dev/null +++ b/services/export/Alert_test.go @@ -0,0 +1,43 @@ +package export + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMapAlertIDs(t *testing.T) { + mapper := NewKeyMapper() + require.NoError(t, mapper.mapKeys(Alert, "1", "10", "100")) + require.NoError(t, mapper.mapKeys(Alert, "1", "11", "101")) + + mapped, err := mapAlertIDs(mapper, "1", []int{10, 11}) + require.NoError(t, err) + assert.Equal(t, []int{100, 101}, mapped) + + empty, err := mapAlertIDs(mapper, "1", []int{}) + require.NoError(t, err) + assert.Empty(t, empty) + + var nilIDs []int + got, err := mapAlertIDs(mapper, "1", nilIDs) + require.NoError(t, err) + assert.Nil(t, got) +} + +func TestMapAlertIDs_Missing(t *testing.T) { + mapper := NewKeyMapper() + + _, err := mapAlertIDs(mapper, "1", []int{10}) + require.Error(t, err) +} + +func TestMapAlertIDsOpt_SkipsMissing(t *testing.T) { + mapper := NewKeyMapper() + require.NoError(t, mapper.mapKeys(Alert, "1", "10", "100")) + + mapped, err := mapAlertIDsOpt(mapper, "1", []int{10, 99}, true) + require.NoError(t, err) + assert.Equal(t, []int{100}, mapped) +} diff --git a/services/export/Event.go b/services/export/Event.go index 0de0bdf2e2..738ef23379 100644 --- a/services/export/Event.go +++ b/services/export/Event.go @@ -102,6 +102,8 @@ func eventObjectTypeToEntityName(t db.EventObjectType) (string, bool) { return User, true case db.EventView: return View, true + case db.EventAlert: + return Alert, true case db.EventIntegration: return Integration, true case db.EventIntegrationExtractValue: @@ -146,7 +148,7 @@ func (e *EventExporter) exportDependsOn() []string { } func (e *EventExporter) importDependsOn() []string { - return []string{Project, User, Integration, AccessKey, Schedule, Environment, Template, Task, Inventory, Repository, View} + return []string{Project, User, Integration, AccessKey, Schedule, Environment, Template, Task, Inventory, Repository, View, Alert} } func (e *EventExporter) getName() string { diff --git a/services/export/Exporter.go b/services/export/Exporter.go index 82d438ab9e..859c993391 100644 --- a/services/export/Exporter.go +++ b/services/export/Exporter.go @@ -20,6 +20,7 @@ const ( Inventory = "Inventory" Repository = "Repository" View = "View" + Alert = "Alert" Role = "Role" TaskParams = "TaskParams" Integration = "Integration" @@ -420,6 +421,7 @@ func InitProjectExporters(mapper KeyMapper, skipTaskOutput bool, mergeExistingUs SecretStorage: &SecretStorageExporter{}, Inventory: &InventoryExporter{}, View: &ViewExporter{}, + Alert: &AlertExporter{}, Role: &RoleExporter{}, Schedule: &ScheduleExporter{}, ProjectUser: &ProjectUserExporter{}, diff --git a/services/export/Schedule.go b/services/export/Schedule.go index 169bcd324a..537c902144 100644 --- a/services/export/Schedule.go +++ b/services/export/Schedule.go @@ -75,6 +75,11 @@ func (e *ScheduleExporter) restoreValue(val EntityObject[db.Schedule], store db. return err } + old.AlertIDs, err = mapAlertIDs(exporter, val.scope, old.AlertIDs) + if err != nil { + return err + } + newObj, err := store.CreateSchedule(old) if err != nil { return err @@ -92,5 +97,5 @@ func (e *ScheduleExporter) exportDependsOn() []string { } func (e *ScheduleExporter) importDependsOn() []string { - return []string{Repository, Project, Inventory, Template} + return []string{Repository, Project, Inventory, Template, Alert} } diff --git a/services/export/Task.go b/services/export/Task.go index 43e03ef24a..ad2a882e32 100644 --- a/services/export/Task.go +++ b/services/export/Task.go @@ -80,6 +80,13 @@ func (e *TaskExporter) restoreValue(val EntityObject[db.Task], store db.Store, e return err } + if old.AlertSnapshot != nil { + old.AlertSnapshot.AlertIDs, err = mapAlertIDsOpt(exporter, val.scope, old.AlertSnapshot.AlertIDs, true) + if err != nil { + return err + } + } + newObj, err := store.CreateTask(old, 0) if err != nil { return err @@ -97,5 +104,5 @@ func (e *TaskExporter) exportDependsOn() []string { } func (e *TaskExporter) importDependsOn() []string { - return []string{Project, Template, Inventory, Integration, Schedule, User} + return []string{Project, Template, Inventory, Integration, Schedule, User, Alert} } diff --git a/services/export/Template.go b/services/export/Template.go index 80657b8801..34c7322f97 100644 --- a/services/export/Template.go +++ b/services/export/Template.go @@ -73,6 +73,11 @@ func (e *TemplateExporter) restoreValue(val EntityObject[db.Template], store db. return err } + old.AlertIDs, err = mapAlertIDs(exporter, val.scope, old.AlertIDs) + if err != nil { + return err + } + old.BuildTemplateID, err = exporter.getNewKeyIntRef(Template, val.scope, old.BuildTemplateID, e) if err != nil { return err @@ -95,5 +100,5 @@ func (e *TemplateExporter) exportDependsOn() []string { } func (e *TemplateExporter) importDependsOn() []string { - return []string{Project, Inventory, Environment, Repository, View} + return []string{Project, Inventory, Environment, Repository, View, Alert} } diff --git a/services/project/backup.go b/services/project/backup.go index 10c863217d..338d462331 100644 --- a/services/project/backup.go +++ b/services/project/backup.go @@ -84,6 +84,12 @@ func makeUniqueNames[T any](items []T, getter func(item *T) string, setter func( func (b *BackupDB) makeUniqueNames() { + makeUniqueNames(b.alerts, func(item *db.Alert) string { + return item.Name + }, func(item *db.Alert, name string) { + item.Name = name + }) + makeUniqueNames(b.templates, func(item *db.Template) string { return item.Name }, func(item *db.Template, name string) { @@ -156,6 +162,11 @@ func (b *BackupDB) load(projectID int, store db.Store, workflowStore db.Workflow b.workflowStore = workflowStore + b.alerts, err = store.GetAlerts(projectID, db.RetrieveQueryParams{}) + if err != nil { + return + } + b.templates, err = store.GetTemplates(projectID, db.TemplateFilter{}, db.RetrieveQueryParams{}) if err != nil { return @@ -309,10 +320,19 @@ func (b *BackupDB) format() (*BackupFormat, error) { continue } + var alertNames []string + for _, alertID := range o.AlertIDs { + name, _ := findNameByID[db.Alert](alertID, b.alerts) + if name != nil { + alertNames = append(alertNames, *name) + } + } + schedules[i] = BackupSchedule{ - o, - *tplName, - repoName, + Schedule: o, + Template: *tplName, + CheckableRepository: repoName, + Alerts: alertNames, } if o.TaskParams != nil && o.TaskParams.InventoryID != nil { @@ -452,6 +472,14 @@ func (b *BackupDB) format() (*BackupFormat, error) { } } + var alertNames []string + for _, alertID := range o.AlertIDs { + name, _ := findNameByID[db.Alert](alertID, b.alerts) + if name != nil { + alertNames = append(alertNames, *name) + } + } + templates[i] = BackupTemplate{ Template: o, View: View, @@ -461,6 +489,7 @@ func (b *BackupDB) format() (*BackupFormat, error) { BuildTemplate: BuildTemplate, Vaults: vaults, Roles: roles, + Alerts: alertNames, } } @@ -533,6 +562,11 @@ func (b *BackupDB) format() (*BackupFormat, error) { } } + alerts := make([]BackupAlert, len(b.alerts)) + for i, o := range b.alerts { + alerts[i] = BackupAlert{Alert: o} + } + return &BackupFormat{ Meta: BackupMeta{ b.meta, @@ -542,6 +576,7 @@ func (b *BackupDB) format() (*BackupFormat, error) { Views: views, Repositories: repositories, Keys: keys, + Alerts: alerts, Templates: templates, Integration: integrations, IntegrationAliases: integrationAliases, diff --git a/services/project/backup_test.go b/services/project/backup_test.go index 97dc4f5c05..bd4d5e2a54 100644 --- a/services/project/backup_test.go +++ b/services/project/backup_test.go @@ -153,6 +153,7 @@ func TestBackup_BackupSecretStorage(t *testing.T) { } assert.Equal(t, `{ + "alerts": [], "environments": [], "integration_aliases": [], "integrations": [], @@ -226,6 +227,103 @@ func TestBackup_BackupSecretStorage(t *testing.T) { // older Semaphore versions omit the per-schedule "task_params" object; on // restore, BackupSchedule.Restore used to dereference the nil pointer and // crash the HTTP handler with a runtime nil-pointer panic. +func TestBackup_AlertsRoundTrip(t *testing.T) { + util.Config = &util.ConfigType{ + TmpPath: "/tmp", + } + + store := sql.InitConfigCreateTestStore() + + proj, err := store.CreateProject(db.Project{Name: "Alert Backup"}) + assert.NoError(t, err) + + key, err := store.CreateAccessKey(db.AccessKey{ + ProjectID: &proj.ID, + Type: db.AccessKeyNone, + }) + assert.NoError(t, err) + + repo, err := store.CreateRepository(db.Repository{ + ProjectID: proj.ID, + SSHKeyID: key.ID, + Name: "Repo", + GitURL: "git@example.com:test/test", + GitBranch: "master", + }) + assert.NoError(t, err) + + token := "gotify-secret" + alert, err := store.CreateAlert(db.Alert{ + ProjectID: proj.ID, + Name: "Ops Gotify", + Type: db.AlertTypeGotify, + Enabled: true, + Token: &token, + }) + assert.NoError(t, err) + + _, err = store.CreateTemplate(db.Template{ + Name: "Nightly", + Playbook: "test.yml", + ProjectID: proj.ID, + RepositoryID: repo.ID, + AlertIDs: []int{alert.ID}, + AlertOnSuccess: db.BoolPtr(false), + AlertOnError: db.BoolPtr(true), + }) + assert.NoError(t, err) + + backup, err := GetBackup(proj.ID, store, proFactory.NewWorkflowStore(store)) + assert.NoError(t, err) + requireBackupAlerts(t, backup) + + str, err := backup.Marshal() + assert.NoError(t, err) + + restoredBackup := &BackupFormat{} + err = restoredBackup.Unmarshal(str) + assert.NoError(t, err) + restoredBackup.Meta.Name = "Alert Backup Restored" + + user, err := store.CreateUser(db.UserWithPwd{ + Pwd: "3412341234123", + User: db.User{ + Username: "alertbackup", + Name: "Test", + Email: "alertbackup@example.com", + Admin: true, + }, + }) + assert.NoError(t, err) + + restoredProj, err := restoredBackup.Restore(user, store, proFactory.NewWorkflowStore(store)) + assert.NoError(t, err) + + restoredAlerts, err := store.GetAlerts(restoredProj.ID, db.RetrieveQueryParams{}) + assert.NoError(t, err) + assert.Len(t, restoredAlerts, 1) + assert.Equal(t, "Ops Gotify", restoredAlerts[0].Name) + assert.Equal(t, db.AlertTypeGotify, restoredAlerts[0].Type) + if assert.NotNil(t, restoredAlerts[0].Token) { + assert.Equal(t, "gotify-secret", *restoredAlerts[0].Token) + } + assert.Contains(t, str, "gotify-secret") + + restoredTemplates, err := store.GetTemplates(restoredProj.ID, db.TemplateFilter{}, db.RetrieveQueryParams{}) + assert.NoError(t, err) + assert.Len(t, restoredTemplates, 1) + assert.Equal(t, []int{restoredAlerts[0].ID}, restoredTemplates[0].AlertIDs) + assert.False(t, *restoredTemplates[0].AlertOnSuccess) + assert.True(t, *restoredTemplates[0].AlertOnError) +} + +func requireBackupAlerts(t *testing.T, backup *BackupFormat) { + t.Helper() + assert.Len(t, backup.Alerts, 1) + assert.Equal(t, "Ops Gotify", backup.Alerts[0].Name) + assert.Equal(t, []string{"Ops Gotify"}, backup.Templates[0].Alerts) +} + func TestBackup_RestoreScheduleWithoutTaskParams(t *testing.T) { util.Config = &util.ConfigType{ TmpPath: "/tmp", @@ -363,3 +461,31 @@ func TestMakeUniqueNames(t *testing.T) { assert.True(t, isUnique(items), "Not unique names") } + +func TestVerifyDuplicate_RejectsTwoEqualNames(t *testing.T) { + err := verifyDuplicate[BackupAlert]("Ops", []BackupAlert{ + {Alert: db.Alert{Name: "Ops"}}, + {Alert: db.Alert{Name: "Ops"}}, + }) + assert.Error(t, err) + + err = verifyDuplicate[BackupAlert]("Ops", []BackupAlert{ + {Alert: db.Alert{Name: "Ops"}}, + }) + assert.NoError(t, err) +} + +func TestResolveBackupAlertIDs(t *testing.T) { + alerts := []db.Alert{{ID: 4, Name: "Ops"}, {ID: 9, Name: "Slack"}} + + ids, err := resolveBackupAlertIDs([]string{"Ops", "Slack"}, alerts) + assert.NoError(t, err) + assert.Equal(t, []int{4, 9}, ids) + + ids, err = resolveBackupAlertIDs(nil, alerts) + assert.NoError(t, err) + assert.Empty(t, ids) + + _, err = resolveBackupAlertIDs([]string{"Ops", "missing"}, alerts) + assert.Error(t, err) +} diff --git a/services/project/restore.go b/services/project/restore.go index 4c56f43bbe..e0096b758c 100644 --- a/services/project/restore.go +++ b/services/project/restore.go @@ -26,7 +26,7 @@ func verifyDuplicate[T BackupEntry](name string, items []T) error { if o.GetName() == name { n++ } - if n > 2 { + if n > 1 { return fmt.Errorf("%s is duplicate", name) } } @@ -94,6 +94,34 @@ func (e BackupView) Restore(b *BackupDB) error { return nil } +func resolveBackupAlertIDs(names []string, alerts []db.Alert) ([]int, error) { + var ids []int + for i := range names { + name := names[i] + if k := findEntityByName[db.Alert](&name, alerts); k == nil { + return nil, fmt.Errorf("alert %q does not exist in alerts[].name", name) + } else { + ids = append(ids, k.ID) + } + } + return ids, nil +} + +func (e BackupAlert) Verify(backup *BackupFormat) error { + return verifyDuplicate[BackupAlert](e.Name, backup.Alerts) +} + +func (e BackupAlert) Restore(b *BackupDB) error { + alert := e.Alert + alert.ProjectID = b.meta.ID + newAlert, err := b.store.CreateAlert(alert) + if err != nil { + return err + } + b.alerts = append(b.alerts, newAlert) + return nil +} + func (e BackupSchedule) Verify(backup *BackupFormat) error { return verifyDuplicate[BackupSchedule](e.Name, backup.Schedules) } @@ -123,6 +151,12 @@ func (e BackupSchedule) Restore(b *BackupDB) error { } } + alertIDs, err := resolveBackupAlertIDs(e.Alerts, b.alerts) + if err != nil { + return err + } + v.AlertIDs = alertIDs + newSchedule, err := b.store.CreateSchedule(v) if err != nil { return err @@ -329,6 +363,11 @@ func (e BackupTemplate) Restore(b *BackupDB) error { template.InventoryID = InventoryID template.ViewID = ViewID template.BuildTemplateID = BuildTemplateID + alertIDs, err := resolveBackupAlertIDs(e.Alerts, b.alerts) + if err != nil { + return err + } + template.AlertIDs = alertIDs newTemplate, err := b.store.CreateTemplate(template) if err != nil { @@ -563,6 +602,11 @@ func (backup *BackupFormat) Verify() error { return fmt.Errorf("error at inventories[%d]: %s", i, err.Error()) } } + for i, o := range backup.Alerts { + if err := o.Verify(backup); err != nil { + return fmt.Errorf("error at alerts[%d]: %s", i, err.Error()) + } + } for i, o := range backup.SecretStorages { if err := o.Verify(backup); err != nil { return fmt.Errorf("error at secret storage[%d]: %s", i, err.Error()) @@ -664,6 +708,12 @@ func (backup *BackupFormat) Restore(user db.User, store db.Store, workflowStore } } + for i, o := range backup.Alerts { + if err := o.Restore(&b); err != nil { + return nil, fmt.Errorf("error at alerts[%d]: %s", i, err.Error()) + } + } + deployTemplates := make([]int, 0) for i, o := range backup.Templates { if string(o.Type) == "deploy" { diff --git a/services/project/types.go b/services/project/types.go index 1627c00f29..bb888acdcb 100644 --- a/services/project/types.go +++ b/services/project/types.go @@ -13,6 +13,7 @@ type BackupDB struct { inventories []db.Inventory environments []db.Environment schedules []db.Schedule + alerts []db.Alert integrationProjAliases []db.IntegrationAlias integrations []db.Integration @@ -47,6 +48,7 @@ type BackupFormat struct { Integration []BackupIntegration `backup:"integrations"` IntegrationAliases []string `backup:"integration_aliases"` Schedules []BackupSchedule `backup:"schedules"` + Alerts []BackupAlert `backup:"alerts"` SecretStorages []BackupSecretStorage `backup:"secret_storages"` Roles []BackupRole `backup:"roles"` Runners []BackupRunner `backup:"runners"` @@ -69,8 +71,13 @@ type BackupAccessKey struct { type BackupSchedule struct { db.Schedule - Template string `backup:"template"` - CheckableRepository *string `backup:"checkable_repository"` + Template string `backup:"template"` + CheckableRepository *string `backup:"checkable_repository"` + Alerts []string `backup:"alerts"` +} + +type BackupAlert struct { + db.Alert } type BackupView struct { @@ -109,6 +116,8 @@ type BackupTemplate struct { VaultKey *string `json:"vault_key"` Roles []BackupTemplateRole `backup:"roles"` + + Alerts []string `backup:"alerts"` } type BackupTemplateVault struct { @@ -184,6 +193,10 @@ func (e BackupTemplate) GetName() string { return e.Name } +func (e BackupAlert) GetName() string { + return e.Name +} + func (e BackupSecretStorage) GetName() string { return e.Name } diff --git a/services/tasks/TaskPool.go b/services/tasks/TaskPool.go index 1dbf676346..1ee57602a1 100644 --- a/services/tasks/TaskPool.go +++ b/services/tasks/TaskPool.go @@ -1101,6 +1101,10 @@ func (p *TaskPool) AddTask( taskObj.CommitHash = nil } + if err = SnapshotTaskAlerts(p.store, &taskObj, tpl); err != nil { + return + } + if tpl.Type == db.TemplateBuild { // get next version for TaskRunner if it is a Build var builds []db.TaskWithTpl builds, err = p.store.GetTemplateTasks(tpl.ProjectID, tpl.ID, db.RetrieveQueryParams{Count: 1}) diff --git a/services/tasks/TaskRunner_logging.go b/services/tasks/TaskRunner_logging.go index 104744d2c8..45488b20a2 100644 --- a/services/tasks/TaskRunner_logging.go +++ b/services/tasks/TaskRunner_logging.go @@ -139,18 +139,7 @@ func (t *TaskRunner) SetStatus(status task_logger.TaskStatus) { localJob.SetStatus(status) } - if status == task_logger.TaskFailStatus { - t.sendMailAlert() - } - - if status.IsNotifiable() { - t.sendTelegramAlert() - t.sendSlackAlert() - t.sendRocketChatAlert() - t.sendMicrosoftTeamsAlert() - t.sendDingTalkAlert() - t.sendGotifyAlert() - } + t.sendStatusAlerts() for _, l := range t.statusListeners { l(status) diff --git a/services/tasks/alert.go b/services/tasks/alert.go index 8dd01c205b..701bc1290d 100644 --- a/services/tasks/alert.go +++ b/services/tasks/alert.go @@ -2,15 +2,23 @@ package tasks import ( "bytes" + "context" "embed" + "encoding/json" "fmt" htmltemplate "html/template" + "io" + "net" "net/http" "strconv" + "strings" "text/template" + "time" "github.com/semaphoreui/semaphore/db" + "github.com/semaphoreui/semaphore/pkg/common_errors" "github.com/semaphoreui/semaphore/pkg/task_logger" + "github.com/semaphoreui/semaphore/pkg/tz" "github.com/semaphoreui/semaphore/util" "github.com/semaphoreui/semaphore/util/mailer" ) @@ -20,33 +28,109 @@ var templates embed.FS // Alert represents an alert that will be templated and sent to the appropriate service type Alert struct { - Name string - Author string - Color string - Task alertTask - Chat alertChat + Name string + Author string + Color string + Task alertTask + Chat alertChat + Playbook string + Project alertProjectInfo + ScheduleName string } type alertTask struct { - ID string - URL string - Result string - Desc string - Version string + ID string + URL string + Result string + Desc string + Version string + Duration string + Trigger string +} + +type alertProjectInfo struct { + ID int + Name string } type alertChat struct { - ID string + ID string + ThreadID string +} + +func (t *TaskRunner) alertEvents() (onSuccess bool, onError bool) { + if t.Task.AlertSnapshot != nil { + return t.Task.AlertSnapshot.OnSuccess, t.Task.AlertSnapshot.OnError + } + return db.ResolveAlertOn(t.Template.AlertOnSuccess, t.Template.SuppressSuccessAlerts), + db.ResolveAlertOn(t.Template.AlertOnError, t.Template.SuppressErrorAlerts) } func (t *TaskRunner) shouldSkipStatusAlert() bool { - if t.Template.SuppressSuccessAlerts && t.Task.Status == task_logger.TaskSuccessStatus { + onSuccess, onError := t.alertEvents() + return shouldSkipAlertForStatus(t.Task.Status, onSuccess, onError) +} + +func shouldSkipAlertForStatus(status task_logger.TaskStatus, onSuccess bool, onError bool) bool { + switch status { + case task_logger.TaskSuccessStatus: + return !onSuccess + case task_logger.TaskFailStatus: + return !onError + default: return true } - if t.Template.SuppressErrorAlerts && t.Task.Status == task_logger.TaskFailStatus { - return true +} + +func (t *TaskRunner) sendStatusAlerts() { + if t.Task.AlertSnapshot == nil { + return + } + snapshot := *t.Task.AlertSnapshot + status := t.Task.Status + go t.sendResolvedAlerts(snapshot, status) +} + +func (t *TaskRunner) sendResolvedAlerts(snapshot db.AlertSnapshot, status task_logger.TaskStatus) { + if shouldSkipAlertForStatus(status, snapshot.OnSuccess, snapshot.OnError) { + return + } + if len(snapshot.AlertIDs) == 0 { + return + } + if t.pool == nil || t.pool.store == nil { + return + } + + sent := make(map[int]bool) + for _, alertID := range snapshot.AlertIDs { + if sent[alertID] { + continue + } + sent[alertID] = true + + alert, err := t.pool.store.GetAlert(t.Task.ProjectID, alertID) + if err != nil { + t.Logf("Can't load alert %d: %s", alertID, err.Error()) + continue + } + if !alert.Enabled { + continue + } + if t.Task.ID > 0 { + claimed, claimErr := t.pool.store.ClaimAlertSend(t.Task.ID, alertID, string(status)) + if claimErr != nil { + t.Logf("Can't claim alert %d: %s", alertID, claimErr.Error()) + continue + } + if !claimed { + continue + } + } + if err := t.sendProjectAlertForStatus(alert, status); err != nil { + t.Logf("%s", err.Error()) + } } - return false } func (t *TaskRunner) sendMailAlert() { @@ -115,7 +199,7 @@ func (t *TaskRunner) sendMailAlert() { util.Config.EmailPassword, util.Config.EmailSender, user.Email, - fmt.Sprintf("Task '%s' failed", t.Template.Name), + t.emailSubject(), str, ); err != nil { util.LogError(err) @@ -180,6 +264,12 @@ func (t *TaskRunner) sendTelegramAlert() { return } + payload, err := wrapTelegramMessage(chatID, "", body.String()) + if err != nil { + t.Log("Can't wrap telegram alert! Error: " + err.Error()) + return + } + t.Log("Attempting to send telegram alert") resp, err := http.Post( @@ -188,7 +278,7 @@ func (t *TaskRunner) sendTelegramAlert() { util.Config.TelegramToken, ), "application/json", - body, + strings.NewReader(payload), ) if err != nil { @@ -538,23 +628,24 @@ func (t *TaskRunner) alertInfos() (string, string) { author := "—" - if t.Task.UserID != nil { + if t.Task.UserID != nil && t.pool != nil && t.pool.store != nil { user, err := t.pool.store.GetUser(*t.Task.UserID) - - if err != nil { - panic(err) + if err == nil { + author = user.Name } - - author = user.Name } return author, version } func (t *TaskRunner) alertColor(kind string) string { + return alertColorFor(kind, t.Task.Status) +} + +func alertColorFor(kind string, status task_logger.TaskStatus) string { switch kind { case "slack": - switch t.Task.Status { + switch status { case task_logger.TaskSuccessStatus: return "good" case task_logger.TaskFailStatus: @@ -569,7 +660,7 @@ func (t *TaskRunner) alertColor(kind string) string { return "#5B5B5B" } case "rocketchat": - switch t.Task.Status { + switch status { case task_logger.TaskSuccessStatus: return "#00EE00" case task_logger.TaskFailStatus: @@ -597,3 +688,504 @@ func (t *TaskRunner) taskLink() string { t.Task.ID, ) } + +func (t *TaskRunner) emailSubject() string { + return t.emailSubjectFor(t.Task.Status) +} + +func (t *TaskRunner) emailSubjectFor(status task_logger.TaskStatus) string { + result := status.Format() + if result == "" { + result = string(status) + } + return fmt.Sprintf("Task '%s' %s", t.Template.Name, result) +} + +func (t *TaskRunner) alertTrigger() string { + if t.Task.ScheduleID != nil { + return "schedule" + } + if t.Task.IntegrationID != nil { + return "integration" + } + if t.Task.UserID != nil { + return "manual" + } + return "api" +} + +func (t *TaskRunner) newAlertPayload(kind string, chatID string, threadID string) Alert { + return t.newAlertPayloadAt(kind, chatID, threadID, t.Task.Status) +} + +func (t *TaskRunner) newAlertPayloadAt(kind string, chatID string, threadID string, status task_logger.TaskStatus) Alert { + author, version := t.alertInfos() + duration := t.alertDuration() + + return Alert{ + Name: t.Template.Name, + Author: author, + Color: alertColorFor(kind, status), + ScheduleName: t.alertScheduleName(), + Task: alertTask{ + ID: strconv.Itoa(t.Task.ID), + URL: t.taskLink(), + Result: status.Format(), + Version: version, + Desc: t.Task.Message, + Duration: duration, + Trigger: t.alertTrigger(), + }, + Chat: alertChat{ + ID: chatID, + ThreadID: threadID, + }, + Playbook: t.Template.Playbook, + Project: alertProjectInfo{ + ID: t.Template.ProjectID, + Name: t.alertProjectName(), + }, + } +} + +func (t *TaskRunner) alertDuration() string { + if t.Task.Start == nil { + return "" + } + end := t.Task.End + if end == nil { + now := tz.Now() + end = &now + } + return end.Sub(*t.Task.Start).String() +} + +func (t *TaskRunner) alertScheduleName() string { + if t.Task.ScheduleID == nil || t.pool == nil || t.pool.store == nil { + return "" + } + schedule, err := t.pool.store.GetSchedule(t.Task.ProjectID, *t.Task.ScheduleID) + if err != nil { + return "" + } + return schedule.Name +} + +func (t *TaskRunner) alertProjectName() string { + if t.pool == nil || t.pool.store == nil { + return "" + } + project, err := t.pool.store.GetProject(t.Template.ProjectID) + if err != nil { + return "" + } + return project.Name +} + +// projectAlertReady reports whether a project alert can be sent. +// Email and Telegram always need the instance SMTP/bot config. +// Webhook destinations must be set on the alert. Gotify may use the +// instance URL/token pair only when both alert fields are empty. +func projectAlertReady(alert db.Alert) error { + if util.Config == nil { + return common_errors.NewValidationError("server config is not loaded") + } + + switch alert.Type { + case db.AlertTypeEmail: + if !util.Config.EmailAlert || util.Config.EmailHost == "" { + return common_errors.NewValidationError("email is not configured on the server") + } + case db.AlertTypeTelegram: + if stringValue(alert.ChatID) == "" { + return common_errors.NewValidationError("telegram chat id can not be empty") + } + if !util.Config.TelegramAlert || util.Config.TelegramToken == "" { + return common_errors.NewValidationError("telegram is not configured on the server") + } + case db.AlertTypeSlack: + return webhookDestinationReady(stringValue(alert.URL), "slack") + case db.AlertTypeTeams: + return webhookDestinationReady(stringValue(alert.URL), "microsoft teams") + case db.AlertTypeRocketChat: + return webhookDestinationReady(stringValue(alert.URL), "rocketchat") + case db.AlertTypeDingTalk: + return webhookDestinationReady(stringValue(alert.URL), "dingtalk") + case db.AlertTypeGotify: + return gotifyDestinationReady(stringValue(alert.URL), stringValue(alert.Token)) + default: + return common_errors.NewValidationError("invalid alert type") + } + return nil +} + +func webhookDestinationReady(alertURL, channel string) error { + if alertURL != "" { + return nil + } + return common_errors.NewValidationError(channel + " webhook URL is missing") +} + +func gotifyDestinationReady(alertURL, alertToken string) error { + if alertURL != "" { + if alertToken == "" { + return common_errors.NewValidationError("gotify token is missing for the alert URL") + } + return db.ValidateGotifyURL(alertURL) + } + if alertToken != "" { + if util.Config.GotifyUrl == "" { + return common_errors.NewValidationError("gotify URL is missing for the alert token") + } + return db.ValidateGotifyURL(util.Config.GotifyUrl) + } + if !util.Config.GotifyAlert || util.Config.GotifyUrl == "" || util.Config.GotifyToken == "" { + return common_errors.NewValidationError("gotify is not configured on the server") + } + return db.ValidateGotifyURL(util.Config.GotifyUrl) +} + +func resolveGotifyDestination(alert db.Alert) (url string, token string, err error) { + url = stringValue(alert.URL) + token = stringValue(alert.Token) + if url != "" { + if token == "" { + return "", "", common_errors.NewValidationError("gotify token is missing for the alert URL") + } + return url, token, db.ValidateGotifyURL(url) + } + url = util.Config.GotifyUrl + if token == "" { + token = util.Config.GotifyToken + } + if url == "" || token == "" { + return "", "", common_errors.NewValidationError("gotify URL or token is missing") + } + return url, token, db.ValidateGotifyURL(url) +} + +func (t *TaskRunner) sendProjectAlert(alert db.Alert) error { + return t.sendProjectAlertForStatus(alert, t.Task.Status) +} + +func (t *TaskRunner) sendProjectAlertForStatus(alert db.Alert, status task_logger.TaskStatus) error { + if err := projectAlertReady(alert); err != nil { + return err + } + + chatID := stringValue(alert.ChatID) + payload := t.newAlertPayloadAt(string(alert.Type), chatID, stringValue(alert.ThreadID), status) + body, err := t.renderAlertBody(alert, payload) + if err != nil { + t.Logf("Can't render alert %s: %s", alert.Name, err.Error()) + return err + } + if body == "" { + t.Logf("Buffer for alert %s is empty", alert.Name) + return common_errors.NewValidationError("alert message is empty") + } + + switch alert.Type { + case db.AlertTypeEmail: + return t.sendProjectMailAlert(alert, body, status) + case db.AlertTypeTelegram: + wrapped, wrapErr := wrapTelegramMessage(chatID, stringValue(alert.ThreadID), body) + if wrapErr != nil { + return wrapErr + } + return t.postAlertJSON( + alert.Name, + fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", util.Config.TelegramToken), + wrapped, + []int{200}, + ) + case db.AlertTypeSlack: + return t.postAlertJSON(alert.Name, stringValue(alert.URL), body, []int{200}) + case db.AlertTypeRocketChat: + return t.postAlertJSON(alert.Name, stringValue(alert.URL), body, []int{200}) + case db.AlertTypeTeams: + return t.postAlertJSON(alert.Name, stringValue(alert.URL), body, []int{200, 202}) + case db.AlertTypeDingTalk: + return t.postAlertJSON(alert.Name, stringValue(alert.URL), body, []int{200}) + case db.AlertTypeGotify: + url, token, destErr := resolveGotifyDestination(alert) + if destErr != nil { + return destErr + } + return t.postAlertJSONWithHeaders( + alert.Name, + strings.TrimRight(url, "/")+"/message", + body, + []int{200}, + map[string]string{"X-Gotify-Key": token}, + ) + default: + return common_errors.NewValidationError("invalid alert type") + } +} + +func (t *TaskRunner) renderAlertBody(alert db.Alert, payload Alert) (string, error) { + if alert.Body != nil && strings.TrimSpace(*alert.Body) != "" { + if alert.Type == db.AlertTypeEmail { + tpl, err := htmltemplate.New("alert").Parse(*alert.Body) + if err != nil { + return "", err + } + var buf bytes.Buffer + if err = tpl.Execute(&buf, payload); err != nil { + return "", err + } + return buf.String(), nil + } + tpl, err := template.New("alert").Parse(*alert.Body) + if err != nil { + return "", err + } + var buf bytes.Buffer + if err = tpl.Execute(&buf, payload); err != nil { + return "", err + } + return buf.String(), nil + } + + tmplName := builtinAlertTemplate(alert.Type) + if alert.Type == db.AlertTypeEmail { + tpl, err := htmltemplate.ParseFS(templates, tmplName) + if err != nil { + return "", err + } + var buf bytes.Buffer + if err = tpl.Execute(&buf, payload); err != nil { + return "", err + } + return buf.String(), nil + } + + tpl, err := template.ParseFS(templates, tmplName) + if err != nil { + return "", err + } + var buf bytes.Buffer + if err = tpl.Execute(&buf, payload); err != nil { + return "", err + } + return buf.String(), nil +} + +// DefaultAlertBodies returns the built-in message template for each channel +// so the UI can show the same text the sender uses when body is empty. +func DefaultAlertBodies() (map[db.AlertType]string, error) { + types := []db.AlertType{ + db.AlertTypeEmail, + db.AlertTypeTelegram, + db.AlertTypeSlack, + db.AlertTypeTeams, + db.AlertTypeRocketChat, + db.AlertTypeDingTalk, + db.AlertTypeGotify, + } + out := make(map[db.AlertType]string, len(types)) + for _, alertType := range types { + name := builtinAlertTemplate(alertType) + body, err := templates.ReadFile(name) + if err != nil { + return nil, err + } + out[alertType] = string(body) + } + return out, nil +} + +func wrapTelegramMessage(chatID, threadID, text string) (string, error) { + msg := map[string]any{ + "chat_id": chatID, + "parse_mode": "HTML", + "text": text, + } + if err := db.ValidateTelegramThreadID(threadID); err != nil { + return "", err + } + if n, err := strconv.Atoi(strings.TrimSpace(threadID)); err == nil { + msg["message_thread_id"] = n + } + b, err := json.Marshal(msg) + if err != nil { + return "", err + } + return string(b), nil +} + +func builtinAlertTemplate(alertType db.AlertType) string { + switch alertType { + case db.AlertTypeEmail: + return "templates/email.tmpl" + case db.AlertTypeTelegram: + return "templates/telegram.tmpl" + case db.AlertTypeSlack: + return "templates/slack.tmpl" + case db.AlertTypeTeams: + return "templates/microsoft-teams.tmpl" + case db.AlertTypeRocketChat: + return "templates/rocketchat.tmpl" + case db.AlertTypeDingTalk: + return "templates/dingtalk.tmpl" + case db.AlertTypeGotify: + return "templates/gotify.tmpl" + default: + return "" + } +} + +func (t *TaskRunner) sendProjectMailAlert(alert db.Alert, body string, status task_logger.TaskStatus) error { + recipients := t.mailRecipients(alert) + if len(recipients) == 0 { + return common_errors.NewValidationError("no email recipients") + } + var firstErr error + for _, email := range recipients { + t.Logf("Attempting to send email alert to %s", email) + if err := mailer.Send( + util.Config.EmailSecure, + util.Config.EmailTls, + util.Config.EmailHost, + util.Config.EmailPort, + util.Config.EmailUsername, + util.Config.EmailPassword, + util.Config.EmailSender, + email, + t.emailSubjectFor(status), + body, + ); err != nil { + util.LogError(err) + if firstErr == nil { + firstErr = common_errors.NewUserError(err) + } + continue + } + t.Logf("Sent successfully email alert to %s", email) + } + return firstErr +} + +func (t *TaskRunner) mailRecipients(alert db.Alert) []string { + if alert.Recipients != nil && strings.TrimSpace(*alert.Recipients) != "" { + parts := strings.Split(*alert.Recipients, ",") + var out []string + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out + } + + var emails []string + for _, uid := range t.users { + if t.pool == nil || t.pool.store == nil { + continue + } + user, err := t.pool.store.GetUser(uid) + if err != nil || !user.Alert || user.Email == "" { + continue + } + emails = append(emails, user.Email) + } + return emails +} + +func alertHTTPClient() *http.Client { + return &http.Client{ + Timeout: 15 * time.Second, + Transport: &http.Transport{ + Proxy: nil, + DialContext: alertDialContext, + ForceAttemptHTTP2: true, + TLSHandshakeTimeout: 10 * time.Second, + IdleConnTimeout: 30 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + }, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + +func alertDialContext(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host) + if err != nil { + return nil, err + } + allowed := db.AllowedAlertIPs(ips) + if len(allowed) == 0 { + return nil, common_errors.NewValidationError("alert URL host is not allowed") + } + dialer := net.Dialer{} + var lastErr error + for _, ip := range allowed { + conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) + if err == nil { + return conn, nil + } + lastErr = err + } + return nil, lastErr +} + +func (t *TaskRunner) postAlertJSON(name, url, body string, okCodes []int) error { + return t.postAlertJSONWithHeaders(name, url, body, okCodes, nil) +} + +func (t *TaskRunner) postAlertJSONWithHeaders(name, url, body string, okCodes []int, headers map[string]string) error { + if url == "" { + t.Logf("Can't send alert %s: empty URL", name) + return common_errors.NewValidationError("alert URL is empty") + } + if err := db.ValidateAlertURL(url); err != nil { + t.Logf("Can't send alert %s: %s", name, err.Error()) + return err + } + + t.Logf("Attempting to send alert %s", name) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(body)) + if err != nil { + t.Logf("Can't send alert %s: %s", name, err.Error()) + return err + } + req.Header.Set("Content-Type", "application/json") + for key, value := range headers { + req.Header.Set(key, value) + } + + resp, err := alertHTTPClient().Do(req) + if err != nil { + t.Logf("Can't send alert %s: %s", name, err.Error()) + return common_errors.NewUserError(err) + } + defer resp.Body.Close() //nolint:errcheck + _, _ = io.Copy(io.Discard, resp.Body) + + for _, code := range okCodes { + if resp.StatusCode == code { + t.Logf("Sent successfully alert %s", name) + return nil + } + } + t.Logf("Can't send alert %s! Response code: %d", name, resp.StatusCode) + return common_errors.NewUserErrorS(fmt.Sprintf("alert %s: unexpected response code %d", name, resp.StatusCode)) +} + +func stringValue(s *string) string { + if s == nil { + return "" + } + return strings.TrimSpace(*s) +} diff --git a/services/tasks/alert_resolve.go b/services/tasks/alert_resolve.go new file mode 100644 index 0000000000..87dbe4e97c --- /dev/null +++ b/services/tasks/alert_resolve.go @@ -0,0 +1,60 @@ +package tasks + +import "github.com/semaphoreui/semaphore/db" + +func ResolveAlerts(template db.Template, schedule *db.Schedule, defaultIDs []int) db.AlertSnapshot { + onSuccess := db.ResolveAlertOn(template.AlertOnSuccess, template.SuppressSuccessAlerts) + onError := db.ResolveAlertOn(template.AlertOnError, template.SuppressErrorAlerts) + + mode := template.AlertMode + if mode == "" && len(template.AlertIDs) > 0 { + mode = db.AlertModeIDs + } + + var ids []int + if mode == db.AlertModeIDs { + ids = append([]int(nil), template.AlertIDs...) + } else { + ids = append([]int(nil), defaultIDs...) + } + + if schedule != nil && schedule.AlertMode == db.AlertModeIDs { + ids = append([]int(nil), schedule.AlertIDs...) + if schedule.AlertOnSuccess != nil { + onSuccess = *schedule.AlertOnSuccess + } + if schedule.AlertOnError != nil { + onError = *schedule.AlertOnError + } + } + + if ids == nil { + ids = []int{} + } + + return db.AlertSnapshot{ + AlertIDs: ids, + OnSuccess: onSuccess, + OnError: onError, + } +} + +func SnapshotTaskAlerts(store db.Store, task *db.Task, template db.Template) error { + var schedule *db.Schedule + if task.ScheduleID != nil { + s, err := store.GetSchedule(task.ProjectID, *task.ScheduleID) + if err != nil { + return err + } + schedule = &s + } + + defaultIDs, err := store.GetDefaultAlertIDs(task.ProjectID) + if err != nil { + return err + } + + snap := ResolveAlerts(template, schedule, defaultIDs) + task.AlertSnapshot = &snap + return nil +} diff --git a/services/tasks/alert_resolve_test.go b/services/tasks/alert_resolve_test.go new file mode 100644 index 0000000000..99f1151e2c --- /dev/null +++ b/services/tasks/alert_resolve_test.go @@ -0,0 +1,91 @@ +package tasks + +import ( + "testing" + + "github.com/semaphoreui/semaphore/db" + "github.com/semaphoreui/semaphore/pkg/task_logger" + "github.com/stretchr/testify/assert" +) + +func TestResolveAlerts_EmptyTemplateUsesDefaults(t *testing.T) { + snap := ResolveAlerts(db.Template{}, nil, []int{4, 5}) + assert.Equal(t, []int{4, 5}, snap.AlertIDs) + assert.True(t, snap.OnSuccess) + assert.True(t, snap.OnError) +} + +func TestResolveAlerts_TemplateIDs(t *testing.T) { + snap := ResolveAlerts(db.Template{ + AlertMode: db.AlertModeIDs, + AlertIDs: []int{3, 7}, + AlertOnSuccess: db.BoolPtr(false), + AlertOnError: db.BoolPtr(true), + SuppressSuccessAlerts: true, + }, nil, []int{9}) + assert.Equal(t, []int{3, 7}, snap.AlertIDs) + assert.False(t, snap.OnSuccess) + assert.True(t, snap.OnError) +} + +func TestResolveAlerts_LegacySuppress(t *testing.T) { + snap := ResolveAlerts(db.Template{ + SuppressSuccessAlerts: true, + SuppressErrorAlerts: false, + }, nil, nil) + assert.False(t, snap.OnSuccess) + assert.True(t, snap.OnError) +} + +func TestResolveAlerts_ScheduleInheritKeepsDefaults(t *testing.T) { + tpl := db.Template{AlertOnError: db.BoolPtr(true)} + schedule := &db.Schedule{AlertMode: db.AlertModeInherit, AlertIDs: []int{9}} + snap := ResolveAlerts(tpl, schedule, []int{1, 2}) + assert.Equal(t, []int{1, 2}, snap.AlertIDs) +} + +func TestResolveAlerts_ScheduleOverride(t *testing.T) { + tpl := db.Template{ + AlertIDs: []int{1, 2}, + AlertOnSuccess: db.BoolPtr(true), + AlertOnError: db.BoolPtr(true), + } + schedule := &db.Schedule{ + AlertMode: db.AlertModeIDs, + AlertIDs: []int{5}, + AlertOnSuccess: db.BoolPtr(false), + } + snap := ResolveAlerts(tpl, schedule, []int{1, 2}) + assert.Equal(t, []int{5}, snap.AlertIDs) + assert.False(t, snap.OnSuccess) + assert.True(t, snap.OnError) +} + +func TestResolveAlerts_ScheduleEmptyStops(t *testing.T) { + tpl := db.Template{AlertIDs: []int{1}} + schedule := &db.Schedule{AlertMode: db.AlertModeIDs, AlertIDs: []int{}} + snap := ResolveAlerts(tpl, schedule, []int{1}) + assert.Empty(t, snap.AlertIDs) +} + +func TestResolveAlerts_CustomEmptyIsSilent(t *testing.T) { + snap := ResolveAlerts(db.Template{AlertMode: db.AlertModeIDs, AlertIDs: []int{}}, nil, []int{8}) + assert.Empty(t, snap.AlertIDs) +} + +func TestShouldSkipStatusAlert_Snapshot(t *testing.T) { + runner := &TaskRunner{ + Task: db.Task{ + AlertSnapshot: &db.AlertSnapshot{ + OnSuccess: false, + OnError: true, + }, + }, + } + runner.Task.Status = task_logger.TaskSuccessStatus + assert.True(t, runner.shouldSkipStatusAlert()) + runner.Task.Status = task_logger.TaskFailStatus + assert.False(t, runner.shouldSkipStatusAlert()) + runner.Task.Status = task_logger.TaskWaitingConfirmation + assert.True(t, runner.shouldSkipStatusAlert()) +} diff --git a/services/tasks/alert_test.go b/services/tasks/alert_test.go index 42043d38ed..20b967ba6d 100644 --- a/services/tasks/alert_test.go +++ b/services/tasks/alert_test.go @@ -5,7 +5,9 @@ import ( "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/task_logger" + "github.com/semaphoreui/semaphore/util" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestShouldSkipStatusAlert(t *testing.T) { @@ -69,7 +71,7 @@ func TestShouldSkipStatusAlert(t *testing.T) { status: task_logger.TaskWaitingConfirmation, suppressSuccessAlerts: true, suppressErrorAlerts: true, - expected: false, + expected: true, }, } @@ -89,3 +91,144 @@ func TestShouldSkipStatusAlert(t *testing.T) { }) } } + +func TestProjectAlertReady(t *testing.T) { + orig := util.Config + t.Cleanup(func() { util.Config = orig }) + util.Config = &util.ConfigType{} + + hook := "https://hooks.example.com/x" + token := "gotify-token" + + assert.NoError(t, projectAlertReady(db.Alert{ + Name: "slack", + Type: db.AlertTypeSlack, + URL: &hook, + })) + + assert.Error(t, projectAlertReady(db.Alert{Name: "slack", Type: db.AlertTypeSlack})) + + util.Config.SlackAlert = true + util.Config.SlackUrl = hook + assert.Error(t, projectAlertReady(db.Alert{Name: "slack", Type: db.AlertTypeSlack})) + assert.NoError(t, projectAlertReady(db.Alert{Name: "slack", Type: db.AlertTypeSlack, URL: &hook})) + + chatID := "12345" + assert.Error(t, projectAlertReady(db.Alert{Name: "tg", Type: db.AlertTypeTelegram})) + util.Config.TelegramAlert = true + util.Config.TelegramToken = "bot" + assert.Error(t, projectAlertReady(db.Alert{Name: "tg", Type: db.AlertTypeTelegram})) + assert.NoError(t, projectAlertReady(db.Alert{Name: "tg", Type: db.AlertTypeTelegram, ChatID: &chatID})) + + assert.Error(t, projectAlertReady(db.Alert{Name: "mail", Type: db.AlertTypeEmail})) + util.Config.EmailAlert = true + util.Config.EmailHost = "smtp.example.com" + assert.NoError(t, projectAlertReady(db.Alert{Name: "mail", Type: db.AlertTypeEmail})) + + assert.NoError(t, projectAlertReady(db.Alert{ + Name: "gotify", + Type: db.AlertTypeGotify, + URL: &hook, + Token: &token, + })) + assert.Error(t, projectAlertReady(db.Alert{Name: "gotify", Type: db.AlertTypeGotify})) + assert.Error(t, projectAlertReady(db.Alert{ + Name: "gotify-url-only", + Type: db.AlertTypeGotify, + URL: &hook, + })) + + httpHook := "http://gotify.example.com" + assert.NoError(t, projectAlertReady(db.Alert{ + Name: "gotify-http", + Type: db.AlertTypeGotify, + URL: &httpHook, + Token: &token, + })) + + util.Config.GotifyAlert = true + util.Config.GotifyUrl = hook + util.Config.GotifyToken = token + assert.NoError(t, projectAlertReady(db.Alert{Name: "gotify-instance", Type: db.AlertTypeGotify})) + assert.NoError(t, projectAlertReady(db.Alert{ + Name: "gotify-custom-token", + Type: db.AlertTypeGotify, + Token: &token, + })) + + otherToken := "other-token" + assert.Error(t, projectAlertReady(db.Alert{ + Name: "gotify-custom-url-instance-token", + Type: db.AlertTypeGotify, + URL: &hook, + Token: nil, + })) + assert.NoError(t, projectAlertReady(db.Alert{ + Name: "gotify-custom-pair", + Type: db.AlertTypeGotify, + URL: &hook, + Token: &otherToken, + })) +} + +func TestSendStatusAlerts_NilSnapshotDoesNothing(t *testing.T) { + runner := &TaskRunner{ + Task: db.Task{Status: task_logger.TaskSuccessStatus}, + } + assert.NotPanics(t, func() { + runner.sendStatusAlerts() + }) +} + +func TestNewAlertPayload_ScheduleName(t *testing.T) { + runner := &TaskRunner{ + Task: db.Task{ + Status: task_logger.TaskSuccessStatus, + Message: "done", + }, + Template: db.Template{Name: "Nightly", Playbook: "site.yml"}, + } + payload := runner.newAlertPayload("telegram", "1", "") + assert.Empty(t, payload.ScheduleName) + + id := 9 + runner.Task.ScheduleID = &id + payload = runner.newAlertPayload("telegram", "1", "") + assert.Empty(t, payload.ScheduleName) +} + +func TestDefaultAlertBodies(t *testing.T) { + bodies, err := DefaultAlertBodies() + require.NoError(t, err) + assert.Contains(t, bodies[db.AlertTypeTelegram], "{{ .Task.ID }}") + assert.NotContains(t, bodies[db.AlertTypeTelegram], "chat_id") + assert.NotContains(t, bodies[db.AlertTypeTelegram], "message_thread_id") + assert.Contains(t, bodies[db.AlertTypeGotify], `"title"`) + assert.Contains(t, bodies[db.AlertTypeEmail], "Task Log") + assert.NotEqual(t, bodies[db.AlertTypeTelegram], bodies[db.AlertTypeGotify]) +} + +func TestWrapTelegramMessage(t *testing.T) { + body, err := wrapTelegramMessage("123", "9", "hello world") + require.NoError(t, err) + assert.JSONEq(t, `{ + "chat_id": "123", + "parse_mode": "HTML", + "text": "hello world", + "message_thread_id": 9 + }`, body) + + noThread, err := wrapTelegramMessage("-1001", "", "ping") + require.NoError(t, err) + assert.NotContains(t, noThread, "message_thread_id") + assert.Contains(t, noThread, `"chat_id":"-1001"`) + + _, err = wrapTelegramMessage("123", "not-a-number", "ping") + require.Error(t, err) + + _, err = wrapTelegramMessage("123", "0", "ping") + require.Error(t, err) + + _, err = wrapTelegramMessage("123", "-1", "ping") + require.Error(t, err) +} diff --git a/services/tasks/alert_test_sender.go b/services/tasks/alert_test_sender.go index 304e2ac717..ca21e0e596 100644 --- a/services/tasks/alert_test_sender.go +++ b/services/tasks/alert_test_sender.go @@ -5,12 +5,10 @@ import ( "github.com/semaphoreui/semaphore/pkg/task_logger" ) -// SendProjectTestAlerts sends test alerts to all enabled notifiers for the given project. -func SendProjectTestAlerts(project db.Project, store db.Store) (err error) { - +func testRunner(project db.Project, store db.Store) (*TaskRunner, error) { projectUsers, err := store.GetProjectUsers(project.ID, db.RetrieveQueryParams{}) if err != nil { - return + return nil, err } var userIDs []int @@ -18,7 +16,16 @@ func SendProjectTestAlerts(project db.Project, store db.Store) (err error) { userIDs = append(userIDs, u.ID) } - tr := &TaskRunner{ + pool := &TaskPool{ + logger: make(chan logRecord, 100), + store: store, + } + go func() { + for range pool.logger { + } + }() + + return &TaskRunner{ Task: db.Task{ ProjectID: project.ID, TemplateID: 0, @@ -34,19 +41,48 @@ func SendProjectTestAlerts(project db.Project, store db.Store) (err error) { users: userIDs, alert: project.Alert, alertChat: project.AlertChat, - pool: &TaskPool{ - logger: make(chan logRecord, 100), - store: store, - }, + pool: pool, + }, nil +} + +func closeTestRunner(tr *TaskRunner) { + if tr == nil || tr.pool == nil || tr.pool.logger == nil { + return } + close(tr.pool.logger) +} - tr.sendTelegramAlert() - tr.sendSlackAlert() - tr.sendRocketChatAlert() - tr.sendMicrosoftTeamsAlert() - tr.sendDingTalkAlert() - tr.sendGotifyAlert() - tr.sendMailAlert() +// SendProjectTestAlerts sends test alerts to all enabled notifiers for the given project. +func SendProjectTestAlerts(project db.Project, store db.Store) error { + tr, err := testRunner(project, store) + if err != nil { + return err + } + defer closeTestRunner(tr) + + alerts, err := store.GetAlerts(project.ID, db.RetrieveQueryParams{}) + if err != nil { + return err + } - return + var firstErr error + for _, alert := range alerts { + if !alert.Enabled { + continue + } + if err := tr.sendProjectAlert(alert); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +// SendAlertTest sends a single project alert as a test message. +func SendAlertTest(project db.Project, alert db.Alert, store db.Store) error { + tr, err := testRunner(project, store) + if err != nil { + return err + } + defer closeTestRunner(tr) + return tr.sendProjectAlert(alert) } diff --git a/services/tasks/templates/email.tmpl b/services/tasks/templates/email.tmpl index c70c3f2dc9..de2ff01438 100644 --- a/services/tasks/templates/email.tmpl +++ b/services/tasks/templates/email.tmpl @@ -1,2 +1,2 @@ -

Task {{ .Task.ID }} with template '{{ .Name }}' has failed!

+

Task {{ .Task.ID }} with template '{{ .Name }}' {{ .Task.Result }}.

Task Log: Link

diff --git a/services/tasks/templates/telegram.tmpl b/services/tasks/templates/telegram.tmpl index 33d50d9017..5651cfa9fe 100644 --- a/services/tasks/templates/telegram.tmpl +++ b/services/tasks/templates/telegram.tmpl @@ -1,5 +1,4 @@ -{ - "chat_id": "{{ .Chat.ID }}", - "parse_mode": "HTML", - "text": "{{ .Name }}\n#{{ .Task.ID }} {{ .Task.Result }} {{ .Task.Version }} - {{ .Task.Desc }}\nby {{ .Author }}\n{{ .Task.URL }}" -} +{{ .Name }} +#{{ .Task.ID }} {{ .Task.Result }} {{ .Task.Version }} - {{ .Task.Desc }} +by {{ .Author }} +{{ .Task.URL }} diff --git a/web/src/App.vue b/web/src/App.vue index c3862998d1..51ffaa3d32 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -1166,6 +1166,13 @@ export default { to: `${base}/integrations`, testId: 'sidebar-integrations', }, + { + key: 'alerts', + icon: 'mdi-bell-outline', + title: this.$t('alerting'), + to: `${base}/alerts`, + testId: 'sidebar-alerts', + }, ); } diff --git a/web/src/components/AlertForm.vue b/web/src/components/AlertForm.vue new file mode 100644 index 0000000000..bb830062da --- /dev/null +++ b/web/src/components/AlertForm.vue @@ -0,0 +1,263 @@ + + + diff --git a/web/src/components/ObjectRefsView.vue b/web/src/components/ObjectRefsView.vue index 06eb8b2943..4f3e09e3d8 100644 --- a/web/src/components/ObjectRefsView.vue +++ b/web/src/components/ObjectRefsView.vue @@ -72,6 +72,8 @@ export default { icon: 'key-change', }, { slug: 'schedules', + path: 'schedule', + pageless: true, title: 'Schedules', icon: 'clock-outline', }].filter((s) => (this.objectRefs[s.slug] || []).length > 0); diff --git a/web/src/components/ProjectForm.vue b/web/src/components/ProjectForm.vue index e61fc4b1ec..90217bdadc 100644 --- a/web/src/components/ProjectForm.vue +++ b/web/src/components/ProjectForm.vue @@ -28,22 +28,6 @@ dense > - - - - + + + + + + + + + + +
+ + + + + +
{{ $t('templateAlertsDefaultHint') }}
+ + + + + + @@ -460,18 +506,6 @@ /> - - - -
@@ -760,6 +794,7 @@ export default { inventory: null, repositories: null, environment: null, + alerts: [], views: null, schedules: null, buildTemplates: null, @@ -1091,6 +1126,10 @@ export default { task_params: {}, jwt_params: { enabled: false, audience: [], ttl: '' }, environment_ids: [], + alert_mode: 'default', + alert_ids: [], + alert_on_success: true, + alert_on_error: true, }; }, @@ -1106,6 +1145,7 @@ export default { this.schedules, this.views, this.environment, + this.alerts, templates, this.runnerTags, ] = await Promise.all([ @@ -1115,6 +1155,7 @@ export default { this.isNew ? [] : this.loadProjectEndpoint(`/templates/${this.itemId}/schedules`), this.loadProjectResources('views'), this.loadProjectResources('environment'), + this.loadProjectResources('alerts'), this.loadProjectResources('templates'), this.loadProjectResources('runner_tags'), ]); @@ -1228,6 +1269,19 @@ export default { this.$set(this.item, 'environment_ids', []); } + if (!this.item.alert_mode) { + this.$set(this.item, 'alert_mode', this.item.alert_ids && this.item.alert_ids.length ? 'ids' : 'default'); + } + if (!Array.isArray(this.item.alert_ids)) { + this.$set(this.item, 'alert_ids', []); + } + if (this.item.alert_on_success == null) { + this.$set(this.item, 'alert_on_success', !this.item.suppress_success_alerts); + } + if (this.item.alert_on_error == null) { + this.$set(this.item, 'alert_on_error', !this.item.suppress_error_alerts); + } + this.args = JSON.parse(this.item.arguments || '[]'); await this.loadRelativeData(); @@ -1253,6 +1307,10 @@ export default { }, async beforeSave() { + if (this.item.alert_mode !== 'ids') { + this.item.alert_ids = []; + } + if (this.cronFormat == null || this.cronFormat === '') { return; } diff --git a/web/src/lang/cs.js b/web/src/lang/cs.js index 89270eb2f1..5331fb43cf 100644 --- a/web/src/lang/cs.js +++ b/web/src/lang/cs.js @@ -111,6 +111,8 @@ export default { projectName: 'Název projektu', allowAlertsForThisProject: 'Povolit upozornění pro tento projekt', telegramChatIdOptional: 'ID chatu Telegramu (volitelné)', + telegramThreadId: 'ID vlákna Telegramu (volitelné)', + telegramThreadIdHint: 'Volitelné ID tématu fóra pro tento cíl Telegramu.', maxNumberOfParallelTasksOptional: 'Maximální počet paralelních úloh (volitelné)', deleteRepository: 'Odstranit repozitář', newRepository: 'Nový repozitář', @@ -259,6 +261,7 @@ export default { isRequired: 'je povinné', mustBeInteger: 'Musí být celé číslo', mustBe0OrGreater: 'Musí být 0 nebo více', + mustBe1OrGreater: 'Musí být 1 nebo více', start_version_required: 'Počáteční verze je povinná', playbook_filename_required: 'Název souboru playbooku je povinný', inventory_required: 'Inventář je povinný', diff --git a/web/src/lang/de.js b/web/src/lang/de.js index 0f2234fc9b..e18395b7fd 100644 --- a/web/src/lang/de.js +++ b/web/src/lang/de.js @@ -109,6 +109,8 @@ export default { projectName: 'Projektname', allowAlertsForThisProject: 'Benachrichtigungen für dieses Projekt erlauben', telegramChatIdOptional: 'Telegram Chat ID (optional)', + telegramThreadId: 'Telegram Thread-ID (optional)', + telegramThreadIdHint: 'Optionale Forum-Topic-ID für dieses Telegram-Ziel.', maxNumberOfParallelTasksOptional: 'Max. Anzahl paralleler Tasks (optional)', deleteRepository: 'Repository löschen', newRepository: 'Neues Repository', @@ -264,6 +266,7 @@ export default { isRequired: 'ist erforderlich', mustBeInteger: 'Muss eine ganze Zahl sein', mustBe0OrGreater: 'Muss >= 0 sein', + mustBe1OrGreater: 'Muss >= 1 sein', start_version_required: 'Startversion ist erforderlich', playbook_filename_required: 'Playbook-Dateiname ist erforderlich', inventory_required: 'Inventory ist erforderlich', diff --git a/web/src/lang/en.js b/web/src/lang/en.js index 6cd9b60cc7..213a0b7f63 100644 --- a/web/src/lang/en.js +++ b/web/src/lang/en.js @@ -122,6 +122,7 @@ export default { "The {objectTitle} can't be deleted because it used by the resources below", projectName: 'Project Name', allowAlertsForThisProject: 'Allow alerts for this project', + telegramChatId: 'Telegram Chat ID', telegramChatIdOptional: 'Telegram Chat ID (Optional)', maxNumberOfParallelTasksOptional: 'Max number of parallel tasks (Optional)', deleteRepository: 'Delete repository', @@ -234,6 +235,41 @@ export default { toLearnMoreAboutCron: 'to learn more about Cron.', suppressSuccessAlerts: 'Suppress success notifications', suppressErrorAlerts: 'Suppress error notifications', + alerting: 'Alerting', + alertingPageHint: 'Each alert is a channel plus a destination plus a message. Mark alerts as project defaults so new jobs send them automatically. A job can instead pick a custom list (empty means silent). A schedule can inherit that choice or use its own. SMTP and Telegram bot tokens stay in the server config.', + testAlerts: 'Test Alerts', + alertTokenHint: 'Leave empty to keep the current token.', + alertTestAllSent: 'Test notification sent.', + alertTestAllMissing: 'Create at least one enabled alert first.', + alert: 'Alert', + alerts: 'Alerts', + alertsHint: 'Used when this job has a custom alert list. Nothing selected means this job sends no alerts.', + alertIsDefault: 'Project default', + alertIsDefaultHint: 'Jobs that use project defaults send this destination.', + templateAlertsDefault: 'Use project default alerts', + templateAlertsCustom: 'Use a custom set of alerts', + templateAlertsDefaultHint: 'New jobs use the alerts marked as project defaults. Existing jobs keep their own setting after upgrade.', + newAlert: 'New Alert', + editAlert: 'Edit Alert', + deleteAlert: 'Delete alert', + askDeleteAlert: 'Are you sure you want to delete this alert?', + cloneAlert: 'Clone', + testAlert: 'Test', + alertTestSent: 'Test alert sent', + alertOnSuccess: 'Send on success', + alertOnError: 'Send on error', + alertWebhookUrl: 'Webhook URL', + alertToken: 'Token', + alertRecipients: 'Recipients', + alertRecipientsHint: 'Comma-separated emails. Leave empty to notify project users who have alerts enabled.', + alertMessageBody: 'Message template', + alertResetDefaultBody: 'Reset to default', + telegramThreadId: 'Telegram Thread ID (Optional)', + telegramThreadIdHint: 'Optional forum topic ID for this Telegram destination.', + scheduleAlerts: 'Alerts', + scheduleAlertsInherit: 'Use alerts from the job template', + scheduleAlertsCustom: 'Use a different set of alerts', + scheduleAlertsHint: 'An empty list means this schedule sends no alerts.', cliArgsJsonArrayExampleIMyinventoryshPrivatekeythe2: 'CLI Args (JSON array). Example: [ "-i", "@myinventory.sh", "--private-key=/there/id_rsa", "-vvvv" ]', allowCliArgsInTask: 'CLI args', @@ -259,7 +295,6 @@ export default { status: 'Status', start: 'Start', actions: 'Actions', - alert: 'Alert', admin: 'Admin', role: 'Role', external: 'External', @@ -289,6 +324,7 @@ export default { isRequired: 'is required', mustBeInteger: 'Must be integer', mustBe0OrGreater: 'Must be 0 or greater', + mustBe1OrGreater: 'Must be 1 or greater', start_version_required: 'Start version is required', playbook_filename_required: 'Playbook filename is required', working_directory_required: 'Working directory is required', diff --git a/web/src/lang/es.js b/web/src/lang/es.js index f6843a18e2..a70f03d17f 100644 --- a/web/src/lang/es.js +++ b/web/src/lang/es.js @@ -105,6 +105,8 @@ export default { projectName: 'Nombre del Proyecto', allowAlertsForThisProject: 'Permitir alertas para este proyecto', telegramChatIdOptional: 'ID de Chat de Telegram (Opcional)', + telegramThreadId: 'ID de hilo de Telegram (Opcional)', + telegramThreadIdHint: 'ID opcional del tema del foro para este destino de Telegram.', maxNumberOfParallelTasksOptional: 'Número máximo de tareas paralelas (Opcional)', deleteRepository: 'Eliminar repositorio', newRepository: 'Nuevo Repositorio', @@ -255,6 +257,7 @@ export default { isRequired: 'es requerido', mustBeInteger: 'Debe ser un número entero', mustBe0OrGreater: 'Debe ser 0 o mayor', + mustBe1OrGreater: 'Debe ser 1 o mayor', start_version_required: 'Se requiere versión inicial', playbook_filename_required: 'Se requiere nombre de archivo de playbook', inventory_required: 'Se requiere inventario', diff --git a/web/src/lang/fr.js b/web/src/lang/fr.js index a2c3601881..7f06b457a3 100644 --- a/web/src/lang/fr.js +++ b/web/src/lang/fr.js @@ -105,6 +105,8 @@ export default { projectName: 'Nom du projet', allowAlertsForThisProject: 'Autoriser les alertes pour ce projet', telegramChatIdOptional: 'ID de chat Telegram (optionnel)', + telegramThreadId: 'ID de fil Telegram (optionnel)', + telegramThreadIdHint: 'ID de sujet de forum optionnel pour cette destination Telegram.', maxNumberOfParallelTasksOptional: 'Nombre maximum de tâches parallèles (optionnel)', deleteRepository: 'Supprimer le dépôt', newRepository: 'Nouveau dépôt', @@ -255,6 +257,7 @@ export default { isRequired: 'est requis', mustBeInteger: 'Doit être un entier', mustBe0OrGreater: 'Doit être 0 ou plus', + mustBe1OrGreater: 'Doit être 1 ou plus', start_version_required: 'La version de départ est requise', playbook_filename_required: 'Le nom du fichier playbook est requis', inventory_required: 'L\'inventaire est requis', diff --git a/web/src/lang/it.js b/web/src/lang/it.js index 9f57398834..1950e42d12 100644 --- a/web/src/lang/it.js +++ b/web/src/lang/it.js @@ -105,6 +105,8 @@ export default { projectName: 'Nome progetto', allowAlertsForThisProject: 'Consenti avvisi per questo progetto', telegramChatIdOptional: 'ID chat Telegram (Opzionale)', + telegramThreadId: 'ID thread Telegram (Opzionale)', + telegramThreadIdHint: 'ID argomento forum opzionale per questa destinazione Telegram.', maxNumberOfParallelTasksOptional: 'Numero massimo di compiti paralleli (Opzionale)', deleteRepository: 'Elimina repository', newRepository: 'Nuovo repository', @@ -255,6 +257,7 @@ export default { isRequired: 'è obbligatorio', mustBeInteger: 'Deve essere un intero', mustBe0OrGreater: 'Deve essere 0 o maggiore', + mustBe1OrGreater: 'Deve essere 1 o maggiore', start_version_required: 'La versione di partenza è obbligatoria', playbook_filename_required: 'Il nome del file playbook è obbligatorio', inventory_required: 'L\'inventario è obbligatorio', diff --git a/web/src/lang/ja.js b/web/src/lang/ja.js index 380865148c..ab44176e87 100644 --- a/web/src/lang/ja.js +++ b/web/src/lang/ja.js @@ -105,6 +105,8 @@ export default { projectName: 'プロジェクト名', allowAlertsForThisProject: 'このプロジェクトのアラートを許可', telegramChatIdOptional: 'TelegramチャットID(オプション)', + telegramThreadId: 'TelegramスレッドID(オプション)', + telegramThreadIdHint: 'このTelegram宛先の任意のフォーラムトピックIDです。', maxNumberOfParallelTasksOptional: '最大並列タスク数(オプション)', deleteRepository: 'リポジトリを削除', newRepository: '新しいリポジトリ', @@ -255,6 +257,7 @@ export default { isRequired: 'は必須です', mustBeInteger: '整数でなければなりません', mustBe0OrGreater: '0以上でなければなりません', + mustBe1OrGreater: '1以上でなければなりません', start_version_required: '開始バージョンは必須です', playbook_filename_required: 'プレイブックファイル名は必須です', inventory_required: 'インベントリは必須です', diff --git a/web/src/lang/ko.js b/web/src/lang/ko.js index 62e6d999f2..72adbd257c 100644 --- a/web/src/lang/ko.js +++ b/web/src/lang/ko.js @@ -105,6 +105,8 @@ export default { projectName: '프로젝트 이름', allowAlertsForThisProject: '이 프로젝트에 대한 알림 허용', telegramChatIdOptional: '텔레그램 채팅 ID (선택 사항)', + telegramThreadId: '텔레그램 스레드 ID (선택 사항)', + telegramThreadIdHint: '이 텔레그램 대상의 선택적 포럼 주제 ID입니다.', maxNumberOfParallelTasksOptional: '최대 병렬 작업 수 (선택 사항)', deleteRepository: '리포지토리 삭제', newRepository: '새 리포지토리', @@ -255,6 +257,7 @@ export default { isRequired: '필수입니다', mustBeInteger: '정수여야 합니다', mustBe0OrGreater: '0 이상이어야 합니다', + mustBe1OrGreater: '1 이상이어야 합니다', start_version_required: '시작 버전은 필수입니다', playbook_filename_required: '플레이북 파일 이름은 필수입니다', inventory_required: '인벤토리는 필수입니다', diff --git a/web/src/lang/nl.js b/web/src/lang/nl.js index 71013eab57..ab9dd28fe8 100644 --- a/web/src/lang/nl.js +++ b/web/src/lang/nl.js @@ -105,6 +105,8 @@ export default { projectName: 'Projectnaam', allowAlertsForThisProject: 'Sta waarschuwingen voor dit project toe', telegramChatIdOptional: 'Telegram Chat ID (Optioneel)', + telegramThreadId: 'Telegram Thread ID (Optioneel)', + telegramThreadIdHint: 'Optionele forum-topic-ID voor deze Telegram-bestemming.', maxNumberOfParallelTasksOptional: 'Maximaal aantal parallelle taken (Optioneel)', deleteRepository: 'Repository Verwijderen', newRepository: 'Nieuwe Repository', @@ -255,6 +257,7 @@ export default { isRequired: 'is vereist', mustBeInteger: 'Moet een geheel getal zijn', mustBe0OrGreater: 'Moet 0 of groter zijn', + mustBe1OrGreater: 'Moet 1 of groter zijn', start_version_required: 'Startversie is vereist', playbook_filename_required: 'Playbook-bestandsnaam is vereist', inventory_required: 'Inventaris is vereist', diff --git a/web/src/lang/pl.js b/web/src/lang/pl.js index 40b6c0a255..6316b7ba7b 100644 --- a/web/src/lang/pl.js +++ b/web/src/lang/pl.js @@ -105,6 +105,8 @@ export default { projectName: 'Nazwa projektu', allowAlertsForThisProject: 'Zezwól na alerty dla tego projektu', telegramChatIdOptional: 'ID czatu Telegram (opcjonalnie)', + telegramThreadId: 'ID wątku Telegram (opcjonalnie)', + telegramThreadIdHint: 'Opcjonalne ID tematu forum dla tego miejsca docelowego Telegram.', maxNumberOfParallelTasksOptional: 'Maksymalna liczba równoległych zadań (opcjonalnie)', deleteRepository: 'Usuń repozytorium', newRepository: 'Nowe repozytorium', @@ -255,6 +257,7 @@ export default { isRequired: 'jest wymagane', mustBeInteger: 'Musi być liczbą całkowitą', mustBe0OrGreater: 'Musi być 0 lub większa', + mustBe1OrGreater: 'Musi być 1 lub większa', start_version_required: 'Wersja początkowa jest wymagana', playbook_filename_required: 'Nazwa pliku playbook jest wymagana', inventory_required: 'Inwentarz jest wymagany', diff --git a/web/src/lang/pt.js b/web/src/lang/pt.js index 98e98b670e..e6ac873d09 100644 --- a/web/src/lang/pt.js +++ b/web/src/lang/pt.js @@ -105,6 +105,8 @@ export default { projectName: 'Nome do Projeto', allowAlertsForThisProject: 'Permitir alertas para este projeto', telegramChatIdOptional: 'ID do Chat do Telegram (Opcional)', + telegramThreadId: 'ID do tópico do Telegram (Opcional)', + telegramThreadIdHint: 'ID opcional do tópico do fórum para este destino do Telegram.', maxNumberOfParallelTasksOptional: 'Número máximo de tarefas paralelas (Opcional)', deleteRepository: 'Excluir repositório', newRepository: 'Novo Repositório', @@ -255,6 +257,7 @@ export default { isRequired: 'é obrigatório', mustBeInteger: 'Deve ser um número inteiro', mustBe0OrGreater: 'Deve ser 0 ou maior', + mustBe1OrGreater: 'Deve ser 1 ou maior', start_version_required: 'Versão inicial é obrigatória', playbook_filename_required: 'Nome do arquivo do playbook é obrigatório', inventory_required: 'Inventário é obrigatório', diff --git a/web/src/lang/pt_br.js b/web/src/lang/pt_br.js index 7d500106e2..d655058c58 100644 --- a/web/src/lang/pt_br.js +++ b/web/src/lang/pt_br.js @@ -105,6 +105,8 @@ export default { projectName: 'Nome do Projeto', allowAlertsForThisProject: 'Permitir alertas para este projeto', telegramChatIdOptional: 'ID do Chat do Telegram (Opcional)', + telegramThreadId: 'ID do tópico do Telegram (Opcional)', + telegramThreadIdHint: 'ID opcional do tópico do fórum para este destino do Telegram.', maxNumberOfParallelTasksOptional: 'Número máximo de tarefas paralelas (Opcional)', deleteRepository: 'Excluir repositório', newRepository: 'Novo Repositório', @@ -255,6 +257,7 @@ export default { isRequired: 'é obrigatório', mustBeInteger: 'Deve ser um número inteiro', mustBe0OrGreater: 'Deve ser 0 ou maior', + mustBe1OrGreater: 'Deve ser 1 ou maior', start_version_required: 'Versão inicial é obrigatória', playbook_filename_required: 'Nome do arquivo playbook é obrigatório', inventory_required: 'Inventário é obrigatório', diff --git a/web/src/lang/ru.js b/web/src/lang/ru.js index dbf9a5ead8..13b91e77d2 100644 --- a/web/src/lang/ru.js +++ b/web/src/lang/ru.js @@ -113,6 +113,8 @@ export default { projectName: 'Имя проекта', allowAlertsForThisProject: 'Разрешить оповещения для этого проекта', telegramChatIdOptional: 'Telegram Chat ID (необязательно)', + telegramThreadId: 'Telegram Thread ID (необязательно)', + telegramThreadIdHint: 'Необязательный ID темы форума для этого назначения Telegram.', maxNumberOfParallelTasksOptional: 'Максимальное количество параллельных задач (необязательно)', deleteRepository: 'Удалить репозиторий', newRepository: 'Новый репозиторий', @@ -263,6 +265,7 @@ export default { isRequired: 'обязательно', mustBeInteger: 'Должно быть целым числом', mustBe0OrGreater: 'Должно быть 0 или больше', + mustBe1OrGreater: 'Должно быть 1 или больше', start_version_required: 'Начальная версия обязательна', playbook_filename_required: 'Имя файла плейбука обязательно', inventory_required: 'Инвентарь обязателен', diff --git a/web/src/lang/uk.js b/web/src/lang/uk.js index 7f161dfc2e..2aebf934ec 100644 --- a/web/src/lang/uk.js +++ b/web/src/lang/uk.js @@ -106,6 +106,8 @@ export default { projectName: 'Назва проєкту', allowAlertsForThisProject: 'Дозволити сповіщення для цього проєкту', telegramChatIdOptional: 'Telegram Chat ID (необов’язково)', + telegramThreadId: 'Telegram Thread ID (необов’язково)', + telegramThreadIdHint: 'Необов’язковий ID теми форуму для цього призначення Telegram.', maxNumberOfParallelTasksOptional: 'Максимальна кількість паралельних завдань (необов’язково)', deleteRepository: 'Видалити репозиторій', newRepository: 'Новий репозиторій', @@ -256,6 +258,7 @@ export default { isRequired: 'обов’язково', mustBeInteger: 'Має бути ціле число', mustBe0OrGreater: 'Має бути 0 або більше', + mustBe1OrGreater: 'Має бути 1 або більше', start_version_required: 'Початкова версія обов’язкова', playbook_filename_required: 'Потрібно вказати playbook', inventory_required: 'Необхідно вказати інвентар', diff --git a/web/src/lang/zh_cn.js b/web/src/lang/zh_cn.js index a8ba8d5a18..07c492e846 100644 --- a/web/src/lang/zh_cn.js +++ b/web/src/lang/zh_cn.js @@ -105,6 +105,8 @@ export default { projectName: '项目名称', allowAlertsForThisProject: '允许此项目的警报', telegramChatIdOptional: 'Telegram 聊天 ID(可选)', + telegramThreadId: 'Telegram 话题 ID(可选)', + telegramThreadIdHint: '此 Telegram 目标的可选论坛话题 ID。', maxNumberOfParallelTasksOptional: '最大并行任务数(可选)', deleteRepository: '删除仓库', newRepository: '新仓库', @@ -255,6 +257,7 @@ export default { isRequired: '是必需的', mustBeInteger: '必须是整数', mustBe0OrGreater: '必须是 0 或更大', + mustBe1OrGreater: '必须是 1 或更大', start_version_required: '起始版本是必需的', playbook_filename_required: '剧本文件名是必需的', inventory_required: '库存是必需的', diff --git a/web/src/lang/zh_tw.js b/web/src/lang/zh_tw.js index ea758615d7..72ae2a9439 100644 --- a/web/src/lang/zh_tw.js +++ b/web/src/lang/zh_tw.js @@ -106,6 +106,8 @@ export default { projectName: '專案名稱', allowAlertsForThisProject: '啟用此專案的警報', telegramChatIdOptional: 'Telegram 聊天 ID(選填)', + telegramThreadId: 'Telegram 主題 ID(選填)', + telegramThreadIdHint: '此 Telegram 目的地的選填論壇主題 ID。', maxNumberOfParallelTasksOptional: '最大平行任務數(選填)', deleteRepository: '刪除儲存庫', newRepository: '新增儲存庫', @@ -257,6 +259,7 @@ export default { isRequired: '是必填的', mustBeInteger: '必須是整數', mustBe0OrGreater: '必須大於或等於 0', + mustBe1OrGreater: '必須大於或等於 1', start_version_required: '起始版本是必填的', playbook_filename_required: 'Playbook 檔案名稱是必填的', inventory_required: '清單是必填的', diff --git a/web/src/router/index.js b/web/src/router/index.js index 46c3d3e018..b105da10e2 100644 --- a/web/src/router/index.js +++ b/web/src/router/index.js @@ -23,6 +23,7 @@ import Users from '../views/Users.vue'; import Auth from '../views/Auth.vue'; import New from '../views/project/New.vue'; import Integrations from '../views/project/Integrations.vue'; +import Alerts from '../views/project/Alerts.vue'; import IntegrationExtractor from '../views/project/IntegrationExtractor.vue'; import Apps from '../views/Apps.vue'; import Runners from '../views/Runners.vue'; @@ -165,6 +166,10 @@ const routes = [ path: '/project/:projectId/integrations', component: Integrations, }, + { + path: '/project/:projectId/alerts', + component: Alerts, + }, { path: '/project/:projectId/integrations/:integrationId', component: IntegrationExtractor, diff --git a/web/src/views/project/Alerts.vue b/web/src/views/project/Alerts.vue new file mode 100644 index 0000000000..47f9f36ca0 --- /dev/null +++ b/web/src/views/project/Alerts.vue @@ -0,0 +1,202 @@ + + diff --git a/web/src/views/project/Settings.vue b/web/src/views/project/Settings.vue index 8571868f1c..3f63f7cba1 100644 --- a/web/src/views/project/Settings.vue +++ b/web/src/views/project/Settings.vue @@ -37,26 +37,9 @@ />
-
- Test Alerts +
{{ $t('save') }}
- -

{{ $t('danger_zone_settings') }}

@@ -184,40 +167,10 @@ export default { deleteProjectDialog: null, backupProgress: false, clearCacheProgress: false, - testNotificationProgress: false, }; }, methods: { - async sendTestNotification() { - this.testNotificationProgress = true; - try { - await axios({ - method: 'post', - url: `/api/project/${this.projectId}/notifications/test`, - responseType: 'json', - }); - EventBus.$emit('i-snackbar', { - color: 'success', - text: 'Test notification sent.', - }); - } catch (err) { - let msg; - if (err.response.status === 409) { - msg = 'Please allow alerts for the project and save it.'; - } else { - msg = getErrorMessage(err); - } - - EventBus.$emit('i-snackbar', { - color: 'error', - text: msg, - }); - } finally { - this.testNotificationProgress = false; - } - }, - showDrawer() { EventBus.$emit('i-show-drawer'); },