diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1548ee1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.idea +.DS_Store +.build/* \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ef0c9d4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2020 Sergei Vizel + +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..81cb792 --- /dev/null +++ b/README.md @@ -0,0 +1,135 @@ +# go-cron + +Cron is a system daemon used to execute desired tasks (in the background) at designated times. + +Binaries can be found at: https://github.com/cravler/go-cron/releases + +## Usage + +```sh +cron --help +``` + +`crontab.yaml` example: + +```yaml +exp: "@every 1h15m5s" +cmd: + name: "ls" + argv: + - "-lah" + - "--color=auto" + dir: "/var/www" +log: + # Combined output (stdout & stderr) + file: "/var/log/app-1.log" +--- +exp: "5 15 */1 * * ?" +# Skip command run, if previous still running +lock: true +cmd: + name: "env" + env: + - "TEST1=123" + - "TEST2=456 789" +log: + # If file empty, we can specify (stdout & stderr) + # Defaults to "". + file: "" + # The maximum size of the log before it is rolled. + # A positive integer plus a modifier representing + # the unit of measure (k, m, or g). + # Defaults to 0 (unlimited). + size: 1m + # The maximum number of log files that can be present. + # If rolling the logs creates excess files, + # the oldest file is removed. + # Only effective when size is also set. + # A positive integer. + # Defaults to 1. + num: 3 +stdout: + file: "/var/log/app-2-stdout.log" +stderr: + file: "/var/log/app-2-stderr.log" + size: 5m + num: 1 +``` + +### CRON Expression Format + + A cron expression represents a set of times, using 5-6 space-separated fields. + +Field name | Mandatory? | Allowed values | Allowed special characters +-------------|:----------:|:-------------------:|:--------------------------: +Seconds | No | `0-59` | `* / , -` +Minutes | Yes | `0-59` | `* / , -` +Hours | Yes | `0-23` | `* / , -` +Day of month | Yes | `1-31` | `* / , - ?` +Month | Yes | `1-12` or `JAN-DEC` | `* / , -` +Day of week | Yes | `0-6` or `SUN-SAT` | `* / , - ?` + +`Month` and `Day of week` field values are case insensitive. `SUN`, `Sun`, and `sun` are equally accepted. + +#### Special Characters + +##### Asterisk `*` + +The asterisk indicates that the cron expression will match for all values of the field; e.g., using an asterisk in +the `month` field would indicate every month. + +##### Slash `/` + +Slashes are used to describe increments of ranges. For example `3-59/15` in the `minutes` field would indicate +the 3rd minute of the hour and every 15 minutes thereafter. The form `*/...` is equivalent to the form `first-last/...`, +that is, an increment over the largest possible range of the field. The form `N/...` is accepted as meaning `N-MAX/...`, +that is, starting at N, use the increment until the end of that specific range. It does not wrap around. + +##### Comma `,` + +Commas are used to separate items of a list. For example, using `MON,WED,FRI` in the `day of week` field would mean +Mondays, Wednesdays and Fridays. + +##### Hyphen `-` + +Hyphens are used to define ranges. For example, `9-17` would indicate every hour between 9am and 5pm inclusive. + +##### Question mark `?` + +Question mark may be used instead of `*` for leaving either `day of month` or `day of week` blank. + +#### Predefined schedules + +You may use one of several pre-defined schedules in place of a cron expression. + +Entry | Description | Equivalent To +-----------------------|--------------------------------------------|:-------------: +@yearly (or @annually) | Run once a year, midnight, Jan. 1st | `0 0 1 1 *` +@monthly | Run once a month, midnight, first of month | `0 0 1 * *` +@weekly | Run once a week, midnight between Sat/Sun | `0 0 * * 0` +@daily (or @midnight) | Run once a day, midnight | `0 0 * * *` +@hourly | Run once an hour, beginning of hour | `0 * * * *` + +##### @every `` + +where `duration` is a string with time units: `s`, `m`, `h`. + +Entry | Description | Equivalent To +---------------|--------------------------------------------|:--------------: +@every 5s | Run every 5 seconds | `*/5 * * * * ?` +@every 15m5s | Run every 15 minutes 5 seconds | `5 */15 * * * ?` +@every 1h15m5s | Run every 1 hour 15 minutes 5 seconds | `5 15 */1 * * ?` + +#### Time zones + +By default, all scheduling is done in the machine's local time zone. +Individual cron schedules may also override the time zone they are to be interpreted in by providing an additional +space-separated field at the beginning of the cron spec, of the form `CRON_TZ=Europe/London`. + +For example: + +```yaml +exp: "CRON_TZ=Europe/London 0 6 * * ?" # Runs at 6am in Europe/London +``` + +Be aware that jobs scheduled during daylight-savings leap-ahead transitions will not be run! diff --git a/cmd/cron/main.go b/cmd/cron/main.go new file mode 100644 index 0000000..2108529 --- /dev/null +++ b/cmd/cron/main.go @@ -0,0 +1,161 @@ +package main + +import ( + "io" + "os" + "log" + "path" + "strings" + "syscall" + "os/exec" + "os/signal" + + "github.com/spf13/cobra" + "github.com/robfig/cron/v3" + + "github.com/cravler/go-cron/internal/app" +) + +var version = "0.x-dev" + +var logger = log.New(os.Stderr, "", log.LstdFlags) + +func main() { + rootCmdName := path.Base(os.Args[0]) + rootCmd := app.NewRootCmd(rootCmdName, version, func(c *cobra.Command, args []string) error { + workdir, _ := c.Flags().GetString("workdir") + verbose, _ := c.Flags().GetBool("verbose") + + runApp(args, workdir, verbose) + + return nil + }) + + rootCmd.Flags().StringP("workdir", "w", "", "Working directory") + rootCmd.Flags().BoolP("verbose", "v", false, "Verbose output") + + if err := rootCmd.Execute(); err != nil { + rootCmd.Println(err) + os.Exit(1) + } +} + +func runApp(args []string, workdir string, verbose bool) { + l := cron.PrintfLogger(logger) + p := cron.NewParser(cron.SecondOptional |cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor) + c := cron.New(cron.WithParser(p), cron.WithLogger(l)) + + entries := []app.Entry{} + for _, crontab := range args { + if err := app.GetEntries(crontab, &entries); err != nil { + logger.Printf("%s\n", err.Error()) + } + } + + for _, raw := range entries { + entry := raw + + tz, exp, err := app.Parse(entry.Exp) + if err != nil { + logger.Printf("%s\n", err.Error()) + continue + } + + if strings.TrimSpace(tz + " " + exp) != strings.TrimSpace(entry.Exp) { + entry.Exp = entry.Exp + " (" + exp + ")" + } + + if err := app.PrepareLogs(&entry); err != nil { + logger.Printf("%s\n", err.Error()) + } + + if verbose { + d, _ := app.DumpYaml("Add", entry) + logger.Printf("%s\n", d) + } + + schedule, err := p.Parse(strings.TrimSpace(tz + " " + exp)) + if err != nil { + logger.Printf("%s\n", err.Error()) + continue + } + + fn := func() { + if verbose { + d, _ := app.DumpYaml("Execute", entry) + logger.Printf("%s\n", d) + } + + cmd := exec.Command(entry.Cmd.Name, entry.Cmd.Argv...) + cmd.Env = os.Environ() + if entry.Cmd.Env != nil { + cmd.Env = append(cmd.Env, entry.Cmd.Env...) + } + if entry.Cmd.Dir != "" { + cmd.Dir = entry.Cmd.Dir + } else if workdir != "" { + cmd.Dir = workdir + } + + var outFile, errFile io.WriteCloser + + if entry.Log.File != "" { + file, err := app.OpenLogFile(entry.Log, "", 0) + if err != nil { + logger.Printf("%s\n", err.Error()) + } else { + defer file.Close() + outFile = file + errFile = file + } + } else { + if entry.Stdout.File != "" { + file, err := app.OpenLogFile(entry.Stdout, entry.Log.Size, entry.Log.Num) + if err != nil { + logger.Printf("%s\n", err.Error()) + } else { + defer file.Close() + outFile = file + } + } + if entry.Stderr.File != "" { + file, err := app.OpenLogFile(entry.Stderr, entry.Log.Size, entry.Log.Num) + if err != nil { + logger.Printf("%s\n", err.Error()) + } else { + defer file.Close() + errFile = file + } + } + } + + if outFile != nil { + cmd.Stdout = outFile + } + if errFile != nil { + cmd.Stderr = errFile + } + + if err := cmd.Start(); err != nil { + logger.Printf("%s\n", err.Error()) + } + cmd.Wait() + } + + var job cron.Job + job = cron.FuncJob(fn) + if entry.Lock { + job = cron.NewChain(cron.SkipIfStillRunning(l)).Then(job) + } + + c.Schedule(schedule, job) + } + + s := make(chan os.Signal, 1) + signal.Notify(s, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(s) + + c.Start() + <-s + <-c.Stop().Done() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..86e8da2 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module github.com/cravler/go-cron + +go 1.13 + +require ( + github.com/go-pkg-hub/logrotate v0.0.0-20200227084444-f3cd25cbd19e + github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/spf13/cobra v0.0.6 + gopkg.in/yaml.v3 v3.0.0-20200121175148-a6ecf24a6d71 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..779c297 --- /dev/null +++ b/go.sum @@ -0,0 +1,134 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-pkg-hub/logrotate v0.0.0-20200227084444-f3cd25cbd19e h1:+JfmTq7+mg0hfZJ4OsN+Gt5bFLN3zdRzyQAc1GqeaCE= +github.com/go-pkg-hub/logrotate v0.0.0-20200227084444-f3cd25cbd19e/go.mod h1:SQZAypN4admFnm9ofgYduOgT+uBE9FC/kseiuYj7dZY= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ= +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/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.6 h1:breEStsVwemnKh2/s6gMvSdMEkwW0sK8vGStnlVBMCs= +github.com/spf13/cobra v0.0.6/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200121175148-a6ecf24a6d71 h1:Xe2gvTZUJpsvOWUnvmL/tmhVBZUmHSvLbMjRj6NUUKo= +gopkg.in/yaml.v3 v3.0.0-20200121175148-a6ecf24a6d71/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/internal/app/cobra.go b/internal/app/cobra.go new file mode 100644 index 0000000..60f96d1 --- /dev/null +++ b/internal/app/cobra.go @@ -0,0 +1,32 @@ +package app + +import ( + "github.com/spf13/cobra" +) + +func NewRootCmd(name, version string, fn func(c *cobra.Command, args []string) error) *cobra.Command { + var rootCmd *cobra.Command + + rootCmd = &cobra.Command{ + Use: name + " ", + SilenceUsage: true, + SilenceErrors: true, + DisableFlagsInUseLine: true, + RunE: func(c *cobra.Command, args []string) error { + if len(args) == 0 { + c.HelpFunc()(c, args) + return nil + } + + return fn(c, args) + }, + Version: version, + } + + rootCmd.Flags().BoolP("help", "h", false, "Output usage information") + + rootCmd.SetVersionTemplate("{{printf \"%s\" .Version}}\n") + rootCmd.Flags().BoolP("version", "V", false, "Display this application version") + + return rootCmd +} diff --git a/internal/app/types.go b/internal/app/types.go new file mode 100644 index 0000000..f28bdd6 --- /dev/null +++ b/internal/app/types.go @@ -0,0 +1,23 @@ +package app + +type Log struct { + File string `yaml:",omitempty"` + Size string `yaml:",omitempty"` + Num int `yaml:",omitempty"` +} + +type Cmd struct { + Name string + Argv []string `yaml:",omitempty"` + Env []string `yaml:",omitempty"` + Dir string `yaml:",omitempty"` +} + +type Entry struct { + Exp string + Lock bool + Cmd Cmd + Log Log `yaml:",omitempty"` + Stdout Log `yaml:",omitempty"` + Stderr Log `yaml:",omitempty"` +} diff --git a/internal/app/utils.go b/internal/app/utils.go new file mode 100644 index 0000000..d3dbf86 --- /dev/null +++ b/internal/app/utils.go @@ -0,0 +1,130 @@ +package app + +import ( + "io" + "os" + "fmt" + "time" + "regexp" + "reflect" + "strconv" + "strings" + + "github.com/go-pkg-hub/logrotate" +) + +func PrepareLogs(entry *Entry) error { + PrepareLog(&entry.Stdout) + PrepareLog(&entry.Stderr) + + if entry.Log.File != "" { + PrepareLog(&entry.Log) + entry.Stdout = Log{} + entry.Stderr = Log{} + } else if entry.Stdout.File == entry.Stderr.File { + l, err := MergeLogs(entry.Stderr, entry.Stdout) + if err == nil { + if reflect.DeepEqual(entry.Log, Log{}) { + entry.Log = l + } else { + entry.Log.File = l.File + if entry.Log.Size == "" { + entry.Log.Size = l.Size + } + if entry.Log.Num == 0 { + entry.Log.Num = l.Num + } + } + PrepareLog(&entry.Log) + } + + entry.Stdout = Log{} + entry.Stderr = Log{} + + if err != nil { + return err + } + } + + return nil +} + +func PrepareLog(l *Log) { + switch l.File { + case "/dev/stdin", "/dev/stdout", "/dev/stderr": + l.Size = "" + l.Num = 0 + } +} + +func OpenLogFile(l Log, defaultSize string, defaultNum int) (io.WriteCloser, error) { + switch l.File { + case "/dev/stdin", "/dev/stdout", "/dev/stderr": + return os.OpenFile(l.File, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + } + + num := defaultNum + if l.Num > 0 { + num = l.Num + } + + size := defaultSize + if l.Size != "" { + size = l.Size + } + + opts := []logrotate.Option{ + logrotate.WithMaxSize(logrotate.StringToSize(size)), + logrotate.WithMaxFiles(num), + } + + return logrotate.New(l.File, opts...) +} + +func GetExpression(descriptor string) (string, error) { + const every = "@every " + if strings.HasPrefix(descriptor, every) { + duration, err := time.ParseDuration(descriptor[len(every):]) + if err != nil { + return "", fmt.Errorf("failed to parse duration %s: %s", descriptor, err) + } + + re := regexp.MustCompile(`(?P\d+)(?P\w{1,1})`) + + d := make(map[string]int) + matches := re.FindAllStringSubmatch(fmt.Sprint(duration), -1) + for i := 0; i < len(matches); i++ { + val, _ := strconv.Atoi(matches[i][1]) + unit := matches[i][2] + d[unit] = val + } + + exp := "" + if duration > 59 * time.Minute { + exp = fmt.Sprintf("%d %d */%d * * ?", d["s"], d["m"], d["h"]) + } else if duration > 59 * time.Second { + exp = fmt.Sprintf("%d */%d * * * ?", d["s"], d["m"]) + } else { + exp = fmt.Sprintf("*/%d * * * * ?", d["s"]) + } + + return exp, nil + } + + return descriptor, nil +} + +func Parse(spec string) (tz, exp string, err error) { + tz = "" + if strings.HasPrefix(spec, "TZ=") || strings.HasPrefix(spec, "CRON_TZ=") { + i := strings.Index(spec, " ") + tz = spec[:i] + spec = strings.TrimSpace(spec[i:]) + } + + exp, err = GetExpression(spec) + + return tz, exp, err +} + + diff --git a/internal/app/yaml.go b/internal/app/yaml.go new file mode 100644 index 0000000..0952a2b --- /dev/null +++ b/internal/app/yaml.go @@ -0,0 +1,88 @@ +package app + +import ( + "io" + "os" + "bufio" + "regexp" + "path/filepath" + "gopkg.in/yaml.v3" +) + +func DumpYaml(prefix string, i interface{}) (out []byte, err error) { + j := make(map[interface{}]interface{}) + j[prefix] = i + return yaml.Marshal(&j) +} + +func MergeLogs(l1 Log, l2 Log) (Log, error) { + l := l1 + + d, err := yaml.Marshal(&l2) + if err != nil { + return Log{}, err + } + + err = yaml.Unmarshal(d, &l) + if err != nil { + return Log{}, err + } + + return l, nil +} + +func ParseEntries(r io.Reader, entries *[]Entry) (err error) { + decoder := yaml.NewDecoder(r) + + for err == nil { + entry := Entry{} + err = decoder.Decode(&entry) + if nil != err { + if err == io.EOF { + err = nil + } + return + } + *entries = append(*entries, entry) + } + + return +} + +func LoadEntries(s string, entries *[]Entry) (error) { + f, err := os.Open(s) + + if nil != err { + return err + } + + defer f.Close() + + r := bufio.NewReader(f) + + return ParseEntries(r, entries) +} + +func GetEntries(s string, entries *[]Entry) (error) { + f, err := os.Stat(s) + if os.IsNotExist(err) { + return err + } + + if !f.IsDir() { + return LoadEntries(s, entries) + } + + return filepath.Walk(s, func(p string, f os.FileInfo, _ error) error { + if !f.IsDir() { + r, err := regexp.MatchString(".yaml", f.Name()) + if err == nil && r { + err = LoadEntries(p, entries) + if err != nil { + return err + } + } + } + return nil + }) +} diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..9ca730a --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,57 @@ +#!/bin/bash + +SCRIPT_DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) +WORKDIR="$( dirname "${SCRIPT_DIR}" )" + +cd "${WORKDIR}" + +GOOS=${GOOS:=linux} +GOARCH=${GOARCH:=amd64} +BUILD_DIR=${BUILD_DIR:=.build} +VERSION=${VERSION:=0.x} +ARCHIVE=NO + +for i in "$@"; do +case $i in + --archive) + ARCHIVE=YES + shift + ;; + *) + # unknown option + ;; +esac +done + +DIR="${BUILD_DIR}/${GOOS}/${GOARCH}" +mkdir -p "${DIR}" + +FILE="cron" +if [ "windows" = "${GOOS}" ]; then + FILE="cron.exe" +fi + +SHA=$( git rev-parse HEAD 2>/dev/null | head -c7 ) +if [ -z "${SHA}" ]; then + SHA="dev" +fi + +PACKAGE="cmd/cron/main.go" + +if [ "linux" = "${GOOS}" ]; then + CGO_ENABLED=0 go build -a -installsuffix cgo -o "${DIR}/${FILE}" -ldflags "-X main.version=${VERSION}-${SHA}" ${PACKAGE} +else + go build -o "${DIR}/${FILE}" -ldflags "-X main.version=${VERSION}-${SHA}" ${PACKAGE} +fi + +md5sum --tag "${DIR}/${FILE}" > "${DIR}/md5" + +if [ "YES" = "${ARCHIVE}" ]; then + cd "${DIR}" + cp "${WORKDIR}/LICENSE" ./ + cp "${WORKDIR}/README.md" ./ + TAR_FILE="${WORKDIR}/${BUILD_DIR}/cron_${GOOS}_${GOARCH}.tar.gz" + tar -czf "${TAR_FILE}" * + md5sum --tag "${TAR_FILE}" > "${TAR_FILE}.md5" + rm -rf "${WORKDIR}/$( dirname "${DIR}" )" +fi \ No newline at end of file diff --git a/scripts/make.sh b/scripts/make.sh new file mode 100755 index 0000000..1bc2c08 --- /dev/null +++ b/scripts/make.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +SCRIPT_DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) + +GOOS=linux GOARCH=amd64 "${SCRIPT_DIR}/build.sh" $@ +GOOS=linux GOARCH=386 "${SCRIPT_DIR}/build.sh" $@ +GOOS=linux GOARCH=arm64 "${SCRIPT_DIR}/build.sh" $@ +GOOS=linux GOARCH=arm "${SCRIPT_DIR}/build.sh" $@ + +GOOS=darwin GOARCH=amd64 "${SCRIPT_DIR}/build.sh" $@ +GOOS=darwin GOARCH=386 "${SCRIPT_DIR}/build.sh" $@ + +GOOS=windows GOARCH=amd64 "${SCRIPT_DIR}/build.sh" $@ +GOOS=windows GOARCH=386 "${SCRIPT_DIR}/build.sh" $@ \ No newline at end of file