commit 87dcffda994ed162bf95936f6ee60ada32293f3a Author: nxshock Date: Sat Mar 26 13:23:39 2022 +0500 Initial commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b735ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..01aa971 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 nxshock + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..047d2c2 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# go-cron + +*cron-like job scheduler* + +## Usage + +1. Create `jobs.d` directory +2. Create job config in `jobs.d/job1.conf` ([TOML](https://en.wikipedia.org/wiki/TOML) format): + ```toml + Cron = "* * * * *" # cron instructions + Command = "echo Hello" # command to execute + Description = "print Hello every minute" # job description + ``` +3. Launch `go-cron` binary +4. HTTP interface available on http://127.0.0.1:9876 \ No newline at end of file diff --git a/consts.go b/consts.go new file mode 100644 index 0000000..9f816e7 --- /dev/null +++ b/consts.go @@ -0,0 +1,18 @@ +package main + +import formatter "github.com/antonfisher/nested-logrus-formatter" + +const ( + timeFormat = "02.01.2006 15:04:05" + logFileName = "log.txt" + logFilesPath = "logs" + listenAddress = "127.0.0.1:9876" +) + +var ( + logFormat = &formatter.Formatter{ + TimestampFormat: timeFormat, + HideKeys: true, + NoColors: true, + TrimMessages: true} +) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..156448d --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module github.com/nxshock/go-cron + +go 1.18 + +require ( + github.com/BurntSushi/toml v1.0.0 + github.com/antonfisher/nested-logrus-formatter v1.3.1 + github.com/robfig/cron/v3 v3.0.1 + github.com/sirupsen/logrus v1.8.1 + github.com/stretchr/testify v1.7.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 // indirect + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a02f865 --- /dev/null +++ b/go.sum @@ -0,0 +1,23 @@ +github.com/BurntSushi/toml v1.0.0 h1:dtDWrepsVPfW9H/4y7dDgFc2MBUSeJhlaDtK13CxFlU= +github.com/BurntSushi/toml v1.0.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/antonfisher/nested-logrus-formatter v1.3.1 h1:NFJIr+pzwv5QLHTPyKz9UMEoHck02Q9L0FP13b/xSbQ= +github.com/antonfisher/nested-logrus-formatter v1.3.1/go.mod h1:6WTfyWFkBc9+zyBaKIqRrg/KwMqBbodBjgbHjDz7zjA= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= +github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/httpserver.go b/httpserver.go new file mode 100644 index 0000000..a773838 --- /dev/null +++ b/httpserver.go @@ -0,0 +1,58 @@ +package main + +import ( + "bytes" + "fmt" + "net/http" + "time" + + log "github.com/sirupsen/logrus" +) + +func httpServer(listenAddress string) { + http.HandleFunc("/", handler) + http.HandleFunc("/start", handleForceStart) + log.WithField("job", "http_server").Fatal(http.ListenAndServe(listenAddress, nil)) +} + +func handler(w http.ResponseWriter, r *http.Request) { + if r.RequestURI != "/" { + http.Error(w, "", http.StatusNotFound) + return + } + + currentRunningJobsMutex.RLock() + buf := new(bytes.Buffer) + jobEntries := c.Entries() + var jobs []*Job + for _, v := range jobEntries { + jobs = append(jobs, v.Job.(*Job)) + } + indexTemplate.ExecuteTemplate(buf, "index", jobs) + currentRunningJobsMutex.RUnlock() + + buf.WriteTo(w) +} + +func handleForceStart(w http.ResponseWriter, r *http.Request) { + jobName := r.FormValue("jobName") + if jobName == "" { + http.Error(w, "job name is not specified", http.StatusBadRequest) + return + } + + jobEntries := c.Entries() + + for _, jobEntry := range jobEntries { + job := jobEntry.Job.(*Job) + if job.FileName == jobName { + log.WithField("job", "http_server").Printf("forced start %s", job.FileName) + go job.Run() + time.Sleep(time.Second / 4) // wait some time for job start + http.Redirect(w, r, "/", http.StatusTemporaryRedirect) + return + } + } + + http.Error(w, fmt.Sprintf("there is no job with name %s", jobName), http.StatusBadRequest) +} diff --git a/index.htm b/index.htm new file mode 100644 index 0000000..0369eab --- /dev/null +++ b/index.htm @@ -0,0 +1,154 @@ + + + + + + + go-cron + + + + +
+

