├── main.go ├── systemd ├── templates │ ├── timer.tmpl │ └── service.tmpl ├── systemd.go ├── daemon.go ├── unit_test.go └── unit.go ├── glide.yaml ├── CHANGELOG.md ├── sample.cron ├── version.go ├── .gitignore ├── .travis.yml ├── glide.lock ├── testdata └── crontab ├── Vagrantfile ├── LICENSE ├── Makefile ├── util.go ├── README.md ├── crontab ├── crontab.go └── crontab_test.go └── cli.go /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | func main() { 8 | os.Exit(run(os.Args[1:])) 9 | } 10 | -------------------------------------------------------------------------------- /systemd/templates/timer.tmpl: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description={{ .Name }} timer unit 3 | 4 | [Timer] 5 | OnCalendar={{ .Cronspec }} 6 | -------------------------------------------------------------------------------- /systemd/systemd.go: -------------------------------------------------------------------------------- 1 | package systemd 2 | 3 | //go:generate go-bindata -pkg $GOPACKAGE templates/ 4 | 5 | const ( 6 | // DefaultUnitsDirectory represents the directory of user-defined systemd units 7 | DefaultUnitsDirectory = "/run/systemd/system" 8 | ) 9 | -------------------------------------------------------------------------------- /systemd/templates/service.tmpl: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description={{ .Name }} service unit{{ if .After }} 3 | After={{ .After }}{{ end }} 4 | 5 | [Service] 6 | TimeoutStartSec=0 7 | ExecStart={{ .Command }} 8 | Type=oneshot{{ if .User }} 9 | User={{ .User }}{{ end }} 10 | -------------------------------------------------------------------------------- /glide.yaml: -------------------------------------------------------------------------------- 1 | package: github.com/dtan4/ct2stimer 2 | import: 3 | - package: github.com/robfig/cron 4 | - package: github.com/spf13/pflag 5 | - package: github.com/coreos/go-systemd 6 | version: v14 7 | subpackages: 8 | - dbus 9 | - package: github.com/pkg/errors 10 | version: ~0.8.0 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [v0.2.0](https://github.com/dtan4/ct2stimer/releases/tag/v0.2.0) (2017-02-23) 2 | 3 | ## Features 4 | 5 | - Set username who executes process [#3](https://github.com/dtan4/ct2stimer/pull/3) 6 | 7 | # [v0.1.0](https://github.com/dtan4/ct2stimer/releases/tag/v0.1.0) (2017-01-30) 8 | 9 | Initial release. 10 | -------------------------------------------------------------------------------- /sample.cron: -------------------------------------------------------------------------------- 1 | # This is comment line 2 | */5 * * * * /bin/echo "Hello" 3 | 0,5,10,15,20,25,30,35,40,45,50,55 10-12 * * * /bin/echo -n "Hello 2" 4 | 5 | 0-5 * 1 * * /bin/echo "Hello 3" 6 | 23 2,1 * 12 1,6 /bin/bash -l -c 'echo "Hello 4"' 7 | 8 | 9 | # 0,20,40 8-17 * * 1-5 echo "Hello 4.5" 10 | 0,20,40 8-17 * * 1-5 /bin/echo "Hello 5" 11 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | var ( 8 | // Version represents version number 9 | Version string 10 | // Revision represents the git commit hash at build time 11 | Revision string 12 | ) 13 | 14 | func printVersion() { 15 | fmt.Println("ct2stimer version " + Version + ", build " + Revision) 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | 26 | /bin 27 | /dist 28 | /vendor 29 | 30 | /.vagrant 31 | /ubuntu-xenial-16.04-cloudimg-console.log 32 | 33 | bindata.go 34 | 35 | /coverage.txt 36 | 37 | /tmp 38 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - '1.8' 4 | before_install: 5 | - sudo add-apt-repository ppa:masterminds/glide -y 6 | - sudo apt-get update -q 7 | - sudo apt-get install glide -y 8 | - mkdir -p $GOPATH/bin 9 | install: 10 | - make deps 11 | before_script: 12 | - make generate 13 | script: 14 | - make ci-test 15 | after_success: 16 | - bash <(curl -s https://codecov.io/bash) 17 | before_deploy: 18 | - make cross-build 19 | - make dist 20 | deploy: 21 | provider: releases 22 | skip_cleanup: true 23 | api_key: $GITHUB_TOKEN 24 | file_glob: true 25 | file: 'dist/*.{tar.gz,zip}' 26 | on: 27 | tags: true 28 | -------------------------------------------------------------------------------- /glide.lock: -------------------------------------------------------------------------------- 1 | hash: 6022a5b9ffc89b189803ce3edb3d2369ec721d2fafd78f1fc2e37ba5fe67b769 2 | updated: 2017-01-30T16:36:05.816020706+09:00 3 | imports: 4 | - name: github.com/coreos/go-systemd 5 | version: 48702e0da86bd25e76cfef347e2adeb434a0d0a6 6 | subpackages: 7 | - dbus 8 | - name: github.com/godbus/dbus 9 | version: 4b24ebee04561bf8a3bcc09aead82062edc56778 10 | - name: github.com/pkg/errors 11 | version: 645ef00459ed84a119197bfb8d8205042c6df63d 12 | - name: github.com/robfig/cron 13 | version: 9585fd555638e77bba25f25db5c44b41f264aeb7 14 | - name: github.com/spf13/pflag 15 | version: 25f8b5b07aece3207895bf19f7ab517eb3b22a40 16 | testImports: [] 17 | -------------------------------------------------------------------------------- /testdata/crontab: -------------------------------------------------------------------------------- 1 | 0,5,10,15,20,25,30,35,40,45,50,55 * * * * /bin/bash -l -c 'docker run --rm=true --name scheduler.task01.`date +\%Y\%m\%d\%H\%M` --memory=5g 123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/app:latest bundle exec rake task01 RAILS_ENV=production' 2 | 3 | 15 * * * * /bin/bash -l -c 'docker run --rm=true --name scheduler.task02.`date +\%Y\%m\%d\%H\%M` --memory=5g 123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/app:latest bundle exec rake task02 RAILS_ENV=production' 4 | 5 | # 10 * * * * /bin/bash -l -c 'docker run --rm=true --name scheduler.task03.`date +\%Y\%m\%d\%H\%M` --memory=5g 123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/app:latest bundle exec rake task03 RAILS_ENV=production' 6 | 7 | 8 | 9 | # This is a comment line 10 | 30 * * * * /bin/bash -l -c 'docker run --rm=true --name scheduler.task04.`date +\%Y\%m\%d\%H\%M` --memory=5g 123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/app:latest bundle exec rake task04 RAILS_ENV=production' 11 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | Vagrant.configure("2") do |config| 5 | config.vm.box = "ubuntu/xenial64" 6 | 7 | config.vm.synced_folder Dir.pwd, "/home/ubuntu/src/github.com/dtan4/ct2stimer" 8 | 9 | config.vm.provision "shell", inline: <<-SHELL 10 | wget -q https://storage.googleapis.com/golang/go1.7.4.linux-amd64.tar.gz 11 | tar zxf go1.7.4.linux-amd64.tar.gz -C /usr/local 12 | rm go1.7.4.linux-amd64.tar.gz 13 | echo 'export GOROOT=/usr/local/go' >> /home/ubuntu/.bashrc 14 | echo 'export GOPATH=$HOME' >> /home/ubuntu/.bashrc 15 | echo 'export PATH=$PATH:$GOROOT/bin:$GOPATH/bin' >> /home/ubuntu/.bashrc 16 | mkdir -p /home/ubuntu/bin && chown -R ubuntu:ubuntu /home/ubuntu/bin 17 | mkdir -p /home/ubuntu/src && chown -R ubuntu:ubuntu /home/ubuntu/src 18 | add-apt-repository ppa:masterminds/glide 19 | apt-get update 20 | apt-get install -y cmake glide 21 | echo 'cd /home/ubuntu/src/github.com/dtan4/ct2stimer' >> /home/ubuntu/.bashrc 22 | SHELL 23 | end 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Daisuke Fujita 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do 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. 22 | -------------------------------------------------------------------------------- /systemd/daemon.go: -------------------------------------------------------------------------------- 1 | package systemd 2 | 3 | import ( 4 | "github.com/coreos/go-systemd/dbus" 5 | "github.com/pkg/errors" 6 | ) 7 | 8 | // Client represents systemd D-Bus API client. 9 | type Client struct { 10 | conn *dbus.Conn 11 | } 12 | 13 | // NewClient creates new Client object 14 | func NewClient(conn *dbus.Conn) *Client { 15 | return &Client{ 16 | conn: conn, 17 | } 18 | } 19 | 20 | // NewConn establishes a new connection to D-Bus 21 | func NewConn() (*dbus.Conn, error) { 22 | return dbus.New() 23 | } 24 | 25 | // StartUnit starts the given systemd unit file 26 | func (c *Client) StartUnit(name string) error { 27 | ch := make(chan string) 28 | 29 | if _, err := c.conn.StartUnit(name, "replace", ch); err != nil { 30 | return errors.Wrapf(err, "failed to start systemd unit %q", name) 31 | } 32 | 33 | if job := <-ch; job != "done" { 34 | return errors.Errorf("cannot start service %q, current status is %q", name, job) 35 | } 36 | 37 | return nil 38 | } 39 | 40 | // Reload reloads systemd unit files 41 | func (c *Client) Reload() error { 42 | if err := c.conn.Reload(); err != nil { 43 | return errors.Wrap(err, "failed to reload systemd unit files") 44 | } 45 | 46 | return nil 47 | } 48 | -------------------------------------------------------------------------------- /systemd/unit_test.go: -------------------------------------------------------------------------------- 1 | package systemd 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestGenerateService(t *testing.T) { 8 | testcases := []struct { 9 | name string 10 | command string 11 | after string 12 | user string 13 | expected string 14 | }{ 15 | { 16 | name: "ct2stimer", 17 | command: "/bin/bash docker run --rm ubuntu:16.04 echo hello", 18 | after: "docker.service", 19 | user: "", 20 | expected: `[Unit] 21 | Description=ct2stimer service unit 22 | After=docker.service 23 | 24 | [Service] 25 | TimeoutStartSec=0 26 | ExecStart=/bin/bash docker run --rm ubuntu:16.04 echo hello 27 | Type=oneshot 28 | `, 29 | }, 30 | { 31 | name: "ct2stimer", 32 | command: "/bin/bash docker run --rm ubuntu:16.04 echo hello", 33 | after: "", 34 | user: "core", 35 | expected: `[Unit] 36 | Description=ct2stimer service unit 37 | 38 | [Service] 39 | TimeoutStartSec=0 40 | ExecStart=/bin/bash docker run --rm ubuntu:16.04 echo hello 41 | Type=oneshot 42 | User=core 43 | `, 44 | }, 45 | } 46 | 47 | for _, tc := range testcases { 48 | got, err := GenerateService(tc.name, tc.command, tc.after, tc.user) 49 | if err != nil { 50 | t.Errorf("Error should not be raised. error: %s", err) 51 | } 52 | 53 | if got != tc.expected { 54 | t.Errorf("Service does not match.\n\nexpected:\n%s\n\ngot:\n%s", tc.expected, got) 55 | } 56 | } 57 | } 58 | 59 | func TestGenerateTimer(t *testing.T) { 60 | name := "ct2stimer" 61 | cronspec := "30 * * * *" 62 | expected := `[Unit] 63 | Description=ct2stimer timer unit 64 | 65 | [Timer] 66 | OnCalendar=30 * * * * 67 | ` 68 | 69 | got, err := GenerateTimer(name, cronspec) 70 | if err != nil { 71 | t.Errorf("Error should not be raised. error: %s", err) 72 | } 73 | 74 | if got != expected { 75 | t.Errorf("Timer does not match.\n\nexpected:\n%s\n\ngot:\n%s", expected, got) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /systemd/unit.go: -------------------------------------------------------------------------------- 1 | package systemd 2 | 3 | import ( 4 | "bytes" 5 | "text/template" 6 | 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | // ServiceData represents data set of systemd Service 11 | type ServiceData struct { 12 | Name string 13 | Command string 14 | After string 15 | User string 16 | } 17 | 18 | // TimerData represents data set of systemd Timer 19 | type TimerData struct { 20 | Name string 21 | Cronspec string 22 | } 23 | 24 | // GenerateService generates new systemd Service 25 | func GenerateService(name, command, after, user string) (string, error) { 26 | body, err := Asset("templates/service.tmpl") 27 | if err != nil { 28 | return "", errors.Wrap(err, "failed to load service template") 29 | } 30 | 31 | tmpl, err := template.New("ct2stimer-service").Parse(string(body)) 32 | if err != nil { 33 | return "", errors.Wrap(err, "failed to parse service template") 34 | } 35 | 36 | var buf bytes.Buffer 37 | 38 | if err := tmpl.Execute(&buf, &ServiceData{ 39 | Name: name, 40 | Command: command, 41 | After: after, 42 | User: user, 43 | }); err != nil { 44 | return "", errors.Wrap(err, "failed to dispatch values in service template") 45 | } 46 | 47 | return buf.String(), nil 48 | } 49 | 50 | // GenerateTimer generates new systemd Timer 51 | func GenerateTimer(name, cronspec string) (string, error) { 52 | body, err := Asset("templates/timer.tmpl") 53 | if err != nil { 54 | return "", errors.Wrap(err, "failed to load timer template") 55 | } 56 | 57 | tmpl, err := template.New("ct2stimer-timer").Parse(string(body)) 58 | if err != nil { 59 | return "", errors.Wrap(err, "failed to parse timer template") 60 | } 61 | 62 | var buf bytes.Buffer 63 | 64 | if err := tmpl.Execute(&buf, &TimerData{ 65 | Name: name, 66 | Cronspec: cronspec, 67 | }); err != nil { 68 | return "", errors.Wrap(err, "failed to dispatch values in timer template") 69 | } 70 | 71 | return buf.String(), nil 72 | } 73 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NAME := ct2stimer 2 | VERSION := v0.2.0 3 | REVISION := $(shell git rev-parse --short HEAD) 4 | 5 | SRCS := $(shell find . -type f -name '*.go') 6 | TEMPLATES := $(shell find . -type f -name '*.tmpl') 7 | LDFLAGS := -ldflags="-s -w -X \"main.Version=$(VERSION)\" -X \"main.Revision=$(REVISION)\" -extldflags \"-static\"" 8 | 9 | DIST_DIRS := find * -type d -exec 10 | 11 | .DEFAULT_GOAL := bin/$(NAME) 12 | 13 | bin/$(NAME): $(SRCS) $(TEMPLATES) 14 | $(MAKE) generate 15 | go build $(LDFLAGS) -o bin/$(NAME) 16 | 17 | .PHONY: ci-test 18 | ci-test: 19 | echo "" > coverage.txt 20 | for d in `glide novendor`; do \ 21 | go test -coverprofile=profile.out -covermode=atomic -v $$d || break; \ 22 | if [ -f profile.out ]; then \ 23 | cat profile.out >> coverage.txt; \ 24 | rm profile.out; \ 25 | fi; \ 26 | done 27 | 28 | .PHONY: clean 29 | clean: 30 | rm -rf bin/* 31 | rm -rf vendor/* 32 | 33 | .PHONY: cross-build 34 | cross-build: generate 35 | for os in linux; do \ 36 | for arch in amd64 386; do \ 37 | GOOS=$$os GOARCH=$$arch go build -a -tags netgo -installsuffix netgo $(LDFLAGS) -o dist/$$os-$$arch/$(NAME); \ 38 | done; \ 39 | done 40 | 41 | .PHONY: deps 42 | deps: glide 43 | go get -u github.com/jteeuwen/go-bindata/... 44 | glide install 45 | 46 | .PHONY: dist 47 | dist: 48 | cd dist && \ 49 | $(DIST_DIRS) cp ../LICENSE {} \; && \ 50 | $(DIST_DIRS) cp ../README.md {} \; && \ 51 | $(DIST_DIRS) tar -zcf $(NAME)-$(VERSION)-{}.tar.gz {} \; && \ 52 | $(DIST_DIRS) zip -r $(NAME)-$(VERSION)-{}.zip {} \; && \ 53 | cd .. 54 | 55 | .PHONY: generate 56 | generate: $(TEMPLATES) 57 | go generate -x ./... 58 | 59 | .PHONY: glide 60 | glide: 61 | ifeq ($(shell command -v glide 2> /dev/null),) 62 | curl https://glide.sh/get | sh 63 | endif 64 | 65 | .PHONY: install 66 | install: 67 | go install $(LDFLAGS) 68 | 69 | .PHONY: test 70 | test: generate 71 | go test -cover -race -v `glide novendor` 72 | 73 | .PHONY: update-deps 74 | update-deps: glide 75 | glide update 76 | -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "path/filepath" 7 | "regexp" 8 | "strings" 9 | 10 | "github.com/dtan4/ct2stimer/crontab" 11 | "github.com/dtan4/ct2stimer/systemd" 12 | "github.com/pkg/errors" 13 | ) 14 | 15 | const ( 16 | serviceExt = ".service" 17 | timerExt = ".timer" 18 | ) 19 | 20 | func deleteUnusedUnits(outdir string, scMap map[string]*crontab.Schedule) ([]string, error) { 21 | files, err := ioutil.ReadDir(outdir) 22 | if err != nil { 23 | return []string{}, errors.Wrapf(err, "failed to read %q", outdir) 24 | } 25 | 26 | deleted := []string{} 27 | 28 | for _, file := range files { 29 | if file.IsDir() { 30 | continue 31 | } 32 | 33 | deletable := false 34 | 35 | if strings.HasSuffix(file.Name(), serviceExt) { 36 | unitName := strings.TrimSuffix(filepath.Base(file.Name()), serviceExt) 37 | 38 | if _, ok := scMap[unitName]; ok { 39 | continue 40 | } 41 | 42 | deletable = true 43 | } else if strings.HasSuffix(file.Name(), timerExt) { 44 | unitName := strings.TrimSuffix(filepath.Base(file.Name()), timerExt) 45 | 46 | if _, ok := scMap[unitName]; ok { 47 | continue 48 | } 49 | 50 | deletable = true 51 | } else { 52 | continue 53 | } 54 | 55 | if deletable { 56 | path := filepath.Join(outdir, file.Name()) 57 | 58 | if err := os.Remove(path); err != nil { 59 | return []string{}, errors.Wrapf(err, "failed to delete %q", path) 60 | } 61 | 62 | deleted = append(deleted, path) 63 | } 64 | } 65 | 66 | return deleted, nil 67 | } 68 | 69 | func getScheduleName(schedule *crontab.Schedule, re *regexp.Regexp) string { 70 | name := schedule.NameByRegexp(re) 71 | if name == "" { 72 | name = "cron-" + schedule.SHA256Sum()[0:12] 73 | } 74 | 75 | return name 76 | } 77 | 78 | func reloadSystemd(timers []string) error { 79 | conn, err := systemd.NewConn() 80 | if err != nil { 81 | return errors.Wrap(err, "cannot establish new systemd connection") 82 | } 83 | defer conn.Close() 84 | 85 | client := systemd.NewClient(conn) 86 | 87 | if err := client.Reload(); err != nil { 88 | return errors.Wrap(err, "cannot reload systemd unit files") 89 | } 90 | 91 | for _, timerUnit := range timers { 92 | if err := client.StartUnit(timerUnit); err != nil { 93 | return errors.Wrap(err, "cannot reload systemd timer unit") 94 | } 95 | } 96 | 97 | return nil 98 | } 99 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ct2stimer 2 | 3 | [![Build Status](https://travis-ci.org/dtan4/ct2stimer.svg?branch=master)](https://travis-ci.org/dtan4/ct2stimer) 4 | [![codecov](https://codecov.io/gh/dtan4/ct2stimer/branch/master/graph/badge.svg)](https://codecov.io/gh/dtan4/ct2stimer) 5 | 6 | Convert crontab to systemd timer 7 | 8 | ```bash 9 | ubuntu@ubuntu-xenial:~/src/github.com/dtan4/ct2stimer$ sudo ct2stimer -f sample.cron --reload 10 | ubuntu@ubuntu-xenial:~/src/github.com/dtan4/ct2stimer$ systemctl list-timers 11 | NEXT LEFT LAST PASSED UNIT ACTIVATES 12 | Fri 2017-01-20 07:50:00 UTC 4min 16s left n/a n/a cron-77e2fb273c45.timer cron-77e2fb273c45.service 13 | Fri 2017-01-20 07:56:01 UTC 10min left n/a n/a systemd-tmpfiles-clean.timer systemd-tmpfiles-clean.service 14 | Fri 2017-01-20 08:00:00 UTC 14min left n/a n/a cron-1b33d99b7dda.timer cron-1b33d99b7dda.service 15 | Fri 2017-01-20 10:00:00 UTC 2h 14min left n/a n/a cron-b60fe106ef63.timer cron-b60fe106ef63.service 16 | Fri 2017-01-20 12:16:09 UTC 4h 30min left n/a n/a snapd.refresh.timer snapd.refresh.service 17 | Fri 2017-01-20 19:11:59 UTC 11h left n/a n/a apt-daily.timer apt-daily.service 18 | Wed 2017-02-01 00:00:00 UTC 1 weeks 4 days left n/a n/a cron-fcd6d8377d9d.timer cron-fcd6d8377d9d.service 19 | Sat 2017-12-02 01:23:00 UTC 10 months 11 days left n/a n/a cron-d3c507cb2439.timer cron-d3c507cb2439.service 20 | 21 | 8 timers listed. 22 | Pass --all to see loaded but inactive timers, too. 23 | ``` 24 | 25 | ## Installation 26 | 27 | TBD 28 | 29 | ## Usage 30 | 31 | ct2stimer reads crontab file at `/etc/crontab` by default. You can specify crontab file with `-f FILE` flag. 32 | 33 | systemd unit file are saved at `/run/systemd/system` by default. You can specify save directory with `-o OUTDIR` flag. 34 | 35 | ```bash 36 | $ ct2stimer 37 | $ ct2stimer -f sample.cron -o unitfiles 38 | ``` 39 | 40 | ### Reload systemd and start all timers automatically 41 | 42 | If `--reload` is provided, ct2stimer reloads systemd unit files (= `systemctl daemon-reload`) and starts all generated timers (= `systemctl start foo.timer`). Maybe `sudo` is required to execute. 43 | 44 | ```bash 45 | $ sudo ct2stimer -f sample.cron --reload 46 | ``` 47 | 48 | ### Determine unit name from command to execute 49 | 50 | As you know, crontab does not have the concept of "task name". However, task name is required to identify each systemd unit. 51 | You can extract task name from original command using regular expression. `--name-regexp REGEXP` flag is used for this. 52 | Regular expression must have one [capturing group](http://www.regular-expressions.info/brackets.html). 53 | 54 | If regular expression is not provided or command does not match to the given regular expression, hash value, which is calculated from command, is used for unit name. 55 | 56 | ```bash 57 | $ ct2stimer -f sample.cron --name-regexp '--name ([a-zA-Z0-9_-]+)' 58 | ``` 59 | 60 | ### Delete unregistered unit files 61 | 62 | If `--delete` is provided, ct2timer deletes unit files which are no longer written in the given crontab file. 63 | 64 | ```bash 65 | $ ct2stimer -f tmp/scheduler -o /run/systemd/system --delete 66 | Deleted: /run/systemd/system/cron-19fb9c164fe8.service 67 | Deleted: /run/systemd/system/cron-19fb9c164fe8.timer 68 | Deleted: /run/systemd/system/cron-4f76a3902132.service 69 | Deleted: /run/systemd/system/cron-4f76a3902132.timer 70 | ``` 71 | 72 | ### Specify unit dependencies 73 | 74 | You can specify unit dependencies (`After=`) with `--after AFTER` flag. 75 | 76 | ```bash 77 | $ ct2stimer -f sample.crom --after docker.service 78 | ``` 79 | 80 | ## Development 81 | 82 | Building and executing on Ubuntu 16.04 VM is easy so that macOS does not have systemd. 83 | 84 | ```bash 85 | $ go get -d github.com/dtan4/ct2stimer 86 | $ cd $GOPATH/src/github.com/dtan4/ct2stimer 87 | $ vagrant up 88 | $ vagrant ssh 89 | 90 | ubuntu@ubuntu-xenial:~/src/github.com/dtan4/ct2timer$ make deps 91 | ubuntu@ubuntu-xenial:~/src/github.com/dtan4/ct2timer$ make 92 | ubuntu@ubuntu-xenial:~/src/github.com/dtan4/ct2timer$ bin/ct2stimer 93 | ``` 94 | 95 | ## Author 96 | 97 | Daisuke Fujita ([@dtan4](https://github.com/dtan4)) 98 | 99 | ## License 100 | 101 | [![MIT License](http://img.shields.io/badge/license-MIT-blue.svg?style=flat)](LICENSE) 102 | -------------------------------------------------------------------------------- /crontab/crontab.go: -------------------------------------------------------------------------------- 1 | package crontab 2 | 3 | import ( 4 | "crypto/sha256" 5 | "fmt" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/pkg/errors" 11 | "github.com/robfig/cron" 12 | ) 13 | 14 | const ( 15 | // DefaultCrontabFilename represents the default path of crontab file 16 | DefaultCrontabFilename = "/etc/crontab" 17 | 18 | minMinute = 0 19 | maxMinute = 59 20 | minHour = 0 21 | maxHour = 23 22 | minDom = 1 23 | maxDom = 31 24 | minMonth = 1 25 | maxMonth = 12 26 | minDow = 0 27 | maxDow = 6 28 | ) 29 | 30 | var ( 31 | suffixRegexp = regexp.MustCompile(`[^a-zA-Z0-9]+$`) 32 | weekDays = []string{ 33 | "Sun", 34 | "Mon", 35 | "Tue", 36 | "Wed", 37 | "Thu", 38 | "Fri", 39 | "Sat", 40 | } 41 | ) 42 | 43 | // Schedule represents crontab spec and command 44 | type Schedule struct { 45 | Spec string 46 | Command string 47 | } 48 | 49 | // Parse parses crontab file and return a list of Schedule 50 | func Parse(crontab string) ([]*Schedule, error) { 51 | schedules := []*Schedule{} 52 | lines := strings.Split(crontab, "\n") 53 | 54 | for _, line := range lines { 55 | if line == "" { 56 | continue 57 | } 58 | 59 | if strings.HasPrefix(line, "#") { 60 | continue 61 | } 62 | 63 | ss := strings.SplitN(line, " ", 6) 64 | if len(ss) < 6 { 65 | return []*Schedule{}, errors.Errorf("line %q is invalid format", line) 66 | } 67 | 68 | schedules = append(schedules, &Schedule{ 69 | Spec: strings.Join(ss[0:5], " "), 70 | Command: ss[5], 71 | }) 72 | } 73 | 74 | return schedules, nil 75 | } 76 | 77 | // ConvertToSystemdCalendar converts crontab spec format to Systemd Timer format 78 | // crontab: https://en.wikipedia.org/wiki/Cron 79 | // Systemd Timer: https://www.freedesktop.org/software/systemd/man/systemd.time.html 80 | func (s *Schedule) ConvertToSystemdCalendar() (string, error) { 81 | schedule, err := cron.ParseStandard(s.Spec) 82 | if err != nil { 83 | return "", errors.Wrapf(err, "failed to parse schedule spec %q", s.Spec) 84 | } 85 | 86 | specSchedule, ok := schedule.(*cron.SpecSchedule) 87 | if !ok { 88 | return "", errors.New("unable to convert Schedule to SpecSchedule") 89 | } 90 | 91 | minutes := parseBits(specSchedule.Minute, minMinute, maxMinute) 92 | hours := parseBits(specSchedule.Hour, minHour, maxHour) 93 | doms := parseBits(specSchedule.Dom, minDom, maxDom) 94 | months := parseBits(specSchedule.Month, minMonth, maxMonth) 95 | dows := parseBits(specSchedule.Dow, minDow, maxDow) 96 | 97 | fields := []string{} 98 | 99 | if dows != "*" { 100 | weekdays, err := convertDowsToWeekdays(dows) 101 | if err != nil { 102 | return "", errors.Wrap(err, "failed to convert day of weeks") 103 | } 104 | fields = append(fields, weekdays) 105 | } 106 | 107 | if months != "*" || doms != "*" { 108 | fields = append(fields, fmt.Sprintf("%s-%s", months, doms)) 109 | } 110 | 111 | fields = append(fields, fmt.Sprintf("%s:%s", hours, minutes)) 112 | 113 | return strings.Join(fields, " "), nil 114 | } 115 | 116 | // NameByRegexp returns schedule name extracted by the given regexp 117 | func (s *Schedule) NameByRegexp(nameRegexp *regexp.Regexp) string { 118 | if nameRegexp == nil { 119 | return "" 120 | } 121 | 122 | var name string 123 | 124 | match := nameRegexp.FindStringSubmatch(s.Command) 125 | if len(match) >= 2 { 126 | name = match[1] 127 | } else { 128 | name = "" 129 | } 130 | 131 | return suffixRegexp.ReplaceAllString(name, "") 132 | } 133 | 134 | // SHA256Sum generates SHA-256 checksum of schedule 135 | func (s *Schedule) SHA256Sum() string { 136 | return fmt.Sprintf("%x", sha256.Sum256([]byte(fmt.Sprintf("%s;%s", s.Spec, s.Command)))) 137 | } 138 | 139 | func convertDowsToWeekdays(bits string) (string, error) { 140 | dows := []string{} 141 | 142 | for _, bit := range strings.Split(bits, ",") { 143 | b, err := strconv.Atoi(bit) 144 | if err != nil { 145 | return "", errors.Wrap(err, "failed to parse bit string") 146 | } 147 | dows = append(dows, weekDays[b]) 148 | } 149 | 150 | return strings.Join(dows, ","), nil 151 | } 152 | 153 | func parseBits(n uint64, min, max int) string { 154 | var all1 uint64 155 | 156 | for i := min; i <= max; i++ { 157 | all1 |= 1 << uint(i) 158 | } 159 | 160 | if n&all1 == all1 { 161 | return "*" 162 | } 163 | 164 | bits := []string{} 165 | 166 | for i := 0; i <= max; i++ { 167 | if n&(1< 0 { 168 | bits = append(bits, strconv.Itoa(i)) 169 | } 170 | } 171 | 172 | return strings.Join(bits, ",") 173 | } 174 | -------------------------------------------------------------------------------- /cli.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "path/filepath" 8 | "regexp" 9 | 10 | "github.com/dtan4/ct2stimer/crontab" 11 | "github.com/dtan4/ct2stimer/systemd" 12 | "github.com/pkg/errors" 13 | flag "github.com/spf13/pflag" 14 | ) 15 | 16 | const ( 17 | exitCodeOK = 0 18 | exitCodeError = 1 19 | ) 20 | 21 | var opts = struct { 22 | after string 23 | delete bool 24 | dryRun bool 25 | filename string 26 | nameRegexp string 27 | outdir string 28 | reload bool 29 | user string 30 | version bool 31 | }{} 32 | 33 | func parseArgs(args []string) error { 34 | f := flag.NewFlagSet("ct2stimer", flag.ExitOnError) 35 | 36 | f.StringVar(&opts.after, "after", "", "unit dependencies (After=)") 37 | f.BoolVar(&opts.delete, "delete", false, "delete unused unit files") 38 | f.BoolVar(&opts.dryRun, "dry-run", false, "dry run") 39 | f.StringVarP(&opts.filename, "file", "f", crontab.DefaultCrontabFilename, "crontab file") 40 | f.StringVar(&opts.nameRegexp, "name-regexp", "", "regexp to extract scheduler name from crontab") 41 | f.StringVarP(&opts.outdir, "outdir", "o", systemd.DefaultUnitsDirectory, "directory to save systemd files") 42 | f.BoolVar(&opts.reload, "reload", false, "reload & start genreated timers") 43 | f.StringVar(&opts.user, "user", "", "unix username who executes process") 44 | f.BoolVarP(&opts.version, "version", "v", false, "print version") 45 | 46 | f.Parse(args) 47 | 48 | if opts.version { 49 | return nil 50 | } 51 | 52 | if opts.filename == "" { 53 | return errors.New("crontab file is required") 54 | } 55 | 56 | if opts.outdir == "" { 57 | return errors.New("directory to save systemd files is required") 58 | } 59 | 60 | if _, err := os.Stat(opts.outdir); err != nil { 61 | if os.IsNotExist(err) { 62 | return errors.Errorf("directory %q does not exist", opts.outdir) 63 | } 64 | 65 | return errors.Wrapf(err, "failed to read directory %q", opts.outdir) 66 | } 67 | 68 | return nil 69 | } 70 | 71 | func run(args []string) int { 72 | if err := parseArgs(args); err != nil { 73 | fmt.Fprintln(os.Stderr, err) 74 | return exitCodeError 75 | } 76 | 77 | if opts.version { 78 | printVersion() 79 | return exitCodeOK 80 | } 81 | 82 | body, err := ioutil.ReadFile(opts.filename) 83 | if err != nil { 84 | fmt.Fprintln(os.Stderr, err) 85 | return exitCodeError 86 | } 87 | 88 | schedules, err := crontab.Parse(string(body)) 89 | if err != nil { 90 | fmt.Fprintln(os.Stderr, err) 91 | return exitCodeError 92 | } 93 | 94 | var re *regexp.Regexp 95 | 96 | if opts.nameRegexp == "" { 97 | re = nil 98 | } else { 99 | var err error 100 | 101 | re, err = regexp.Compile(opts.nameRegexp) 102 | if err != nil { 103 | fmt.Fprintln(os.Stderr, err) 104 | return exitCodeError 105 | } 106 | } 107 | 108 | scMap := map[string]*crontab.Schedule{} 109 | 110 | for _, schedule := range schedules { 111 | name := getScheduleName(schedule, re) 112 | 113 | if sc, ok := scMap[name]; ok { 114 | fmt.Fprintln(os.Stderr, fmt.Errorf(`Schedule name %q already exists. Please consider another name regexp. 115 | Command A: %s 116 | Command B: %s`, name, sc.Command, schedule.Command)) 117 | return exitCodeError 118 | } 119 | 120 | scMap[name] = schedule 121 | } 122 | 123 | timers := []string{} 124 | 125 | for name, schedule := range scMap { 126 | calendar, err := schedule.ConvertToSystemdCalendar() 127 | if err != nil { 128 | fmt.Fprintln(os.Stderr, err) 129 | return exitCodeError 130 | } 131 | 132 | service, err := systemd.GenerateService(name, schedule.Command, opts.after, opts.user) 133 | if err != nil { 134 | fmt.Fprintln(os.Stderr, err) 135 | return exitCodeError 136 | } 137 | 138 | servicePath := filepath.Join(opts.outdir, name+".service") 139 | 140 | if opts.dryRun { 141 | fmt.Printf("[dry-run] %q will be created\n", servicePath) 142 | } else { 143 | if err := ioutil.WriteFile(servicePath, []byte(service), 0644); err != nil { 144 | fmt.Fprintln(os.Stderr, err) 145 | return exitCodeError 146 | } 147 | } 148 | 149 | timer, err := systemd.GenerateTimer(name, calendar) 150 | if err != nil { 151 | fmt.Fprintln(os.Stderr, err) 152 | return exitCodeError 153 | } 154 | 155 | timerPath := filepath.Join(opts.outdir, name+".timer") 156 | 157 | if opts.dryRun { 158 | fmt.Printf("[dry-run] %q will be created\n", timerPath) 159 | } else { 160 | if err := ioutil.WriteFile(timerPath, []byte(timer), 0644); err != nil { 161 | fmt.Fprintln(os.Stderr, err) 162 | return exitCodeError 163 | } 164 | } 165 | 166 | timers = append(timers, name+".timer") 167 | } 168 | 169 | if opts.delete { 170 | deleted, err := deleteUnusedUnits(opts.outdir, scMap) 171 | 172 | if err != nil { 173 | fmt.Fprintln(os.Stderr, err) 174 | return exitCodeError 175 | } 176 | 177 | for _, path := range deleted { 178 | fmt.Printf("Deleted: %s\n", path) 179 | } 180 | } 181 | 182 | if opts.reload && !opts.dryRun { 183 | if err := reloadSystemd(timers); err != nil { 184 | fmt.Fprintln(os.Stderr, err) 185 | return exitCodeError 186 | } 187 | } 188 | 189 | return exitCodeOK 190 | } 191 | -------------------------------------------------------------------------------- /crontab/crontab_test.go: -------------------------------------------------------------------------------- 1 | package crontab 2 | 3 | import ( 4 | "io/ioutil" 5 | "path/filepath" 6 | "reflect" 7 | "regexp" 8 | "testing" 9 | ) 10 | 11 | func TestParse(t *testing.T) { 12 | filename := filepath.Join("..", "testdata", "crontab") 13 | body, err := ioutil.ReadFile(filename) 14 | if err != nil { 15 | t.Fatalf("Failed to open testdata. filename: %s, error: %s", filename, err) 16 | } 17 | 18 | expected := []*Schedule{ 19 | &Schedule{ 20 | Spec: "0,5,10,15,20,25,30,35,40,45,50,55 * * * *", 21 | Command: "/bin/bash -l -c 'docker run --rm=true --name scheduler.task01.`date +\\%Y\\%m\\%d\\%H\\%M` --memory=5g 123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/app:latest bundle exec rake task01 RAILS_ENV=production'", 22 | }, 23 | &Schedule{ 24 | Spec: "15 * * * *", 25 | Command: "/bin/bash -l -c 'docker run --rm=true --name scheduler.task02.`date +\\%Y\\%m\\%d\\%H\\%M` --memory=5g 123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/app:latest bundle exec rake task02 RAILS_ENV=production'", 26 | }, 27 | &Schedule{ 28 | Spec: "30 * * * *", 29 | Command: "/bin/bash -l -c 'docker run --rm=true --name scheduler.task04.`date +\\%Y\\%m\\%d\\%H\\%M` --memory=5g 123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/app:latest bundle exec rake task04 RAILS_ENV=production'", 30 | }, 31 | } 32 | 33 | got, err := Parse(string(body)) 34 | if err != nil { 35 | t.Errorf("Error should not be raised. error: %s", err) 36 | } 37 | 38 | if !reflect.DeepEqual(got, expected) { 39 | t.Errorf("Schedules do not match.\n expected: %q\n got: %q", expected, got) 40 | } 41 | } 42 | 43 | func TestConvertToSystemdCalendar(t *testing.T) { 44 | testcases := []struct { 45 | schedule *Schedule 46 | expected string 47 | }{ 48 | { 49 | schedule: &Schedule{ 50 | Spec: "*/5 * * * *", 51 | Command: "", 52 | }, 53 | expected: "*:0,5,10,15,20,25,30,35,40,45,50,55", 54 | }, 55 | { 56 | schedule: &Schedule{ 57 | Spec: "0,5,10,15,20,25,30,35,40,45,50,55 10-12 * * *", 58 | Command: "", 59 | }, 60 | expected: "10,11,12:0,5,10,15,20,25,30,35,40,45,50,55", // TODO: 10-12:0,5,... 61 | }, 62 | { 63 | schedule: &Schedule{ 64 | Spec: "0-5 * 1 * *", 65 | Command: "", 66 | }, 67 | expected: "*-1 *:0,1,2,3,4,5", // TODO: *:0-5 68 | }, 69 | { 70 | schedule: &Schedule{ 71 | Spec: "23 2,1 * 12 1,6", 72 | Command: "", 73 | }, 74 | expected: "Mon,Sat 12-* 1,2:23", 75 | }, 76 | { 77 | schedule: &Schedule{ 78 | Spec: "0,20,40 8-17 * * 1-5", 79 | Command: "", 80 | }, 81 | expected: "Mon,Tue,Wed,Thu,Fri 8,9,10,11,12,13,14,15,16,17:0,20,40", 82 | }, 83 | { 84 | schedule: &Schedule{ 85 | Spec: "0 17 * * *", 86 | Command: "", 87 | }, 88 | expected: "17:0", 89 | }, 90 | { 91 | schedule: &Schedule{ 92 | Spec: "* * * * 0", 93 | Command: "", 94 | }, 95 | expected: "Sun *:*", 96 | }, 97 | { 98 | schedule: &Schedule{ 99 | Spec: "5 * * * *", 100 | Command: "", 101 | }, 102 | expected: "*:5", 103 | }, 104 | } 105 | 106 | for _, tc := range testcases { 107 | got, err := tc.schedule.ConvertToSystemdCalendar() 108 | if err != nil { 109 | t.Errorf("Error should not be raised. error: %s", err) 110 | } 111 | 112 | if got != tc.expected { 113 | t.Errorf("Calendar does not match. expected: %q, actual: %q", tc.expected, got) 114 | } 115 | } 116 | } 117 | 118 | func TestNameByRegexp(t *testing.T) { 119 | testcases := []struct { 120 | schedule *Schedule 121 | nameRegexp *regexp.Regexp 122 | expected string 123 | }{ 124 | { 125 | schedule: &Schedule{ 126 | Spec: "0,5,10,15,20,25,30,35,40,45,50,55 * * * *", 127 | Command: "/bin/bash -l -c 'docker run --rm=true --name scheduler.task01.`date +\\%Y\\%m\\%d\\%H\\%M` --memory=5g 123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/app:latest bundle exec rake task01 RAILS_ENV=production'", 128 | }, 129 | nameRegexp: regexp.MustCompile(`--name ([a-zA-Z0-9.]+)`), 130 | expected: "scheduler.task01", 131 | }, 132 | { 133 | schedule: &Schedule{ 134 | Spec: "15 * * * *", 135 | Command: "/bin/echo hello", 136 | }, 137 | nameRegexp: regexp.MustCompile(`--name ([a-zA-Z0-9.]+)`), 138 | expected: "", 139 | }, 140 | { 141 | schedule: &Schedule{ 142 | Spec: "30 * * * *", 143 | Command: "/bin/docker run --name hello ubuntu:16.04 echo hello", 144 | }, 145 | nameRegexp: regexp.MustCompile(`--name ([a-zA-Z0-9.]+)`), 146 | expected: "hello", 147 | }, 148 | { 149 | schedule: &Schedule{ 150 | Spec: "30 * * * *", 151 | Command: "/bin/bash -l -c 'docker run --rm=true --name scheduler.task01_--.._.`date +\\%Y\\%m\\%d\\%H\\%M` --memory=5g 123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/app:latest bundle exec rake task01 RAILS_ENV=production'", 152 | }, 153 | nameRegexp: regexp.MustCompile(`--name ([a-zA-Z0-9.]+)`), 154 | expected: "scheduler.task01", 155 | }, 156 | { 157 | schedule: &Schedule{ 158 | Spec: "30 * * * *", 159 | Command: "/bin/bash -l -c 'docker run --rm=true --name scheduler.task01.`date +\\%Y\\%m\\%d\\%H\\%M` --memory=5g 123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/app:latest bundle exec rake task01 RAILS_ENV=production'", 160 | }, 161 | nameRegexp: regexp.MustCompile(``), 162 | expected: "", 163 | }, 164 | { 165 | schedule: &Schedule{ 166 | Spec: "30 * * * *", 167 | Command: "/bin/bash -l -c 'docker run --rm=true --name scheduler.task01.`date +\\%Y\\%m\\%d\\%H\\%M` --memory=5g 123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/app:latest bundle exec rake task01 RAILS_ENV=production'", 168 | }, 169 | nameRegexp: nil, 170 | expected: "", 171 | }, 172 | } 173 | 174 | for _, tc := range testcases { 175 | if got := tc.schedule.NameByRegexp(tc.nameRegexp); got != tc.expected { 176 | t.Errorf("Name does not match. expected: %q, got: %q", tc.expected, got) 177 | } 178 | } 179 | } 180 | 181 | func TestSHA256Sum(t *testing.T) { 182 | schedule := &Schedule{ 183 | Spec: "15 * * * *", 184 | Command: "echo 'hello'", 185 | } 186 | expected := "4ab7fd35a3996a8b58483a640a52976d5c974372c12e5f7a973be86d96a0096e" 187 | 188 | if got := schedule.SHA256Sum(); got != expected { 189 | t.Errorf("Checksum does not match. expected: %q, got: %q", expected, got) 190 | } 191 | } 192 | --------------------------------------------------------------------------------