Skip to content

Commit ec1192e

Browse files
committed
Cronspec and other parameters can be specified as comments in the file
Added support for Timezone & Timeout
1 parent 8362e04 commit ec1192e

File tree

9 files changed

+482
-71
lines changed

9 files changed

+482
-71
lines changed

Readme.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,16 @@ include environment variables.
1111
The `.godoit` filename contains the cronspec description of when to run
1212
the job and the scheduled job name.
1313

14+
The `.godoit` file can also include job parameters as comment in the script (including specifying the cronspec
15+
as comments of the form `#:godoit <param> <value>`
16+
e.g.
17+
18+
Comment | Detail
19+
-------------------|-----------
20+
`#:godoit cronspec ...`| The cron spec (see https://godoc.org/github.com/robfig/cron)
21+
`#:godoit timeout ...` | Time as a duration after which SIGTERM is sent e.g. `1h30m`, `15s`
22+
`#:godoit timezone ...`| The timezone for the job e.g. `Europe/London`
23+
1424
Godoit jobs are executed by a wrapper script which allows the deployment
1525
to handle specific concerns such as job logging and alerting of failures.
1626

execution.go

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,47 @@ import (
55
"os/exec"
66
"log"
77
"io"
8+
"time"
9+
"syscall"
810
)
911

10-
type JobExecutor func(jobName, jobPath string)
12+
type JobExecutor func(jobName, jobPath string, timeout time.Duration)
1113

1214
func JobExecutorFromScript(jobExecutorScript string, output io.Writer) JobExecutor {
1315
if len(jobExecutorScript) == 0 {
14-
log.Fatalf("Job executor is not defined")
16+
log.Fatal("Job executor is not defined")
1517
}
1618
jobExecutorScript = os.ExpandEnv(jobExecutorScript)
17-
return func(jobName, jobPath string) {
19+
return func(jobName, jobPath string, timeout time.Duration) {
1820
cmd := exec.Command(jobExecutorScript, jobName, jobPath)
19-
log.Printf("Running comand line: %s '%s' '%s'", jobExecutorScript, jobName, jobPath)
21+
log.Printf("Running comand line: %s '%s' '%s' Timeout: %s", jobExecutorScript, jobName, jobPath, timeout)
2022
cmd.Stdout = output
2123
cmd.Stderr = output
22-
err := cmd.Run()
24+
err := runWithTimout(cmd, timeout)
2325
if err != nil {
2426
log.Printf("ERROR: Failed to execute executor script %s %s %s", jobExecutorScript, jobName, jobPath)
2527
}
2628
}
2729
}
30+
31+
func runWithTimout(cmd *exec.Cmd, timeout time.Duration) error {
32+
if timeout.Seconds() <= 0 {
33+
return cmd.Run()
34+
} else {
35+
done := make(chan error, 1)
36+
go func() {
37+
cmd.Start()
38+
done <- cmd.Wait()
39+
}()
40+
select {
41+
case <-time.After(timeout):
42+
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
43+
log.Printf("ERROR: Failed to terminate job %s error: %s", cmd.Path, err)
44+
}
45+
log.Printf("Job %s timed out", cmd.Path)
46+
return nil
47+
case err := <-done:
48+
return err
49+
}
50+
}
51+
}

execution_test.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,20 @@ import (
44
"testing"
55
"github.com/stretchr/testify/assert"
66
"os"
7+
"time"
78
)
89

910
func TestExecutor(t *testing.T) {
1011
// TODO...
1112
jobExec := JobExecutorFromScript("./test_wrapper.sh", os.Stdout)
12-
jobExec("my job","/path/to/@ 1 @ @ @ @ my job.godoit")
13+
jobExec("my job","/path/to/@ 1 @ @ @ @ my job.godoit", noTimeout)
1314
assert.True(t, true, "Failed to parse job")
1415
}
16+
17+
func TestExecutorWithTimeout(t *testing.T) {
18+
jobExec := JobExecutorFromScript("./test_wrapper_sleep.sh", os.Stdout)
19+
start := time.Now()
20+
jobExec("my job","/path/to/@ 1 @ @ @ @ my job.godoit", time.Second * 3)
21+
duration := time.Since(start)
22+
assert.True(t, duration.Seconds() < 4.0, "Job took to long")
23+
}

job.go

Lines changed: 113 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,135 @@
11
package main
22

33
import (
4+
"path"
45
"path/filepath"
56
"strings"
67
"regexp"
8+
"time"
9+
"os"
10+
"bufio"
11+
"log"
12+
"github.com/robfig/cron"
13+
"fmt"
714
)
815

916
type Job struct {
1017
Filepath string
1118
Spec string
19+
Timezone *time.Location
1220
Name string
21+
Timeout time.Duration
22+
Enabled bool
23+
Errors []string
24+
UpdateTime time.Time
1325
}
1426

1527
var cronSpecRegex,_ = regexp.Compile(`\s*($|#|\w+\s*=|(x|\*|(?:[0-5]?\d)(?:(?:-|%|\,)(?:[0-5]?\d))?(?:,(?:[0-5]?\d)(?:(?:-|%|\,)(?:[0-5]?\d))?)*)\s+(x|\*|(?:[0-5]?\d)(?:(?:-|%|\,)(?:[0-5]?\d))?(?:,(?:[0-5]?\d)(?:(?:-|%|\,)(?:[0-5]?\d))?)*)\s+(x|\*|(?:[01]?\d|2[0-3])(?:(?:-|%|\,)(?:[01]?\d|2[0-3]))?(?:,(?:[01]?\d|2[0-3])(?:(?:-|%|\,)(?:[01]?\d|2[0-3]))?)*)\s+(x|\*|(?:0?[1-9]|[12]\d|3[01])(?:(?:-|%|\,)(?:0?[1-9]|[12]\d|3[01]))?(?:,(?:0?[1-9]|[12]\d|3[01])(?:(?:-|%|\,)(?:0?[1-9]|[12]\d|3[01]))?)*)\s+(x|\*|(?:[1-9]|1[012])(?:(?:-|%|\,)(?:[1-9]|1[012]))?(?:L|W)?(?:,(?:[1-9]|1[012])(?:(?:-|%|\,)(?:[1-9]|1[012]))?(?:L|W)?)*|x|\*|(?:JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)(?:(?:-)(?:JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC))?(?:,(?:JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)(?:(?:-)(?:JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC))?)*)\s+(x|\*|(?:[0-6])(?:(?:-|%|\,|#)(?:[0-6]))?(?:L)?(?:,(?:[0-6])(?:(?:-|%|\,|#)(?:[0-6]))?(?:L)?)*|x|\*|(?:MON|TUE|WED|THU|FRI|SAT|SUN)(?:(?:-)(?:MON|TUE|WED|THU|FRI|SAT|SUN))?(?:,(?:MON|TUE|WED|THU|FRI|SAT|SUN)(?:(?:-)(?:MON|TUE|WED|THU|FRI|SAT|SUN))?)*)(|\s)+(x|\*|(?:|\d{4})(?:(?:-|%|\,)(?:|\d{4}))?(?:,(?:|\d{4})(?:(?:-|%|\,)(?:|\d{4}))?)*)) (.*)\.godoit`)
28+
var noTimeout = time.Second * 0
29+
var GodoitFileSuffix = ".godoit"
30+
var godoitCommentPrefix = "#:godoit "
31+
32+
33+
func ParseJobFile(directory, filename string) *Job {
34+
jobPath := path.Join(directory, filename)
35+
var cronspec string
36+
var name string
37+
enabled :=
38+
!strings.HasPrefix(filename, "--") &&
39+
!strings.HasPrefix(filename, "#")
1640

17-
func ParseJobFile(path, filename string) *Job {
18-
// Return commented out files
19-
if strings.HasPrefix(filename, "--") {
20-
return nil
21-
}
2241
if result := cronSpecRegex.FindStringSubmatch(filename); result != nil {
23-
cronspec := strings.Replace(result[1], "x", "*", -1)
42+
cronspec = strings.Replace(result[1], "x", "*", -1)
2443
cronspec = strings.Replace(cronspec, "%", "/", -1)
25-
return &Job{filepath.Join(path, filename), cronspec, strings.TrimSpace(result[10])}
44+
name = strings.TrimSpace(result[10])
45+
} else if strings.HasSuffix(filename, GodoitFileSuffix) {
46+
name = strings.TrimSuffix(filename, GodoitFileSuffix)
2647
} else {
2748
return nil
2849
}
50+
51+
cronspec, timeout, timezone, errors, updateTime := parseJobParameters(jobPath, cronspec)
52+
53+
if cronspec == "" {
54+
errors = append(errors, "Missing cronspec")
55+
}
56+
57+
if len(errors) > 0 {
58+
enabled = false
59+
log.Printf("Errors parsing job %s: %v", jobPath, errors)
60+
}
61+
62+
return &Job{
63+
filepath.Join(directory, filename),
64+
cronspec,
65+
timezone,
66+
name,
67+
timeout,
68+
enabled,
69+
errors,
70+
updateTime}
71+
}
72+
73+
func parseJobParameters(jobPath, cronspec string) (string, time.Duration, *time.Location, []string, time.Time) {
74+
timeout := 0 * time.Second
75+
timezone := time.UTC
76+
var updateTime time.Time
77+
errors := make ([]string,0,10)
78+
79+
if file, err := os.Open(jobPath); err == nil {
80+
defer file.Close()
81+
if info, err := file.Stat() ; err == nil {
82+
updateTime = info.ModTime()
83+
}
84+
85+
// create a new scanner and read the file line by line
86+
scanner := bufio.NewScanner(file)
87+
i := 0
88+
for scanner.Scan() {
89+
// Only scan start of file for params...
90+
i++
91+
if i > 10 {
92+
break
93+
}
94+
95+
line := scanner.Text()
96+
if strings.HasPrefix(line, godoitCommentPrefix) {
97+
line = strings.TrimPrefix(line, godoitCommentPrefix)
98+
line = strings.TrimSpace(line)
99+
parts := strings.SplitN(line," ",2)
100+
if len(parts) == 2 {
101+
param := parts[0]
102+
value := parts[1]
103+
if param == "cronspec" {
104+
if cronspec != "" {
105+
errors = append(errors, "Cronspec in filename and as comment")
106+
}
107+
if _, err := cron.Parse(value); err == nil {
108+
cronspec = value
109+
} else {
110+
errors = append(errors, fmt.Sprintf("Invalid cronspec: '%s'", value))
111+
}
112+
} else if param == "timeout" {
113+
if d, err := time.ParseDuration(value); err == nil {
114+
timeout = d
115+
} else {
116+
errors = append(errors, fmt.Sprintf("Invalid timeout: '%s'", value))
117+
}
118+
} else if param == "timezone" {
119+
if l, err := time.LoadLocation(value); err == nil {
120+
timezone = l
121+
} else {
122+
errors = append(errors, fmt.Sprintf("Invalid timezone: '%s'", value))
123+
}
124+
}
125+
126+
} else {
127+
errors = append(errors, fmt.Sprintf("Invalid parameter '%s'", line))
128+
}
129+
}
130+
}
131+
} else {
132+
errors = append(errors, "Unable to open file to parse parameters")
133+
}
134+
return cronspec, timeout, timezone, errors, updateTime
29135
}

0 commit comments

Comments
 (0)