diff --git a/README.md b/README.md index d9a46e4..f6d87ce 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # gron -*cron-like job scheduler* +*SystemD and cron inspired job scheduler* ## Usage diff --git a/index.htm b/index.htm index 40136b1..6518e46 100644 --- a/index.htm +++ b/index.htm @@ -207,9 +207,9 @@
{{.Name}} - {{.Description}} + {{.JobConfig.Description}} -
{{.Cron}}
+
{{.JobConfig.Cron}}
{{if gt .CurrentRunningCount 1}}running {{.CurrentRunningCount}} jobs{{else}}{{if .CurrentRunningCount}}▶ running{{else}}◼ inactive{{end}}{{end}} {{.LastStartTime}} diff --git a/job.go b/job.go index b312a15..52bd66a 100644 --- a/job.go +++ b/job.go @@ -1,8 +1,7 @@ package main import ( - "bytes" - "io/ioutil" + "io" "os" "os/exec" "path/filepath" @@ -11,20 +10,24 @@ import ( "time" "github.com/BurntSushi/toml" + formatter "github.com/antonfisher/nested-logrus-formatter" log "github.com/sirupsen/logrus" ) // JobConfig is a TOML representation of job type JobConfig struct { - Cron string // cron decription - Command string // command for execution - Description string // job description + Cron string // cron decription + Command string // command for execution + Description string // job description + NumberOfRestartAttemts int + RestartSec int // the time to sleep before restarting a job (seconds) + RestartRule RestartRule // Configures whether the job shall be restarted when the job process exits } type Job struct { Name string // from filename - JobConfig + JobConfig JobConfig // Fields for stats CurrentRunningCount int @@ -52,13 +55,7 @@ func readJob(filePath string) (*Job, error) { return job, nil } -func (js *JobConfig) Write() { - buf := new(bytes.Buffer) - toml.NewEncoder(buf).Encode(*js) - ioutil.WriteFile("job.conf", buf.Bytes(), 0644) -} - -func (j *Job) CommandAndParams() (command string, params []string) { +func (j *Job) commandAndParams() (command string, params []string) { quoted := false items := strings.FieldsFunc(j.JobConfig.Command, func(r rune) bool { if r == '"' { @@ -74,51 +71,76 @@ func (j *Job) CommandAndParams() (command string, params []string) { } func (j *Job) Run() { - startTime := time.Now() + jobLogFile, _ := os.OpenFile(filepath.Join(config.LogFilesPath, j.Name+".log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + jobLogFile.WriteString("\n") - globalMutex.Lock() - j.CurrentRunningCount++ - j.LastStartTime = startTime.Format(config.TimeFormat) - globalMutex.Unlock() + logWriter := io.MultiWriter(logFile, jobLogFile) - defer jobLogFile.Close() - defer jobLogFile.WriteString("\n") + log := log.New() + log.SetFormatter(&formatter.Formatter{ + TimestampFormat: config.TimeFormat, + HideKeys: true, + NoColors: true, + TrimMessages: true}) + log.SetOutput(logWriter) + logEntry := log.WithField("job", j.Name) - l := log.New() - l.SetOutput(jobLogFile) - l.SetFormatter(log.StandardLogger().Formatter) - - log.WithField("job", j.Name).Info("started") - l.Info("started") - - command, params := j.CommandAndParams() - - cmd := exec.Command(command, params...) - cmd.Stdout = jobLogFile - cmd.Stderr = jobLogFile - jobLogFile, _ := os.OpenFile(filepath.Join(config.LogFilesPath, j.Name+".log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) - - err := cmd.Run() - if err != nil { - log.WithField("job", j.Name).Error(err.Error()) - l.WithField("job", j.Name).Error(err.Error()) + for i := 0; i < j.JobConfig.NumberOfRestartAttemts+1; i++ { + logEntry.Info("Started.") + startTime := time.Now() globalMutex.Lock() - j.LastError = err.Error() + j.CurrentRunningCount++ + j.LastStartTime = startTime.Format(config.TimeFormat) globalMutex.Unlock() - } else { + + /**/ + command, params := j.commandAndParams() + + cmd := exec.Command(command, params...) + cmd.Stdout = jobLogFile + cmd.Stderr = jobLogFile + + err := cmd.Run() + if err != nil { + logEntry.Error(err.Error()) + + globalMutex.Lock() + j.LastError = err.Error() + globalMutex.Unlock() + } else { + globalMutex.Lock() + j.LastError = "" + globalMutex.Unlock() + } + + endTime := time.Now() + logEntry.Infof("Finished (%s).", endTime.Sub(startTime).Truncate(time.Second).String()) + globalMutex.Lock() - j.LastError = "" + j.CurrentRunningCount-- + j.LastEndTime = endTime.Format(config.TimeFormat) + j.LastExecutionDuration = endTime.Sub(startTime).Truncate(time.Second).String() globalMutex.Unlock() + + if err == nil { + break + } + + if j.JobConfig.RestartRule == No || j.JobConfig.NumberOfRestartAttemts == 0 { + break + } + + if i == 0 { + logEntry.Printf("Job failed, restarting in %d seconds.", j.JobConfig.RestartSec) + } else if i+1 < j.JobConfig.NumberOfRestartAttemts { + logEntry.Printf("Retry attempt №%d of %d failed, restarting in %d seconds.", i, j.JobConfig.NumberOfRestartAttemts, j.JobConfig.RestartSec) + } else { + logEntry.Printf("Retry attempt №%d of %d failed.", i, j.JobConfig.NumberOfRestartAttemts) + } + + time.Sleep(time.Duration(j.JobConfig.RestartSec) * time.Second) + } - - endTime := time.Now() - log.WithField("job", j.Name).Infof("finished (%s)", endTime.Sub(startTime).Truncate(time.Second).String()) - l.Infof("finished (%s)", endTime.Sub(startTime).Truncate(time.Second).String()) - - globalMutex.Lock() - j.CurrentRunningCount-- - j.LastEndTime = endTime.Format(config.TimeFormat) - j.LastExecutionDuration = endTime.Sub(startTime).Truncate(time.Second).String() - globalMutex.Unlock() + jobLogFile.Close() } diff --git a/job_test.go b/job_test.go new file mode 100644 index 0000000..e305ee3 --- /dev/null +++ b/job_test.go @@ -0,0 +1,24 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestReadJob(t *testing.T) { + expectedJob := &Job{ + Name: "job", + JobConfig: JobConfig{ + Cron: "* * * * *", + Command: `command "param1 param1" param2`, + Description: "comment", + NumberOfRestartAttemts: 3, + RestartSec: 5, + RestartRule: OnError}} + + job, err := readJob("tests/job.conf") + assert.NoError(t, err) + + assert.Equal(t, expectedJob, job) +} diff --git a/main.go b/main.go index c5b9896..82335bb 100644 --- a/main.go +++ b/main.go @@ -64,7 +64,7 @@ func initJobs() error { return err } - _, err = c.AddJob(job.Cron, job) + _, err = c.AddJob(job.JobConfig.Cron, job) if err != nil { return err } diff --git a/restartrule.go b/restartrule.go new file mode 100644 index 0000000..0357d41 --- /dev/null +++ b/restartrule.go @@ -0,0 +1,36 @@ +package main + +import ( + "fmt" +) + +type RestartRule int + +const ( + No RestartRule = iota + OnError +) + +func (r *RestartRule) MarshalText() (text []byte, err error) { + switch *r { + case No: + return []byte("no"), nil + case OnError: + return []byte("on-error"), nil + } + + return nil, fmt.Errorf("unknown restart rule: %v", r) +} + +func (r *RestartRule) UnmarshalText(text []byte) error { + switch string(text) { + case "no": + *r = No + return nil + case "on-error": + *r = OnError + return nil + } + + return fmt.Errorf("unknown restart rule: %s", string(text)) +} diff --git a/restartrule_test.go b/restartrule_test.go new file mode 100644 index 0000000..1771b5e --- /dev/null +++ b/restartrule_test.go @@ -0,0 +1,40 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRestartRuleMarshalText(t *testing.T) { + var tests = []struct { + expected []byte + value RestartRule + }{ + {[]byte("no"), No}, + {[]byte("on-error"), OnError}, + } + + for _, test := range tests { + got, err := test.value.MarshalText() + assert.NoError(t, err) + assert.Equal(t, test.expected, got) + } +} + +func TestRestartRuleUnmarshalText(t *testing.T) { + var tests = []struct { + expected RestartRule + value []byte + }{ + {No, []byte("no")}, + {OnError, []byte("on-error")}, + } + + for _, test := range tests { + var r RestartRule + err := r.UnmarshalText(test.value) + assert.NoError(t, err) + assert.Equal(t, test.expected, r) + } +} diff --git a/tests/job.conf b/tests/job.conf index 95605dd..2bfae77 100644 --- a/tests/job.conf +++ b/tests/job.conf @@ -1,3 +1,6 @@ Cron = "* * * * *" Command = 'command "param1 param1" param2' Description = "comment" +NumberOfRestartAttemts = 3 +RestartSec = 5 +RestartRule = "on-error"