1
0
mirror of https://github.com/nxshock/gron.git synced 2024-11-27 03:41:00 +05:00

Initial commit

This commit is contained in:
nxshock 2022-03-26 13:23:39 +05:00
commit 87dcffda99
17 changed files with 614 additions and 0 deletions

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

21
.gitignore vendored Normal file
View File

@ -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

21
LICENSE Normal file
View File

@ -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.

15
README.md Normal file
View File

@ -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

18
consts.go Normal file
View File

@ -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}
)

18
go.mod Normal file
View File

@ -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
)

23
go.sum Normal file
View File

@ -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=

58
httpserver.go Normal file
View File

@ -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)
}

154
index.htm Normal file
View File

@ -0,0 +1,154 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>go-cron</title>
<style>
/*
Based on base16 architecture for building themes
https://github.com/chriskempson/base16
*/
:root {
--base00: #282c34;
/* Default Background */
--base01: #353b45;
/* Lighter Background */
--base02: #3e4451;
/* Selection Background */
--base03: #545862;
/* Comments, Invisibles, Line Highlighting */
--base04: #565c64;
/* Dark Foreground (Used for status bars) */
--base05: #abb2bf;
/* Default Foreground, Caret, Delimiters, Operators */
--base06: #b6bdca;
/* Light Foreground (Not often used) */
--base07: #c8ccd4;
/* Light Background (Not often used) */
--base08: #e06c75;
/* Red */
--base09: #d19a66;
/* Orange */
--base0A: #e5c07b;
/* Yellow */
--base0B: #98c379;
/* Green */
--base0C: #56b6c2;
/* Aqua */
--base0D: #61afef;
/* Blue */
--base0E: #c678dd;
/* Purple */
--base0F: #be5046;
/* Dark red */
}
* {
font-family: Verdana;
font-size: 14px;
color: var(--base05);
margin: 0;
padding: 0;
}
html {
height: 100%
}
body {
background-color: var(--base00);
display: flex;
justify-content: space-between;
flex-wrap: nowrap;
flex-direction: column;
height: 100%;
}
h1 {
margin-top: 1em;
font-size: 150%;
margin-bottom: 0.5em;
}
main {
flex-grow: 1;
padding-left: 1em;
padding-right: 1em;
}
table {
border-collapse: collapse;
}
th {
background-color: var(--base02);
}
td,
th {
border: 1px solid var(--base03);
padding: 0.25em;
}
button {
width: 100%;
color: #fff;
background-color: var(--base01);
border: 1px solid var(--base03);
}
form {
display: none;
}
.smaller {
font-size: 75%;
}
.nowrap {
white-space: nowrap;
}
.errorbg {
background-color: var(--base08);
}
.runningbg {
background-color: var(--base0B);
}
</style>
</head>
<body>
<main>
<h1>Job list</h1>
<table>
<tr>
<th>Name</th>
<th>Description</th>
<th>Cron</th>
<th>Status</th>
<th>Last start time</th>
<th>Last finish time</th>
<th>Last execution time</th>
<th>Last error</th>
</tr>
{{range .}}<tr>
<td>
<form action="/start" method="get" id="form-{{.Name}}"></form>
<button{{if gt .CurrentRunningCount 0}} class="runningbg"{{else}}{{if .LastError}} class="errorbg"{{end}}{{end}} type="submit" form="form-{{.Name}}" name="jobName" value="{{.Name}}"{{if gt .CurrentRunningCount 0}} disabled{{end}}>{{.Name}}</button>
</td>
<td class="smaller">{{.Description}}</td>
<td class="nowrap" align="right">{{.Cron}}</td>
<td>{{if gt .CurrentRunningCount 1}}running {{.CurrentRunningCount}} jobs{{else}}{{if .CurrentRunningCount}}running{{end}}{{end}}</td>
<td>{{.LastStartTime}}</td>
<td>{{.LastEndTime}}</td>
<td align="right">{{.LastExecutionDuration}}</td>
<td class="smaller">{{.LastError}}</td>
</tr>{{end}}
</table>
</main>
</body>
</html>

115
job.go Normal file
View File

@ -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()
}

22
job_test.go Normal file
View File

@ -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)
}

17
log.go Normal file
View File

@ -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)
}
}

85
main.go Normal file
View File

@ -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())
}
}

1
make.bat Normal file
View File

@ -0,0 +1 @@
go build -ldflags "-s -w -H windowsgui"

20
parser.go Normal file
View File

@ -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:]
}

21
template.go Normal file
View File

@ -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)
}
}

3
tests/job.conf Normal file
View File

@ -0,0 +1,3 @@
Cron = "* * * * *"
Command = 'command "param1 param1" param2'
Description = "comment"