Skip to content

Commit 640f705

Browse files
authoredJul 11, 2017
Merge pull request aptible#2 from krallin/master
Initial PR
2 parents eb172ca + e99f294 commit 640f705

20 files changed

+1122
-0
lines changed
 

‎.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
supercronic
2+
vendor

‎.travis.yml

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
sudo: false
2+
dist: trusty
3+
4+
language: go
5+
6+
go: "1.8"
7+
8+
install:
9+
- curl https://glide.sh/get | sh
10+
- make deps
11+
- git clone https://github.com/sstephenson/bats.git --branch v0.4.0 --depth 1 "${HOME}/bats"
12+
- export "PATH=${PATH}:${HOME}/bats/bin"
13+
14+
jobs:
15+
include:
16+
- stage: test
17+
script:
18+
- make fmt && make test
19+
- stage: build
20+
script:
21+
- mkdir -p dist
22+
- for arch in amd64 386 arm arm64; do GOARCH="$arch" go build && mv supercronic "dist/supercronic-${arch}"; done
23+
- cd dist
24+
- ls -lah *
25+
- file *
26+
- sha1sum *
27+
- sha256sum *
28+
deploy:
29+
provider: releases
30+
api_key:
31+
secure: "MPXVtn7XhcWkkDpDCcPqvfp1klsfGflsMrhBTH5WO2dUD352fJdbHTUi87Yuo+aFib+yNArzFn3cl9ZfUmD7vSSjmxqFjrgcIJT45Qqo4Y+T39JMUo7QuCsBUV4QbLlHMAyA3ZrcpWmpyGC5VgqiRN6D/XCTmB355fMfbF/Wov/shADiLLzYxDRkxUggx2nqBrG1Eo0JfS5Ji/MUbA5dhmOoDnf0YTsu2SWhS1nErj0HTe/j2/o7wG9aM1rQg6sU0DDTarPWNVJn6HNx0E65VzvfZH2v6g0rfLQ3sydeHdtvyS2KxBlCcp7ceJ2VHLuurR9IZTqH8GYA8GYAAZpo2oZF2esqH6tIpTIG5kJJy9Ybzw1o5Q3R3LAY18/IvdiUmfrMUy1Bkai1Lz1nHvcG5azwSOgrZ/hTbby1XPS4TdjbC7tyQXJ/u0ch+qxLOcIwKp/3DiE6nmMXJkCv4hf5YX/AYze2TKtm2uhE2qQF7kQ3tKi64nOBX9N3+mJVthS37JA8Zrak3D/5E4vtul87lahczOCNS2qYcET04Td77HJ1HEGgSnJETvnfG4+8LmLYoIqN3201Vsk585CNVpUY7PjYCxFBRadj7SfHmAq8mEQF7rpM8ELmBirkwta1QQq5Qma49ozCxWcnhnqw9NPRG3oNCrYsVC2APIrkvsOjVPo="
32+
file: "dist/*"
33+
skip_cleanup: true
34+
on:
35+
tags: true

‎LICENSE.md

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2017, Aptible, Inc.
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy of
6+
this software and associated documentation files (the "Software"), to deal in
7+
the Software without restriction, including without limitation the rights to
8+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
9+
of the Software, and to permit persons to whom the Software is furnished to do
10+
so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

‎Makefile

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
GOFILES_NOVENDOR = $(shell find . -type f -name '*.go' -not -path "./vendor/*")
2+
SHELL=/bin/bash
3+
4+
.PHONY: deps
5+
deps:
6+
glide install
7+
8+
.PHONY: build
9+
build: $(GOFILES)
10+
go build
11+
12+
.PHONY: unit
13+
unit:
14+
go test $$(go list ./... | grep -v /vendor/)
15+
go vet $$(go list ./... | grep -v /vendor/)
16+
17+
.PHONY: integration
18+
integration: build
19+
bats integration
20+
21+
.PHONY: test
22+
test: unit integration
23+
true
24+
25+
.PHONY: fmt
26+
fmt:
27+
gofmt -l -w ${GOFILES_NOVENDOR}

