From 87dcffda994ed162bf95936f6ee60ada32293f3a Mon Sep 17 00:00:00 2001 From: nxshock Date: Sat, 26 Mar 2022 13:23:39 +0500 Subject: [PATCH] Initial commit --- .gitattributes | 2 + .gitignore | 21 +++++++ LICENSE | 21 +++++++ README.md | 15 +++++ consts.go | 18 ++++++ go.mod | 18 ++++++ go.sum | 23 ++++++++ httpserver.go | 58 +++++++++++++++++++ index.htm | 154 +++++++++++++++++++++++++++++++++++++++++++++++++ job.go | 115 ++++++++++++++++++++++++++++++++++++ job_test.go | 22 +++++++ log.go | 17 ++++++ main.go | 85 +++++++++++++++++++++++++++ make.bat | 1 + parser.go | 20 +++++++ template.go | 21 +++++++ tests/job.conf | 3 + 17 files changed, 614 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 consts.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 httpserver.go create mode 100644 index.htm create mode 100644 job.go create mode 100644 job_test.go create mode 100644 log.go create mode 100644 main.go create mode 100644 make.bat create mode 100644 parser.go create mode 100644 template.go create mode 100644 tests/job.conf 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"