Job list

+ + + + + + + + + + + + {{range .}} + + + + + + + + + {{end}} +
NameDescriptionCronStatusLast start timeLast finish timeLast execution timeLast error
+
+ {{.Name}} +
{{.Description}}{{.Cron}}{{if gt .CurrentRunningCount 1}}running {{.CurrentRunningCount}} jobs{{else}}{{if .CurrentRunningCount}}running{{end}}{{end}}{{.LastStartTime}}{{.LastEndTime}}{{.LastExecutionDuration}}{{.LastError}}
+
+ + + \ No newline at end of file diff --git a/job.go b/job.go new file mode 100644 index 0000000..e014f51 --- /dev/null +++ b/job.go @@ -0,0 +1,115 @@ +package main + +import ( + "bytes" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/BurntSushi/toml" + log "github.com/sirupsen/logrus" +) + +type JobConfig struct { + Cron string + Command string + Description string +} + +var currentRunningJobsMutex sync.RWMutex + +func readJob(filePath string) (*Job, error) { + var jobConfig JobConfig + + _, err := toml.DecodeFile(filePath, &jobConfig) + if err != nil { + return nil, err + } + + command, params := parseCommand(jobConfig.Command) + + job := &Job{ + Name: strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath)), + Cron: jobConfig.Cron, + Command: command, + Params: params, + FileName: strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filepath.Base(filePath))), + Description: jobConfig.Description} + + return job, nil +} + +func (js *JobConfig) Write() { + buf := new(bytes.Buffer) + toml.NewEncoder(buf).Encode(*js) + ioutil.WriteFile("job.conf", buf.Bytes(), 0644) +} + +type Job struct { + Name string // from filename + + Cron string // cron decription + Command string // command for execution + Params []string // command params + FileName string // short job name + Description string // job description + + // Fields for stats + CurrentRunningCount int + LastStartTime string + LastEndTime string + LastExecutionDuration string + LastError string +} + +func (j *Job) Run() { + startTime := time.Now() + + currentRunningJobsMutex.Lock() + j.CurrentRunningCount++ + j.LastStartTime = startTime.Format(timeFormat) + currentRunningJobsMutex.Unlock() + + jobLogFile, _ := os.OpenFile(filepath.Join(logFilesPath, j.FileName+".txt"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + defer jobLogFile.Close() + defer jobLogFile.WriteString("\n") + + l := log.New() + l.SetOutput(jobLogFile) + l.SetFormatter(logFormat) + + log.WithField("job", j.FileName).Info("started") + l.Info("started") + + cmd := exec.Command(j.Command, j.Params...) + cmd.Stdout = jobLogFile + cmd.Stderr = jobLogFile + + err := cmd.Run() + if err != nil { + log.WithField("job", j.FileName).Error(err.Error()) + l.WithField("job", j.FileName).Error(err.Error()) + + currentRunningJobsMutex.Lock() + j.LastError = err.Error() + currentRunningJobsMutex.Unlock() + } else { + currentRunningJobsMutex.Lock() + j.LastError = "" + currentRunningJobsMutex.Unlock() + } + + endTime := time.Now() + log.WithField("job", j.FileName).Infof("finished (%s)", endTime.Sub(startTime).Truncate(time.Second).String()) + l.Infof("finished (%s)", endTime.Sub(startTime).Truncate(time.Second).String()) + + currentRunningJobsMutex.Lock() + j.CurrentRunningCount-- + j.LastEndTime = endTime.Format(timeFormat) + j.LastExecutionDuration = endTime.Sub(startTime).Truncate(time.Second).String() + currentRunningJobsMutex.Unlock() +} diff --git a/job_test.go b/job_test.go new file mode 100644 index 0000000..d2450cb --- /dev/null +++ b/job_test.go @@ -0,0 +1,22 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestReadJob(t *testing.T) { + expectedJob := &Job{ + Name: "job", + Cron: "* * * * *", + Command: "command", + Params: []string{"param1 param1", "param2"}, + FileName: "job", + Description: "comment"} + + job, err := readJob("tests/job.conf") + assert.NoError(t, err) + + assert.Equal(t, expectedJob, job) +} diff --git a/log.go b/log.go new file mode 100644 index 0000000..f358439 --- /dev/null +++ b/log.go @@ -0,0 +1,17 @@ +package main + +import ( + "os" + + log "github.com/sirupsen/logrus" +) + +var logFile *os.File + +func initLogFile() { + var err error + logFile, err = os.OpenFile(logFileName, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + log.Fatalln(err) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..d620da6 --- /dev/null +++ b/main.go @@ -0,0 +1,85 @@ +package main + +import ( + "os" + "os/signal" + "path/filepath" + "syscall" + + "github.com/robfig/cron/v3" + log "github.com/sirupsen/logrus" +) + +var c *cron.Cron + +func init() { + err := os.MkdirAll(logFilesPath, 0644) + if err != nil { + log.Fatalln(err) + } + + initLogFile() + + log.SetFormatter(logFormat) + //multiWriter := io.MultiWriter(os.Stderr, logFile) + //log.SetOutput(multiWriter) + log.SetOutput(logFile) + log.SetLevel(log.InfoLevel) + + initTemplate() + + go httpServer(listenAddress) + + c = cron.New() +} + +func main() { + log := log.WithField("job", "core") + + log.Info("started") + + err := filepath.Walk("jobs.d", func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + + if filepath.Ext(info.Name()) == ".conf" { + job, err := readJob(path) + if err != nil { + return err + } + + _, err = c.AddJob(job.Cron, job) + if err != nil { + return err + } + } + + return nil + }) + if err != nil { + log.Fatalln(err) + } + + if len(c.Entries()) == 0 { + log.Fatal("no jobs loaded") + } + + log.Infof("loaded jobs count: %d", len(c.Entries())) + + c.Start() + + intChan := make(chan os.Signal) + signal.Notify(intChan, syscall.SIGTERM) + <-intChan + + log.Info("got stop signal") + + err = logFile.Close() + if err != nil { + log.Fatal(err.Error()) + } +} diff --git a/make.bat b/make.bat new file mode 100644 index 0000000..d121840 --- /dev/null +++ b/make.bat @@ -0,0 +1 @@ +go build -ldflags "-s -w -H windowsgui" \ No newline at end of file diff --git a/parser.go b/parser.go new file mode 100644 index 0000000..43069a9 --- /dev/null +++ b/parser.go @@ -0,0 +1,20 @@ +package main + +import ( + "strings" +) + +func parseCommand(s string) (command string, params []string) { + quoted := false + items := strings.FieldsFunc(s, func(r rune) bool { + if r == '"' { + quoted = !quoted + } + return !quoted && r == ' ' + }) + for i := range items { + items[i] = strings.Trim(items[i], `"`) + } + + return items[0], items[1:] +} diff --git a/template.go b/template.go new file mode 100644 index 0000000..2248b62 --- /dev/null +++ b/template.go @@ -0,0 +1,21 @@ +package main + +import ( + _ "embed" + "html/template" + + log "github.com/sirupsen/logrus" +) + +//go:embed index.htm +var indexTemplateStr string + +var indexTemplate *template.Template + +func initTemplate() { + var err error + indexTemplate, err = template.New("index").Parse(indexTemplateStr) // TODO: optimize + if err != nil { + log.Fatalln("init template error:", err) + } +} diff --git a/tests/job.conf b/tests/job.conf new file mode 100644 index 0000000..95605dd --- /dev/null +++ b/tests/job.conf @@ -0,0 +1,3 @@ +Cron = "* * * * *" +Command = 'command "param1 param1" param2' +Description = "comment"