diff --git a/README.md b/README.md index 313967f..07a085d 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ jobs: - https://webhook.site/4b732eb4-ba10-4a84-8f6b-30167b2f2762 notify_slack_webhook: # notify slack via a slack compatible webhook - https://webhook.site/048ff47f-9ef5-43fb-9375-a795a8c5cbf5 + notify_discord_webhook: # notify discord via a discord compatible webhook + - https://discord.com/api/webhooks/user/token ``` If your `command` requires arguments, please make sure to pass them as an array like in `foo_job`. @@ -103,7 +105,7 @@ Configuration can be passed as flags to the `cheek` CLI directly. All configurat ## Events & Notifications -There are two types of event you can hook into: `on_success` and `on_error`. Both events materialize after an (attempted) job run. Three types of actions can be taken as a response: `notify_webhook`, `notify_slack_webhook` and `trigger_job`. See the example below. Definition of these event actions can be done on job level or at schedule level, in the latter case it will apply to all jobs. +There are two types of event you can hook into: `on_success` and `on_error`. Both events materialize after an (attempted) job run. Three types of actions can be taken as a response: `notify_webhook`, `notify_slack_webhook`, `notify_slack_webhook` and `trigger_job`. See the example below. Definition of these event actions can be done on job level or at schedule level, in the latter case it will apply to all jobs. ```yaml on_success: @@ -121,7 +123,7 @@ jobs: cron: "* * * * *" ``` -Webhooks are a generic way to push notifications to a plethora of tools. There is a generic way to do this via the `notify_webhook` option or a Slack-compatible one via `notify_slack_webhook`. +Webhooks are a generic way to push notifications to a plethora of tools. There is a generic way to do this via the `notify_webhook` option or a Slack-compatible one via `notify_slack_webhook` or a Discord-compatible one via `notify_discord_webhook`. The `notify_webhook` sends a JSON payload to your webhook url with the following structure: @@ -144,6 +146,14 @@ The `notify_slack_webhook` sends a JSON payload to your Slack webhook url with t } ``` +The `notify_discord_webhook` sends a JSON payload to your Discord webhook url with the following structure (which is Discord app compatible): + +```json +{ + "content": "TeapotTask (exitcode 0):\nI'm a teapot, not a coffee machine!" +} +``` + ## Docker diff --git a/pkg/job.go b/pkg/job.go index 14d4622..825eb93 100644 --- a/pkg/job.go +++ b/pkg/job.go @@ -23,9 +23,10 @@ const ( // OnEvent contains specs on what needs to happen after a job event. type OnEvent struct { - TriggerJob []string `yaml:"trigger_job,omitempty" json:"trigger_job,omitempty"` - NotifyWebhook []string `yaml:"notify_webhook,omitempty" json:"notify_webhook,omitempty"` - NotifySlackWebhook []string `yaml:"notify_slack_webhook,omitempty" json:"notify_slack_webhook,omitempty"` + TriggerJob []string `yaml:"trigger_job,omitempty" json:"trigger_job,omitempty"` + NotifyWebhook []string `yaml:"notify_webhook,omitempty" json:"notify_webhook,omitempty"` + NotifySlackWebhook []string `yaml:"notify_slack_webhook,omitempty" json:"notify_slack_webhook,omitempty"` + NotifyDiscordWebhook []string `yaml:"notify_discord_webhook,omitempty" json:"notify_discord_webhook,omitempty"` } // JobSpec holds specifications and metadata of a job. @@ -339,27 +340,32 @@ func (j *JobSpec) ValidateCron() error { func (j *JobSpec) OnEvent(jr *JobRun) { var jobsToTrigger []string - var webhooksToCall []string - var slackWebhooksToCall []string + var webhooksToCall []webhook + var events []OnEvent switch *jr.Status == StatusOK { case true: // after success - jobsToTrigger = j.OnSuccess.TriggerJob - webhooksToCall = j.OnSuccess.NotifyWebhook - slackWebhooksToCall = j.OnSuccess.NotifySlackWebhook + events = append(events, j.OnSuccess) if j.globalSchedule != nil { - jobsToTrigger = append(jobsToTrigger, j.globalSchedule.OnSuccess.TriggerJob...) - webhooksToCall = append(webhooksToCall, j.globalSchedule.OnSuccess.NotifyWebhook...) - slackWebhooksToCall = append(slackWebhooksToCall, j.globalSchedule.OnSuccess.NotifySlackWebhook...) + events = append(events, j.globalSchedule.OnSuccess) } case false: // after error - jobsToTrigger = j.OnError.TriggerJob - webhooksToCall = j.OnError.NotifyWebhook - slackWebhooksToCall = j.OnError.NotifySlackWebhook + events = append(events, j.OnError) if j.globalSchedule != nil { - jobsToTrigger = append(jobsToTrigger, j.globalSchedule.OnError.TriggerJob...) - webhooksToCall = append(webhooksToCall, j.globalSchedule.OnError.NotifyWebhook...) - slackWebhooksToCall = append(slackWebhooksToCall, j.globalSchedule.OnError.NotifySlackWebhook...) + events = append(events, j.globalSchedule.OnError) + } + } + + for _, e := range events { + jobsToTrigger = append(jobsToTrigger, e.TriggerJob...) + for _, whURL := range e.NotifyWebhook { + webhooksToCall = append(webhooksToCall, NewDefaultWebhook(whURL)) + } + for _, whURL := range e.NotifySlackWebhook { + webhooksToCall = append(webhooksToCall, NewSlackWebhook(whURL)) + } + for _, whURL := range e.NotifyDiscordWebhook { + webhooksToCall = append(webhooksToCall, NewDiscordWebhook(whURL)) } } @@ -377,29 +383,15 @@ func (j *JobSpec) OnEvent(jr *JobRun) { // trigger webhooks for _, wu := range webhooksToCall { - j.log.Debug().Str("job", j.Name).Str("on_event", "webhook_call").Msg("triggered by parent job") - wg.Add(1) - go func(wg *sync.WaitGroup, webhookURL string) { - defer wg.Done() - resp_body, err := JobRunWebhookCall(jr, webhookURL, "generic") - if err != nil { - j.log.Warn().Str("job", j.Name).Str("on_event", "webhook").Err(err).Msg("webhook notify failed") - } - j.log.Debug().Str("job", jr.Name).Str("webhook_call", "response").Str("webhook_url", webhookURL).Msg(string(resp_body)) - }(&wg, wu) - } - - // trigger slack webhooks - this feels like a lot of duplication - for _, wu := range slackWebhooksToCall { - j.log.Debug().Str("job", j.Name).Str("on_event", "slack_webhook_call").Msg("triggered by parent job") + j.log.Debug().Str("job", j.Name).Str("on_event", wu.Name()+"_webhook_call").Msg("triggered by parent job") wg.Add(1) - go func(wg *sync.WaitGroup, webhookURL string) { + go func(wg *sync.WaitGroup, wu webhook) { defer wg.Done() - resp_body, err := JobRunWebhookCall(jr, webhookURL, "slack") + resp_body, err := wu.Call(jr) if err != nil { j.log.Warn().Str("job", j.Name).Str("on_event", "webhook").Err(err).Msg("webhook notify failed") } - j.log.Debug().Str("job", jr.Name).Str("webhook_call", "response").Str("webhook_url", webhookURL).Msg(string(resp_body)) + j.log.Debug().Str("job", jr.Name).Str("webhook_call", "response").Str("webhook_url", wu.URL()).Msg(string(resp_body)) }(&wg, wu) } diff --git a/pkg/webhook.go b/pkg/webhook.go index 29df189..bccb5f4 100644 --- a/pkg/webhook.go +++ b/pkg/webhook.go @@ -8,29 +8,81 @@ import ( "net/http" ) -type slackPayload struct { - Text string `json:"text"` +type webhook interface { + Call(jr *JobRun) ([]byte, error) + URL() string + Name() string } -func JobRunWebhookCall(jr *JobRun, webhookURL string, webhookType string) ([]byte, error) { +// Discord Webhook + +type discordWebhook struct { + endpoint string +} + +func NewDiscordWebhook(endpoint string) discordWebhook { + return discordWebhook{endpoint} +} + +func (dw discordWebhook) Call(jr *JobRun) ([]byte, error) { + type discordPayload struct { + Content string `json:"content"` + } payload := bytes.Buffer{} + msg := fmt.Sprintf("%s (exitcode %v):\n%s", jr.Name, *jr.Status, jr.Log) + d := discordPayload{ + Content: msg[:min(len(msg), 2000)], // discord accepts a max. of 2000 chars + } + if err := json.NewEncoder(&payload).Encode(d); err != nil { + return nil, err + } - if webhookType == "slack" { - d := slackPayload{ - Text: fmt.Sprintf("%s (exitcode %v):\n%s", jr.Name, *jr.Status, jr.Log), - } + resp, err := http.Post(dw.endpoint, "application/json", bytes.NewBuffer(payload.Bytes())) + if err != nil { + return []byte{}, err + } + defer resp.Body.Close() - if err := json.NewEncoder(&payload).Encode(d); err != nil { - return []byte{}, err - } + resp_body, err := io.ReadAll(resp.Body) + if err != nil { + return []byte{}, err + } - } else { - if err := json.NewEncoder(&payload).Encode(jr); err != nil { - return []byte{}, err - } + return resp_body, nil +} + +func (dw discordWebhook) URL() string { + return dw.endpoint +} + +func (dw discordWebhook) Name() string { + return "discord" +} + +// Slack Webhook + +type slackWebhook struct { + endpoint string +} + +func NewSlackWebhook(endpoint string) slackWebhook { + return slackWebhook{endpoint} +} + +func (dw slackWebhook) Call(jr *JobRun) ([]byte, error) { + type slackPayload struct { + Text string `json:"text"` + } + payload := bytes.Buffer{} + msg := fmt.Sprintf("%s (exitcode %v):\n%s", jr.Name, *jr.Status, jr.Log) + d := slackPayload{ + Text: msg[:min(len(msg), 40000)], // slack accepts a max. of 40000 chars + } + if err := json.NewEncoder(&payload).Encode(d); err != nil { + return nil, err } - resp, err := http.Post(webhookURL, "application/json", bytes.NewBuffer(payload.Bytes())) + resp, err := http.Post(dw.endpoint, "application/json", bytes.NewBuffer(payload.Bytes())) if err != nil { return []byte{}, err } @@ -43,3 +95,49 @@ func JobRunWebhookCall(jr *JobRun, webhookURL string, webhookType string) ([]byt return resp_body, nil } + +func (dw slackWebhook) URL() string { + return dw.endpoint +} + +func (dw slackWebhook) Name() string { + return "slack" +} + +// Default Webhook + +type defaultWebhook struct { + endpoint string +} + +func NewDefaultWebhook(endpoint string) defaultWebhook { + return defaultWebhook{endpoint} +} + +func (dw defaultWebhook) Call(jr *JobRun) ([]byte, error) { + payload := bytes.Buffer{} + if err := json.NewEncoder(&payload).Encode(jr); err != nil { + return []byte{}, err + } + + resp, err := http.Post(dw.endpoint, "application/json", bytes.NewBuffer(payload.Bytes())) + if err != nil { + return []byte{}, err + } + defer resp.Body.Close() + + resp_body, err := io.ReadAll(resp.Body) + if err != nil { + return []byte{}, err + } + + return resp_body, nil +} + +func (dw defaultWebhook) URL() string { + return dw.endpoint +} + +func (dw defaultWebhook) Name() string { + return "generic" +} diff --git a/pkg/webhook_test.go b/pkg/webhook_test.go index 252612d..d64ad03 100644 --- a/pkg/webhook_test.go +++ b/pkg/webhook_test.go @@ -1,8 +1,6 @@ package cheek import ( - "bytes" - "encoding/json" "fmt" "io" "net/http" @@ -24,7 +22,7 @@ func TestJobRunWebhookCall(t *testing.T) { } // mirror this w.Header().Set("Content-Type", "application/json") - fmt.Fprintln(w, string(body)) + fmt.Fprint(w, string(body)) t.Log(string(body)) })) @@ -38,23 +36,21 @@ func TestJobRunWebhookCall(t *testing.T) { Log: "this is a random log statement\nwith multiple lines\nand stuff", } - resp_body, err = JobRunWebhookCall(&jr, testServer.URL, "generic") + var wh webhook + wh = NewDefaultWebhook(testServer.URL) + resp_body, err = wh.Call(&jr) assert.NoError(t, err) - - jr2 := JobRun{} - err = json.NewDecoder(bytes.NewBuffer(resp_body)).Decode(&jr2) - assert.NoError(t, err) - - assert.Equal(t, jr, jr2) + assert.Contains(t, string(resp_body), `{"status":0,"log":"this is a random log statement\nwith multiple lines\nand stuff","name":"test","triggered_at":"0001-01-01T00:00:00Z","triggered_by":"cron"}`) // test slack webhook - resp_body, err = JobRunWebhookCall(&jr, testServer.URL, "slack") + wh = NewSlackWebhook(testServer.URL) + resp_body, err = wh.Call(&jr) assert.NoError(t, err) - assert.Contains(t, string(resp_body), "text\":\"test (exitcode 0)") + assert.Contains(t, string(resp_body), `{"text":"test (exitcode 0):\nthis is a random log statement\nwith multiple lines\nand stuff"}`) - sl := slackPayload{} - err = json.NewDecoder(bytes.NewBuffer(resp_body)).Decode(&sl) + // test discord webhook + wh = NewDiscordWebhook(testServer.URL) + resp_body, err = wh.Call(&jr) assert.NoError(t, err) - assert.NotEmpty(t, sl.Text) - + assert.Contains(t, string(resp_body), `{"content":"test (exitcode 0):\nthis is a random log statement\nwith multiple lines\nand stuff"}`) } diff --git a/testdata/readme_example.yaml b/testdata/readme_example.yaml index 857e87b..dc89d71 100644 --- a/testdata/readme_example.yaml +++ b/testdata/readme_example.yaml @@ -22,3 +22,5 @@ jobs: on_error: notify_webhook: # notify something on error - https://webhook.site/4b732eb4-ba10-4a84-8f6b-30167b2f2762 + notify_slack_webhook: # notify slack via a slack compatible webhook + - https://webhook.site/048ff47f-9ef5-43fb-9375-a795a8c5cbf5 \ No newline at end of file