Skip to content

Commit

Permalink
Merge pull request #159 from datarootsio/notify-slack
Browse files Browse the repository at this point in the history
notify slack & localization
  • Loading branch information
bart6114 authored Oct 25, 2023
2 parents 47b9307 + 44c5476 commit 7cf80bc
Show file tree
Hide file tree
Showing 12 changed files with 146 additions and 44 deletions.
30 changes: 27 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ 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
```
If your `command` requires arguments, please make sure to pass them as an array like in `foo_job`.
Expand Down Expand Up @@ -99,9 +101,9 @@ All configuration options are available by checking out `cheek --help` or the he

Configuration can be passed as flags to the `cheek` CLI directly. All configuration flags are also possible to set via environment variables. The following environment variables are available, they will override the default and/or set value of their similarly named CLI flags (without the prefix): `CHEEK_PORT`, `CHEEK_SUPPRESSLOGS`, `CHEEK_LOGLEVEL`, `CHEEK_PRETTY`, `CHEEK_HOMEDIR`.

## Events
## 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. Two types of actions can be taken as a response: `notify_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` 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:
Expand All @@ -119,7 +121,29 @@ jobs:
cron: "* * * * *"
```

Webhooks are a generic way to push notifications to a plethora of tools. You can use it for instance via Zapier to push messages to a Slack channel.
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`.

The `notify_webhook` sends a JSON payload to your webhook url with the following structure:

```json
{
"status": 0,
"log": "I'm a teapot, not a coffee machine!",
"name": "TeapotTask",
"triggered_at": "2023-04-01T12:00:00Z",
"triggered_by": "CoffeeRequestButton",
"triggered": ["CoffeeMachine"] // this job triggered another one
}
```

The `notify_slack_webhook` sends a JSON payload to your Slack webhook url with the following structure (which is Slack app compatible):

```json
{
"text": "TeapotTask (exitcode 0):\nI'm a teapot, not a coffee machine!"
}
```


## Docker

Expand Down
6 changes: 6 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
coverage:
status:
project:
default:
target: auto
threshold: 5%
10 changes: 9 additions & 1 deletion pkg/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"net/http"
"sort"
"strings"
"time"
)

type Response struct {
Expand Down Expand Up @@ -123,8 +124,15 @@ func ui(s *Schedule) func(w http.ResponseWriter, r *http.Request) {
}
sort.Strings(jobNames)

// add custom functions to template
funcMap := template.FuncMap{
"roundToSeconds": func(d time.Duration) int {
return int(d.Seconds())
},
}

// parse template files
tmpl, err := template.ParseFS(fsys(), "*.html")
tmpl, err := template.New("index.html").Funcs(funcMap).ParseFS(fsys(), "*.html")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
Expand Down
50 changes: 40 additions & 10 deletions pkg/job.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ import (

// 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"`
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"`
}

