Skip to content

Commit

Permalink
Merge pull request #2 from krallin/master
Browse files Browse the repository at this point in the history
Initial PR
  • Loading branch information
krallin authored Jul 11, 2017
2 parents eb172ca + e99f294 commit 640f705
Show file tree
Hide file tree
Showing 20 changed files with 1,122 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
supercronic
vendor
35 changes: 35 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
sudo: false
dist: trusty

language: go

go: "1.8"

install:
- curl https://glide.sh/get | sh
- make deps
- git clone https://github.com/sstephenson/bats.git --branch v0.4.0 --depth 1 "${HOME}/bats"
- export "PATH=${PATH}:${HOME}/bats/bin"

jobs:
include:
- stage: test
script:
- make fmt && make test
- stage: build
script:
- mkdir -p dist
- for arch in amd64 386 arm arm64; do GOARCH="$arch" go build && mv supercronic "dist/supercronic-${arch}"; done
- cd dist
- ls -lah *
- file *
- sha1sum *
- sha256sum *
deploy:
provider: releases
api_key:
secure: "MPXVtn7XhcWkkDpDCcPqvfp1klsfGflsMrhBTH5WO2dUD352fJdbHTUi87Yuo+aFib+yNArzFn3cl9ZfUmD7vSSjmxqFjrgcIJT45Qqo4Y+T39JMUo7QuCsBUV4QbLlHMAyA3ZrcpWmpyGC5VgqiRN6D/XCTmB355fMfbF/Wov/shADiLLzYxDRkxUggx2nqBrG1Eo0JfS5Ji/MUbA5dhmOoDnf0YTsu2SWhS1nErj0HTe/j2/o7wG9aM1rQg6sU0DDTarPWNVJn6HNx0E65VzvfZH2v6g0rfLQ3sydeHdtvyS2KxBlCcp7ceJ2VHLuurR9IZTqH8GYA8GYAAZpo2oZF2esqH6tIpTIG5kJJy9Ybzw1o5Q3R3LAY18/IvdiUmfrMUy1Bkai1Lz1nHvcG5azwSOgrZ/hTbby1XPS4TdjbC7tyQXJ/u0ch+qxLOcIwKp/3DiE6nmMXJkCv4hf5YX/AYze2TKtm2uhE2qQF7kQ3tKi64nOBX9N3+mJVthS37JA8Zrak3D/5E4vtul87lahczOCNS2qYcET04Td77HJ1HEGgSnJETvnfG4+8LmLYoIqN3201Vsk585CNVpUY7PjYCxFBRadj7SfHmAq8mEQF7rpM8ELmBirkwta1QQq5Qma49ozCxWcnhnqw9NPRG3oNCrYsVC2APIrkvsOjVPo="
file: "dist/*"
skip_cleanup: true
on:
tags: true
21 changes: 21 additions & 0 deletions LICENSE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2017, Aptible, Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
27 changes: 27 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
GOFILES_NOVENDOR = $(shell find . -type f -name '*.go' -not -path "./vendor/*")
SHELL=/bin/bash

.PHONY: deps
deps:
glide install

.PHONY: build
build: $(GOFILES)
go build

.PHONY: unit
unit:
go test $$(go list ./... | grep -v /vendor/)
go vet $$(go list ./... | grep -v /vendor/)

.PHONY: integration
integration: build
bats integration

.PHONY: test
test: unit integration
true

.PHONY: fmt
fmt:
gofmt -l -w ${GOFILES_NOVENDOR}
152 changes: 152 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# Supercronic #

Supercronic is a crontab-compatible job runner, designed specifically to run in
containers.


## Why Supercronic? ##

Crontabs are the lingua franca of job scheduling, but typical server cron
implementations are ill-suited for container environments:

