First working version
This commit is contained in:
parent
ce01279e08
commit
eb4942729f
35
README.md
Normal file
35
README.md
Normal file
@ -0,0 +1,35 @@
|
||||
# concron
|
||||
|
||||
**Con**tainerized **cron**. Golang native scheduler to run repeated command inside containers (Docker, k8s)
|
||||
|
||||
## command-line
|
||||
|
||||
examples:
|
||||
```
|
||||
# run with config file 'tasks.yaml' located in current directory
|
||||
concron -c tasks.yaml
|
||||
# show options
|
||||
concron -h
|
||||
```
|
||||
|
||||
arguments:
|
||||
```
|
||||
-c <config file> : config file, YAML format
|
||||
-h : show help
|
||||
-p <http port> : http port for http server (default: 8080)
|
||||
-debug : show debug logs
|
||||
```
|
||||
|
||||
## http server endpoint
|
||||
|
||||
- `/healthz` - health endpoint, returns code `200` with text `OK`. Useful for kubernetes pods ready/live probes.
|
||||
|
||||
## config format
|
||||
|
||||
example:
|
||||
|
||||
see [tasks.yaml](tasks.yaml)
|
||||
|
||||
description:
|
||||
|
||||
_in work..._
|
9
go.mod
Normal file
9
go.mod
Normal file
@ -0,0 +1,9 @@
|
||||
module github.com/jar3b/concron
|
||||
|
||||
require (
|
||||
github.com/jar3b/logrus-levelpad-formatter v1.0.0
|
||||
github.com/julienschmidt/httprouter v1.2.0
|
||||
github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967
|
||||
github.com/sirupsen/logrus v1.4.0
|
||||
gopkg.in/yaml.v2 v2.2.2
|
||||
)
|
28
src/helpers/helpers.go
Normal file
28
src/helpers/helpers.go
Normal file
@ -0,0 +1,28 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
)
|
||||
|
||||
func ReadBinFile(filename string) ([]byte, error) {
|
||||
file, err := os.Open(filename)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
stats, statsErr := file.Stat()
|
||||
if statsErr != nil {
|
||||
return nil, statsErr
|
||||
}
|
||||
|
||||
var size int64 = stats.Size()
|
||||
bytes := make([]byte, size)
|
||||
|
||||
bufr := bufio.NewReader(file)
|
||||
_, err = bufr.Read(bytes)
|
||||
|
||||
return bytes, err
|
||||
}
|
74
src/main.go
Normal file
74
src/main.go
Normal file
@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/jar3b/concron/src/tasks"
|
||||
"github.com/jar3b/logrus-levelpad-formatter"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func healthHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte("OK"))
|
||||
}
|
||||
|
||||
func initLog(debug bool) {
|
||||
// Log as JSON instead of the default ASCII formatter.
|
||||
log.SetFormatter(&levelpad.Formatter{
|
||||
TimestampFormat: "2006-01-02 15:04:05.000",
|
||||
LogFormat: "[%lvl%][%time%] %msg%\n",
|
||||
LevelPad: 8,
|
||||
})
|
||||
|
||||
if debug {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
} else {
|
||||
log.SetLevel(log.InfoLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
// command arguments
|
||||
port := flag.Int("p", 8080, "HTTP server port")
|
||||
debug := flag.Bool("debug", false, "debug mode")
|
||||
configFile := flag.String("c", "tasks.yaml", "config file location")
|
||||
flag.Parse()
|
||||
|
||||
initLog(*debug)
|
||||
|
||||
// manage tasks
|
||||
taskList, err := tasks.LoadTasks(*configFile)
|
||||
if err != nil {
|
||||
log.Fatalf("cannot load %s: %v", *configFile, err)
|
||||
return
|
||||
}
|
||||
|
||||
// start scheduler
|
||||
sched, err := tasks.NewScheduler()
|
||||
if err != nil {
|
||||
log.Fatalf("cannot initialize scheduler: %v", err)
|
||||
return
|
||||
}
|
||||
if err = sched.AddTasks(taskList.Tasks); err != nil {
|
||||
log.Fatalf("cannot add task list to scheduler: %v", err)
|
||||
return
|
||||
}
|
||||
if err = sched.Start(); err != nil {
|
||||
log.Fatalf("cannot start scheduler: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// setup http router
|
||||
router := httprouter.New()
|
||||
router.GET("/healthz", healthHandler)
|
||||
|
||||
// start HTTP server
|
||||
log.Infof("concron was started on :%d", *port)
|
||||
err = http.ListenAndServe(fmt.Sprintf(":%d", *port), router)
|
||||
if err != nil {
|
||||
log.Errorf("cannot start concron http server", err)
|
||||
}
|
||||
}
|
167
src/tasks/models.go
Normal file
167
src/tasks/models.go
Normal file
@ -0,0 +1,167 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type taskExecution struct {
|
||||
id int
|
||||
time time.Time
|
||||
cmd *exec.Cmd
|
||||
stop chan error
|
||||
}
|
||||
|
||||
func (te *taskExecution) Wait() {
|
||||
te.stop <- te.cmd.Wait()
|
||||
}
|
||||
|
||||
func (te *taskExecution) Stop() {
|
||||
_ = te.cmd.Process.Kill()
|
||||
te.stop <- errors.New("interrupted")
|
||||
}
|
||||
|
||||
func newExecution(id int, cmd *exec.Cmd) *taskExecution {
|
||||
return &taskExecution{
|
||||
id: id,
|
||||
time: time.Now(),
|
||||
cmd: cmd,
|
||||
stop: make(chan error, 1),
|
||||
}
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
Name string `yaml:"name"`
|
||||
Crontab string `yaml:"crontab"`
|
||||
Dir string `yaml:"dir"`
|
||||
UseShell bool `yaml:"useShell"`
|
||||
Command string `yaml:"cmd"`
|
||||
Args []string `yaml:"args"`
|
||||
Deadline uint32 `yaml:"deadline"`
|
||||
ConcurrencyPolicy string `yaml:"concurrencyPolicy"`
|
||||
|
||||
// internal values
|
||||
path string
|
||||
cmdExe string
|
||||
cmdArg []string
|
||||
buf bytes.Buffer
|
||||
writer io.Writer
|
||||
|
||||
// task executions
|
||||
execCount int
|
||||
execMap map[int]*taskExecution
|
||||
}
|
||||
|
||||
func (t *Task) init(shell string, systemPath string) error {
|
||||
t.execCount = 0
|
||||
t.path = fmt.Sprintf("PATH=%s", systemPath)
|
||||
if shell != "" && t.UseShell {
|
||||
t.cmdExe = shell
|
||||
t.cmdArg = []string{"-c", t.Command + " " + strings.Join(t.Args, " ")}
|
||||
} else {
|
||||
t.cmdExe = t.Command
|
||||
t.cmdArg = t.Args
|
||||
}
|
||||
|
||||
if t.ConcurrencyPolicy == "" {
|
||||
t.ConcurrencyPolicy = "Allow"
|
||||
} else if t.ConcurrencyPolicy != "Allow" && t.ConcurrencyPolicy != "Forbid" && t.ConcurrencyPolicy != "Replace" {
|
||||
return errors.New(fmt.Sprintf("invalid value '%s' for concurrencyPolicy, allowed: Allow, Forbid, Replace", t.ConcurrencyPolicy))
|
||||
}
|
||||
|
||||
t.execMap = make(map[int]*taskExecution, 0)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Task) getCmd() *exec.Cmd {
|
||||
cmd := exec.Command(t.cmdExe, t.cmdArg...)
|
||||
if t.Dir != "" {
|
||||
cmd.Dir = t.Dir
|
||||
}
|
||||
cmd.Env = append(cmd.Env, t.path)
|
||||
|
||||
// setup out pipe
|
||||
t.buf.Reset()
|
||||
t.writer = bufio.NewWriter(&t.buf)
|
||||
cmd.Stdout = t.writer
|
||||
cmd.Stderr = t.writer
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (t *Task) Run() {
|
||||
// work with running executions
|
||||
runningTaskCount := len(t.execMap)
|
||||
if runningTaskCount > 0 {
|
||||
if t.ConcurrencyPolicy == "Forbid" {
|
||||
log.Errorf("[%-20s][%d] CANNOT RUN, another %d running executions", t.Name, t.execCount+1, runningTaskCount)
|
||||
return
|
||||
} else if t.ConcurrencyPolicy == "Replace" {
|
||||
log.Infof("[%-20s][%d] found %d running executions, cleaning...", t.Name, t.execCount+1, runningTaskCount)
|
||||
for _, ex := range t.execMap {
|
||||
ex.Stop()
|
||||
}
|
||||
log.Infof("[%-20s][%d] %d running executions was cleaned", t.Name, t.execCount+1, runningTaskCount)
|
||||
}
|
||||
}
|
||||
t.execCount++
|
||||
|
||||
// create execution
|
||||
execution := newExecution(t.execCount, t.getCmd())
|
||||
// start command
|
||||
if err := execution.cmd.Start(); err != nil {
|
||||
log.Errorf("[%-20s][%d] cannot start: %v", t.Name, execution.id, err)
|
||||
return
|
||||
}
|
||||
// wait for execution ends
|
||||
go execution.Wait()
|
||||
// add execution to list
|
||||
t.execMap[execution.id] = execution
|
||||
|
||||
log.Infof("[%-20s][%d] started", t.Name, execution.id)
|
||||
select {
|
||||
case err := <-execution.stop:
|
||||
if err == nil {
|
||||
log.Infof("[%-20s][%d] SUCCESS", t.Name, execution.id)
|
||||
log.Debugf("[%-20s][%d] SUCCESS, output: %s", t.Name, execution.id, t.buf.String())
|
||||
} else {
|
||||
log.Infof("[%-20s][%d] ERROR, err: %v, output: %s", t.Name, execution.id, err, t.buf.String())
|
||||
}
|
||||
delete(t.execMap, execution.id)
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigDescriptiveInfo
|
||||
type ConfigDescriptiveInfo struct {
|
||||
Shell string `yaml:"shell"`
|
||||
Tasks []*Task `yaml:"tasks"`
|
||||
}
|
||||
|
||||
func (di *ConfigDescriptiveInfo) InitTasks() []error {
|
||||
var errList = make([]error, 0)
|
||||
var err error
|
||||
|
||||
// get os PATH env
|
||||
osPath := os.Getenv("PATH")
|
||||
|
||||
var taskNames []string
|
||||
for _, t := range di.Tasks {
|
||||
if err = t.init(di.Shell, osPath); err != nil {
|
||||
errList = append(errList, err)
|
||||
}
|
||||
taskNames = append(taskNames, t.Name)
|
||||
}
|
||||
|
||||
log.Infof("%d tasks loaded - %s", len(di.Tasks), strings.Join(taskNames, ", "))
|
||||
|
||||
return errList
|
||||
}
|
54
src/tasks/scheduler.go
Normal file
54
src/tasks/scheduler.go
Normal file
@ -0,0 +1,54 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/robfig/cron"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type Scheduler struct {
|
||||
tasks []*Task
|
||||
cron *cron.Cron
|
||||
started bool
|
||||
}
|
||||
|
||||
func (s *Scheduler) AddTasks(taskList []*Task) error {
|
||||
s.tasks = taskList
|
||||
for _, task := range s.tasks {
|
||||
if err := s.cron.AddFunc(task.Crontab, task.Run); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Scheduler) Start() error {
|
||||
if s.started {
|
||||
return errors.New("cannot start scheduler, already started")
|
||||
}
|
||||
s.cron.Start()
|
||||
log.Info("scheduler was started")
|
||||
s.started = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Scheduler) Stop() error {
|
||||
if s.started {
|
||||
return errors.New("cannot stop scheduler, already stopped")
|
||||
}
|
||||
s.cron.Stop()
|
||||
log.Info("scheduler was stopped")
|
||||
s.started = false
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewScheduler() (*Scheduler, error) {
|
||||
sched := Scheduler{}
|
||||
sched.cron = cron.New()
|
||||
sched.started = false
|
||||
|
||||
return &sched, nil
|
||||
}
|
32
src/tasks/tasks.go
Normal file
32
src/tasks/tasks.go
Normal file
@ -0,0 +1,32 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/jar3b/concron/src/helpers"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
func LoadTasks(configFileName string) (*ConfigDescriptiveInfo, error) {
|
||||
config := ConfigDescriptiveInfo{}
|
||||
data, err := helpers.ReadBinFile(configFileName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal([]byte(data), &config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// init tasks
|
||||
errList := config.InitTasks()
|
||||
for _, err := range errList {
|
||||
log.Errorf(fmt.Sprintf("parse task error: %v", err))
|
||||
}
|
||||
if len(errList) > 0 {
|
||||
return nil, errors.New("cannot parse tasks, errors was shown above")
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
19
tasks.yaml
Normal file
19
tasks.yaml
Normal file
@ -0,0 +1,19 @@
|
||||
shell: "/bin/sh"
|
||||
tasks:
|
||||
- name: reservation
|
||||
crontab: "*/5 * * * * *"
|
||||
dir: "/tmp"
|
||||
useShell: true
|
||||
cmd: "sleep"
|
||||
args: ["60"]
|
||||
concurrencyPolicy: Replace
|
||||
- name: test
|
||||
crontab: "*/10 * * * * *"
|
||||
dir: "/tmp"
|
||||
useShell: false
|
||||
cmd: "/bin/echo"
|
||||
args: ["HELLO WORLD"]
|
||||
- name: show_env
|
||||
crontab: "*/15 * * * * *"
|
||||
useShell: true
|
||||
cmd: "env"
|
Loading…
x
Reference in New Issue
Block a user