// JobSpec holds specifications and metadata of a job.
Expand All @@ -36,7 +37,7 @@ type JobSpec struct {
Env map[string]string `yaml:"env,omitempty"`
WorkingDirectory string `yaml:"working_directory,omitempty" json:"working_directory,omitempty"`
globalSchedule *Schedule
Runs []JobRun `yaml:"runs,omitempty"`
Runs []JobRun `yaml:"runs,omitempty"`

nextTick time.Time
log zerolog.Logger
Expand All @@ -47,11 +48,12 @@ type JobSpec struct {
type JobRun struct {
Status int `json:"status"`
logBuf bytes.Buffer
Log string `json:"log"`
Name string `json:"name"`
TriggeredAt time.Time `json:"triggered_at"`
TriggeredBy string `json:"triggered_by"`
Triggered []string `json:"triggered,omitempty"`
Log string `json:"log"`
Name string `json:"name"`
TriggeredAt time.Time `json:"triggered_at"`
TriggeredBy string `json:"triggered_by"`
Triggered []string `json:"triggered,omitempty"`
Duration time.Duration `json:"duration,omitempty"`
jobRef *JobSpec
}

Expand Down Expand Up @@ -111,10 +113,18 @@ func (j *JobSpec) execCommandWithRetry(trigger string) JobRun {
return jr
}

func (j JobSpec) now() time.Time {
// defer for if schedule doesn't exist, allows fore easy testing
if j.globalSchedule != nil {
return j.globalSchedule.now()
}
return time.Now()
}

func (j *JobSpec) execCommand(trigger string) JobRun {
j.log.Info().Str("job", j.Name).Str("trigger", trigger).Msgf("Job triggered")
// init status to non-zero until execution says otherwise
jr := JobRun{Name: j.Name, TriggeredAt: time.Now(), TriggeredBy: trigger, Status: -1, jobRef: j}
jr := JobRun{Name: j.Name, TriggeredAt: j.now(), TriggeredBy: trigger, Status: -1, jobRef: j}

suppressLogs := j.cfg.SuppressLogs

Expand Down Expand Up @@ -178,6 +188,7 @@ func (j *JobSpec) execCommand(trigger string) JobRun {
return jr
}

jr.Duration = time.Since(jr.TriggeredAt)
jr.Status = 0
j.log.Debug().Str("job", j.Name).Int("exitcode", jr.Status).Msgf("job exited status: %v", jr.Status)

Expand Down Expand Up @@ -216,21 +227,26 @@ func (j *JobSpec) ValidateCron() error {
func (j *JobSpec) OnEvent(jr *JobRun) {
var jobsToTrigger []string
var webhooksToCall []string
var slackWebhooksToCall []string

switch jr.Status == 0 {
case true: // after success
jobsToTrigger = j.OnSuccess.TriggerJob
webhooksToCall = j.OnSuccess.NotifyWebhook
slackWebhooksToCall = j.OnSuccess.NotifySlackWebhook
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...)
}
case false: // after error
jobsToTrigger = j.OnError.TriggerJob
webhooksToCall = j.OnError.NotifyWebhook
slackWebhooksToCall = j.OnError.NotifySlackWebhook
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...)
}
}

Expand All @@ -252,7 +268,21 @@ func (j *JobSpec) OnEvent(jr *JobRun) {
wg.Add(1)
go func(wg *sync.WaitGroup, webhookURL string) {
defer wg.Done()
resp_body, err := JobRunWebhookCall(jr, webhookURL)
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")
wg.Add(1)
go func(wg *sync.WaitGroup, webhookURL string) {
defer wg.Done()
resp_body, err := JobRunWebhookCall(jr, webhookURL, "slack")
if err != nil {
j.log.Warn().Str("job", j.Name).Str("on_event", "webhook").Err(err).Msg("webhook notify failed")
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
<div class="row">
<div class="col-12">
<div class="flex-container">
{{range .JobNames}}<a class="{{if eq . $.SelectedJobName}}text-primary{{else}}text-grey{{end}} pad"
{{range .JobNames}}<a class="{{if eq . $.SelectedJobName}}text-primary{{else}}text-dark{{end}} pad"
href="/job/{{.}}">{{.}}</a>{{end}}
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion pkg/public/jobview.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ <h4 class="is-marginless view-header text-primary">JobSpec</h4>
</div>
<div class="view-container">
<h4 class="is-marginless view-header text-primary">Logs</h4>
<pre class="pre-wrap">{{range $i, $j := .SelectedJobSpec.Runs}}<span id="log{{$i}}"></span>{{.TriggeredAt}} | triggered by: {{ .TriggeredBy }} | exit code: {{.Status}}
<pre class="pre-wrap">{{range $i, $j := .SelectedJobSpec.Runs}}<span id="log{{$i}}"></span>{{.TriggeredAt}} | triggered by: {{ .TriggeredBy }} | duration: {{ .Duration | roundToSeconds}}s | exit code: {{.Status}}
---
{{.Log}}
{{end}}
Expand Down
6 changes: 3 additions & 3 deletions pkg/public/overview.html
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{{ define "overview"}} {{range .JobNames}} {{ $spec := index $.JobSpecs .}}
<div class="inline">
<a class="text-grey pad" href="/job/{{$spec.Name}}">{{$spec.Name}}</a>
<a class="text-dark pad" href="/job/{{$spec.Name}}">{{$spec.Name}}</a>
{{ range $i, $r := $spec.Runs }}
<a href="/job/{{$spec.Name}}#log{{$i}}"
><abbr class="no-underline" title="{{$r.TriggeredAt.Format "2006-01-02T15:04:05"}}&#10;exit code: {{$r.Status}}"
><abbr class="no-underline" title="{{$r.TriggeredAt.Format "2006-01-02T15:04:05"}}&#10;duration: {{$r.Duration | roundToSeconds}}s&#10;exit code: {{$r.Status}}"
>{{ if eq $r.Status 0 }}
<img src="/static/img/circle.svg" />
{{else}}
Expand All @@ -14,5 +14,5 @@
{{end}}
</div>
{{end}}
<p class="text-grey pad-top"><small>shows statuses up until the last 10 runs</small></p>
<p class="text-dark pad-top"><small>shows statuses up until the last 10 runs</small></p>
{{ end }}
4 changes: 2 additions & 2 deletions pkg/public/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
body.dark {
--bg-color: #000;
--bg-secondary-color: #131316;
--font-color: #f5f5f5;
--font-color: #d3d7cf;
--color-grey: #ccc;
--color-darkGrey: #777;
--color-darkGrey: #d3d7cf;
}

.button.icon-only {
Expand Down
25 changes: 12 additions & 13 deletions pkg/schedule.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func (s *Schedule) Run() {
select {
case <-ticker.C:
s.log.Debug().Msg("tick")
currentTickTime = time.Now()
currentTickTime = s.now()

for _, j := range s.Jobs {
if j.Cron == "" {
Expand Down Expand Up @@ -98,7 +98,16 @@ func readSpecs(fn string) (Schedule, error) {

// initialize Schedule spec and logic.
func (s *Schedule) initialize() error {
initTime := time.Now()
// validate tz location
if s.TZLocation == "" {
s.TZLocation = "Local"
}

loc, err := time.LoadLocation(s.TZLocation)
if err != nil {
return err
}
s.loc = loc

for k, v := range s.Jobs {
// check if trigger references exist
Expand All @@ -121,21 +130,11 @@ func (s *Schedule) initialize() error {
}

// init nextTick
if err := v.setNextTick(initTime, true); err != nil {
if err := v.setNextTick(s.now(), true); err != nil {
return err
}

}
// validate tz location
if s.TZLocation == "" {
s.TZLocation = "Local"
}

loc, err := time.LoadLocation(s.TZLocation)
if err != nil {
return err
}
s.loc = loc

return nil
}
Expand Down
23 changes: 20 additions & 3 deletions pkg/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,31 @@ package cheek
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)

func JobRunWebhookCall(jr *JobRun, webhookURL string) ([]byte, error) {
type slackPayload struct {
Text string `json:"text"`
}

func JobRunWebhookCall(jr *JobRun, webhookURL string, webhookType string) ([]byte, error) {
payload := bytes.Buffer{}
if err := json.NewEncoder(&payload).Encode(jr); err != nil {
return []byte{}, err

if webhookType == "slack" {
d := slackPayload{
Text: fmt.Sprintf("%s (exitcode %v):\n%s", jr.Name, jr.Status, jr.Log),
}

if err := json.NewEncoder(&payload).Encode(d); err != nil {
return []byte{}, err
}

} else {
if err := json.NewEncoder(&payload).Encode(jr); err != nil {
return []byte{}, err
}
}

resp, err := http.Post(webhookURL, "application/json", bytes.NewBuffer(payload.Bytes()))
Expand Down
28 changes: 21 additions & 7 deletions pkg/webhook_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import (
)

func TestJobRunWebhookCall(t *testing.T) {
var err error
var resp_body []byte

testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
Expand All @@ -22,25 +25,36 @@ func TestJobRunWebhookCall(t *testing.T) {
// mirror this
w.Header().Set("Content-Type", "application/json")
fmt.Fprintln(w, string(body))
t.Log(string(body))
}))

defer testServer.Close()

// test generic webhook
jr := JobRun{
Status: 0,
Name: "test",
TriggeredBy: "cron",
Log: "this is a random log statement\nwith multiple lines\nand stuff",
}

resp_body, err := JobRunWebhookCall(&jr, testServer.URL)
if err != nil {
t.Fatal(err)
}
resp_body, err = JobRunWebhookCall(&jr, testServer.URL, "generic")
assert.NoError(t, err)

jr2 := JobRun{}
if err := json.NewDecoder(bytes.NewBuffer(resp_body)).Decode(&jr2); err != nil {
t.Fatal(err)
}
err = json.NewDecoder(bytes.NewBuffer(resp_body)).Decode(&jr2)
assert.NoError(t, err)

assert.Equal(t, jr, jr2)

// test slack webhook
resp_body, err = JobRunWebhookCall(&jr, testServer.URL, "slack")
assert.NoError(t, err)
assert.Contains(t, string(resp_body), "text\":\"test (exitcode 0)")

sl := slackPayload{}
err = json.NewDecoder(bytes.NewBuffer(resp_body)).Decode(&sl)
assert.NoError(t, err)
assert.NotEmpty(t, sl.Text)

}
4 changes: 4 additions & 0 deletions testdata/sleep.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
jobs:
sleep_3_command:
cron: "* * * * *"
command: sleep 3

0 comments on commit 7cf80bc

Please sign in to comment.