- They purge their environment before starting jobs. This is an important
security feature in multi-user systems, but it breaks a fundamental
configuration mechanism for containers.
- They capture the output from the jobs they run, and often either want to
email this output or simply discard it. In a containerized environment,
logging task output and errors to `stdout` / `stderr` is often easier to work
with.
- They often don't respond gracefully to `SIGINT` / `SIGTERM`, and may leave
running jobs orphaned when signaled. Again, this makes sense in a server
environment where `init` will handle the orphan jobs and Cron isn't restarted
often anyway, but it's inappropriate in a container environment as it'll
result in jobs being forcefully terminated (i.e. `SIGKILL`'ed) when the
container exits.
- They often try to send their logs to syslog. This conveniently provides
centralized logging when a syslog server is running, but with containers,
simply logging to `stdout` or `stderr` is preferred.

Finally, they are often very quiet, which makes the above issues difficult to
understand or debug!

The list could go on, but the fundamental takeaway is this: unlike typical
server cron implementations, Supercronic tries very hard to do exactly what you
expect from running `cron` in a container:

- Your environment variables are available in jobs.
- Job output is logged to `stdout` / `stderr`.
- `SIGTERM` (or `SIGINT`, which you can deliver via CTRL+C when used
interactively) triggers a graceful shutdown
- Job return codes and schedules are also logged to `stdout` / `stderr`.

## How does it work? ##

- Install Supercronic (see below).
- Point it at a crontab: `supercronic CRONTAB`.
- You're done!


### Installation

- If you have a `go` toolchain available: `go install github.com/aptible/supercronic`
- TODO: Docker installation instructions / packaging.


## Crontab format ##

Broadly speaking, Supercronic tries to process crontabs just like Vixie cron
does. In most cases, it should be compatible with your existing crontab.

There are, however, a few exceptions:

- First, Supercronic supports second-resolution schedules: under the hood,
Supercronic uses [the `cronexpr` package][cronexpr], so refer to its
documentation to know exactly what you can do.
- Second, Supercronic does not support changing users when running tasks.
Again, this is something that hardly makes sense in a cron environment. This
means that setting `USER` in your crontab won't have any effect.

Here's an example crontab:

```
# Run every minute
*/1 * * * * echo "hello"
# Run every 2 seconds
*/2 * * * * * * ls 2>/dev/null
```


## Environment variables ##

Just like regular cron, Supercronic lets you specify environment variables in
your crontab using a `KEY=VALUE` syntax.

However, this is only here for compatibility with existing crontabs, and using
this feature is generally **not recommended** when using Supercronic.

Indeed, Supercronic does not wipe your environment before running jobs, so if
you need environment variables to be available when your jobs run, just set
them before starting Supercronic itself, and your jobs will inherit them
(unless you've used cron before, this is exactly what you expect).

For example, if you're using Docker, Supercronic


## Logging ##

Supercronic provides rich logging, and will let you know exactly what command
triggered a given message. Here's an example:

```
$ cat ./my-crontab
*/5 * * * * * * echo "hello from Supercronic"
$ ./supercronic ./my-crontab
INFO[2017-07-10T19:40:44+02:00] read crontab: ./my-crontab
INFO[2017-07-10T19:40:50+02:00] starting iteration=0 job.command="echo "hello from Supercronic"" job.position=0 job.schedule="*/5 * * * * * *"
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 * * * * * *"
INFO[2017-07-10T19:40:50+02:00] job succeeded iteration=0 job.command="echo "hello from Supercronic"" job.position=0 job.schedule="*/5 * * * * * *"
INFO[2017-07-10T19:40:55+02:00] starting iteration=1 job.command="echo "hello from Supercronic"" job.position=0 job.schedule="*/5 * * * * * *"
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 * * * * * *"
INFO[2017-07-10T19:40:55+02:00] job succeeded iteration=1 job.command="echo "hello from Supercronic"" job.position=0 job.schedule="*/5 * * * * * *"
```


## Debugging ##

If your jobs aren't running, or you'd simply like to double-check your crontab
syntax, pass the `-debug` flag for more verbose logging:

```
$ ./supercronic -debug ./my-crontab
INFO[2017-07-10T19:43:51+02:00] read crontab: ./my-crontab
DEBU[2017-07-10T19:43:51+02:00] try parse(7): */5 * * * * * * echo "hello from Supercronic"[0:15] = */5 * * * * * *
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 * * * * * *"
```


## Duplicate Jobs ##

Supercronic will wait for a given job to finish before that job is scheduled
again (some cron implementations do this, others don't). If a job is falling
behind schedule (i.e. it's taking too long to finish), Supercronic will warn
you.

Here is an example:

```
# Sleep for 2 seconds every second. This will take too long.
* * * * * * * sleep 2
$ ./supercronic ./my-crontab
INFO[2017-07-11T12:24:25+02:00] read crontab: foo
INFO[2017-07-11T12:24:27+02:00] starting iteration=0 job.command="sleep 2" job.position=0 job.schedule="* * * * * * *"
INFO[2017-07-11T12:24:29+02:00] job succeeded iteration=0 job.command="sleep 2" job.position=0 job.schedule="* * * * * * *"
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="* * * * * * *"
INFO[2017-07-11T12:24:30+02:00] starting iteration=1 job.command="sleep 2" job.position=0 job.schedule="* * * * * * *"
INFO[2017-07-11T12:24:32+02:00] job succeeded iteration=1 job.command="sleep 2" job.position=0 job.schedule="* * * * * * *"
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="* * * * * * *"
```

[cronexpr]: https://github.com/gorhill/cronexpr
139 changes: 139 additions & 0 deletions cron/cron.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package cron

import (
"bufio"
"fmt"
"github.com/aptible/supercronic/crontab"
"github.com/sirupsen/logrus"
"io"
"os"
"os/exec"
"strings"
"sync"
"syscall"
"time"
)

func startReaderDrain(wg *sync.WaitGroup, readerLogger *logrus.Entry, reader io.Reader) {
wg.Add(1)

go func() {
defer wg.Done()

scanner := bufio.NewScanner(reader)

for scanner.Scan() {
readerLogger.Info(scanner.Text())
}

if err := scanner.Err(); err != nil {
// The underlying reader might get closed by e.g. Wait(), or
// even the process we're starting, so we don't log EOF-like
// errors
if strings.Contains(err.Error(), os.ErrClosed.Error()) {
return
}

readerLogger.Error(err)
}
}()
}

func runJob(context *crontab.Context, command string, jobLogger *logrus.Entry) error {
jobLogger.Info("starting")

cmd := exec.Command(context.Shell, "-c", command)

// Run in a separate process group so that in interactive usage, CTRL+C
// stops supercronic, not the children threads.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}

env := os.Environ()
for k, v := range context.Environ {
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
cmd.Env = env

stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}

stderr, err := cmd.StderrPipe()
if err != nil {
return err
}

if err := cmd.Start(); err != nil {
return err
}

var wg sync.WaitGroup

stdoutLogger := jobLogger.WithFields(logrus.Fields{"channel": "stdout"})
startReaderDrain(&wg, stdoutLogger, stdout)

stderrLogger := jobLogger.WithFields(logrus.Fields{"channel": "stderr"})
startReaderDrain(&wg, stderrLogger, stderr)

wg.Wait()

if err := cmd.Wait(); err != nil {
return err
}

return nil
}

func StartJob(wg *sync.WaitGroup, context *crontab.Context, job *crontab.Job, exitChan chan interface{}) {
wg.Add(1)

go func() {
defer wg.Done()

cronLogger := logrus.WithFields(logrus.Fields{
"job.schedule": job.Schedule,
"job.command": job.Command,
"job.position": job.Position,
})

var cronIteration uint64 = 0
nextRun := job.Expression.Next(time.Now())

// NOTE: this (intentionally) does not run multiple instances of the
// job concurrently
for {
nextRun = job.Expression.Next(nextRun)
cronLogger.Debugf("job will run next at %v", nextRun)

delay := nextRun.Sub(time.Now())
if delay < 0 {
cronLogger.Warningf("job took too long to run: it should have started %v ago", -delay)
nextRun = time.Now()
continue
}

select {
case <-exitChan:
cronLogger.Debug("shutting down")
return
case <-time.After(delay):
// Proceed normally
}

jobLogger := cronLogger.WithFields(logrus.Fields{
"iteration": cronIteration,
})

err := runJob(context, job.Command, jobLogger)

if err == nil {
jobLogger.Info("job succeeded")
} else {
jobLogger.Error(err)
}

cronIteration++
}
}()
}
Loading

0 comments on commit 640f705

Please sign in to comment.