‎README.md

+152
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# Supercronic #
2+
3+
Supercronic is a crontab-compatible job runner, designed specifically to run in
4+
containers.
5+
6+
7+
## Why Supercronic? ##
8+
9+
Crontabs are the lingua franca of job scheduling, but typical server cron
10+
implementations are ill-suited for container environments:
11+
12+
- They purge their environment before starting jobs. This is an important
13+
security feature in multi-user systems, but it breaks a fundamental
14+
configuration mechanism for containers.
15+
- They capture the output from the jobs they run, and often either want to
16+
email this output or simply discard it. In a containerized environment,
17+
logging task output and errors to `stdout` / `stderr` is often easier to work
18+
with.
19+
- They often don't respond gracefully to `SIGINT` / `SIGTERM`, and may leave
20+
running jobs orphaned when signaled. Again, this makes sense in a server
21+
environment where `init` will handle the orphan jobs and Cron isn't restarted
22+
often anyway, but it's inappropriate in a container environment as it'll
23+
result in jobs being forcefully terminated (i.e. `SIGKILL`'ed) when the
24+
container exits.
25+
- They often try to send their logs to syslog. This conveniently provides
26+
centralized logging when a syslog server is running, but with containers,
27+
simply logging to `stdout` or `stderr` is preferred.
28+
29+
Finally, they are often very quiet, which makes the above issues difficult to
30+
understand or debug!
31+
32+
The list could go on, but the fundamental takeaway is this: unlike typical
33+
server cron implementations, Supercronic tries very hard to do exactly what you
34+
expect from running `cron` in a container:
35+
36+
- Your environment variables are available in jobs.
37+
- Job output is logged to `stdout` / `stderr`.
38+
- `SIGTERM` (or `SIGINT`, which you can deliver via CTRL+C when used
39+
interactively) triggers a graceful shutdown
40+
- Job return codes and schedules are also logged to `stdout` / `stderr`.
41+
42+
## How does it work? ##
43+
44+
- Install Supercronic (see below).
45+
- Point it at a crontab: `supercronic CRONTAB`.
46+
- You're done!
47+
48+
49+
### Installation
50+
51+
- If you have a `go` toolchain available: `go install github.com/aptible/supercronic`
52+
- TODO: Docker installation instructions / packaging.
53+
54+
55+
## Crontab format ##
56+
57+
Broadly speaking, Supercronic tries to process crontabs just like Vixie cron
58+
does. In most cases, it should be compatible with your existing crontab.
59+
60+
There are, however, a few exceptions:
61+
62+
- First, Supercronic supports second-resolution schedules: under the hood,
63+
Supercronic uses [the `cronexpr` package][cronexpr], so refer to its
64+
documentation to know exactly what you can do.
65+
- Second, Supercronic does not support changing users when running tasks.
66+
Again, this is something that hardly makes sense in a cron environment. This
67+
means that setting `USER` in your crontab won't have any effect.
68+
69+
Here's an example crontab:
70+
71+
```
72+
# Run every minute
73+
*/1 * * * * echo "hello"
74+
75+
# Run every 2 seconds
76+
*/2 * * * * * * ls 2>/dev/null
77+
```
78+
79+
80+
## Environment variables ##
81+
82+
Just like regular cron, Supercronic lets you specify environment variables in
83+
your crontab using a `KEY=VALUE` syntax.
84+
85+
However, this is only here for compatibility with existing crontabs, and using
86+
this feature is generally **not recommended** when using Supercronic.
87+
88+
Indeed, Supercronic does not wipe your environment before running jobs, so if
89+
you need environment variables to be available when your jobs run, just set
90+
them before starting Supercronic itself, and your jobs will inherit them
91+
(unless you've used cron before, this is exactly what you expect).
92+
93+
For example, if you're using Docker, Supercronic
94+
95+
96+
## Logging ##
97+
98+
Supercronic provides rich logging, and will let you know exactly what command
99+
triggered a given message. Here's an example:
100+
101+
```
102+
$ cat ./my-crontab
103+
*/5 * * * * * * echo "hello from Supercronic"
104+
105+
$ ./supercronic ./my-crontab
106+
INFO[2017-07-10T19:40:44+02:00] read crontab: ./my-crontab
107+
INFO[2017-07-10T19:40:50+02:00] starting iteration=0 job.command="echo "hello from Supercronic"" job.position=0 job.schedule="*/5 * * * * * *"
108+
INFO[2017-07-10T19:40:50+02:00] hello from Supercronic channel=stdout iteration=0 job.command="echo "hello from Supercronic"" job.position=0 job.schedule="*/5 * * * * * *"
109+
INFO[2017-07-10T19:40:50+02:00] job succeeded iteration=0 job.command="echo "hello from Supercronic"" job.position=0 job.schedule="*/5 * * * * * *"
110+
INFO[2017-07-10T19:40:55+02:00] starting iteration=1 job.command="echo "hello from Supercronic"" job.position=0 job.schedule="*/5 * * * * * *"
111+
INFO[2017-07-10T19:40:55+02:00] hello from Supercronic channel=stdout iteration=1 job.command="echo "hello from Supercronic"" job.position=0 job.schedule="*/5 * * * * * *"
112+
INFO[2017-07-10T19:40:55+02:00] job succeeded iteration=1 job.command="echo "hello from Supercronic"" job.position=0 job.schedule="*/5 * * * * * *"
113+
```
114+
115+
116+
## Debugging ##
117+
118+
If your jobs aren't running, or you'd simply like to double-check your crontab
119+
syntax, pass the `-debug` flag for more verbose logging:
120+
121+
```
122+
$ ./supercronic -debug ./my-crontab
123+
INFO[2017-07-10T19:43:51+02:00] read crontab: ./my-crontab
124+
DEBU[2017-07-10T19:43:51+02:00] try parse(7): */5 * * * * * * echo "hello from Supercronic"[0:15] = */5 * * * * * *
125+
DEBU[2017-07-10T19:43:51+02:00] job will run next at 2017-07-10 19:44:00 +0200 CEST job.command="echo "hello from Supercronic"" job.position=0 job.schedule="*/5 * * * * * *"
126+
```
127+
128+
129+
## Duplicate Jobs ##
130+
131+
Supercronic will wait for a given job to finish before that job is scheduled
132+
again (some cron implementations do this, others don't). If a job is falling
133+
behind schedule (i.e. it's taking too long to finish), Supercronic will warn
134+
you.
135+
136+
Here is an example:
137+
138+
```
139+
# Sleep for 2 seconds every second. This will take too long.
140+
* * * * * * * sleep 2
141+
142+
$ ./supercronic ./my-crontab
143+
INFO[2017-07-11T12:24:25+02:00] read crontab: foo
144+
INFO[2017-07-11T12:24:27+02:00] starting iteration=0 job.command="sleep 2" job.position=0 job.schedule="* * * * * * *"
145+
INFO[2017-07-11T12:24:29+02:00] job succeeded iteration=0 job.command="sleep 2" job.position=0 job.schedule="* * * * * * *"
146+
WARN[2017-07-11T12:24:29+02:00] job took too long to run: it should have started 1.009438854s ago job.command="sleep 2" job.position=0 job.schedule="* * * * * * *"
147+
INFO[2017-07-11T12:24:30+02:00] starting iteration=1 job.command="sleep 2" job.position=0 job.schedule="* * * * * * *"
148+
INFO[2017-07-11T12:24:32+02:00] job succeeded iteration=1 job.command="sleep 2" job.position=0 job.schedule="* * * * * * *"
149+
WARN[2017-07-11T12:24:32+02:00] job took too long to run: it should have started 1.014474099s ago job.command="sleep 2" job.position=0 job.schedule="* * * * * * *"
150+
```
151+
152+
[cronexpr]: https://github.com/gorhill/cronexpr

‎cron/cron.go

+139
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package cron
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"github.com/aptible/supercronic/crontab"
7+
"github.com/sirupsen/logrus"
8+
"io"
9+
"os"
10+
"os/exec"
11+
"strings"
12+
"sync"
13+
"syscall"
14+
"time"
15+
)
16+
17+
func startReaderDrain(wg *sync.WaitGroup, readerLogger *logrus.Entry, reader io.Reader) {
18+
wg.Add(1)
19+
20+
go func() {
21+
defer wg.Done()
22+
23+
scanner := bufio.NewScanner(reader)
24+
25+
for scanner.Scan() {
26+
readerLogger.Info(scanner.Text())
27+
}
28+
29+
if err := scanner.Err(); err != nil {
30+
// The underlying reader might get closed by e.g. Wait(), or
31+
// even the process we're starting, so we don't log EOF-like
32+
// errors
33+
if strings.Contains(err.Error(), os.ErrClosed.Error()) {
34+
return
35+
}
36+
37+
readerLogger.Error(err)
38+
}
39+
}()
40+
}
41+
42+
func runJob(context *crontab.Context, command string, jobLogger *logrus.Entry) error {
43+
jobLogger.Info("starting")
44+
45+
cmd := exec.Command(context.Shell, "-c", command)
46+
47+
// Run in a separate process group so that in interactive usage, CTRL+C
48+
// stops supercronic, not the children threads.
49+
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
50+
51+
env := os.Environ()
52+
for k, v := range context.Environ {
53+
env = append(env, fmt.Sprintf("%s=%s", k, v))
54+
}
55+
cmd.Env = env
56+
57+
stdout, err := cmd.StdoutPipe()
58+
if err != nil {
59+
return err
60+
}
61+
62+
stderr, err := cmd.StderrPipe()
63+
if err != nil {
64+
return err
65+
}
66+
67+
if err := cmd.Start(); err != nil {
68+
return err
69+
}
70+
71+
var wg sync.WaitGroup
72+
73+
stdoutLogger := jobLogger.WithFields(logrus.Fields{"channel": "stdout"})
74+
startReaderDrain(&wg, stdoutLogger, stdout)
75+
76+
stderrLogger := jobLogger.WithFields(logrus.Fields{"channel": "stderr"})
77+
startReaderDrain(&wg, stderrLogger, stderr)
78+
79+
wg.Wait()
80+
81+
if err := cmd.Wait(); err != nil {
82+
return err
83+
}
84+
85+
return nil
86+
}
87+
88+
func StartJob(wg *sync.WaitGroup, context *crontab.Context, job *crontab.Job, exitChan chan interface{}) {
89+
wg.Add(1)
90+
91+
go func() {
92+
defer wg.Done()
93+
94+
cronLogger := logrus.WithFields(logrus.Fields{
95+
"job.schedule": job.Schedule,
96+
"job.command": job.Command,
97+
"job.position": job.Position,
98+
})
99+
100+
var cronIteration uint64 = 0
101+
nextRun := job.Expression.Next(time.Now())
102+
103+
// NOTE: this (intentionally) does not run multiple instances of the
104+
// job concurrently
105+
for {
106+
nextRun = job.Expression.Next(nextRun)
107+
cronLogger.Debugf("job will run next at %v", nextRun)
108+
109+
delay := nextRun.Sub(time.Now())
110+
if delay < 0 {
111+
cronLogger.Warningf("job took too long to run: it should have started %v ago", -delay)
112+
nextRun = time.Now()
113+
continue
114+
}
115+
116+
select {
117+
case <-exitChan:
118+
cronLogger.Debug("shutting down")
119+
return
120+
case <-time.After(delay):
121+
// Proceed normally
122+
}
123+
124+
jobLogger := cronLogger.WithFields(logrus.Fields{
125+
"iteration": cronIteration,
126+
})
127+
128+
err := runJob(context, job.Command, jobLogger)
129+
130+
if err == nil {
131+
jobLogger.Info("job succeeded")
132+
} else {
133+
jobLogger.Error(err)
134+
}
135+
136+
cronIteration++
137+
}
138+
}()
139+
}

0 commit comments

Comments
 (0)
Please sign in to comment.