├── .gitignore ├── go.mod ├── .goreleaser.yml ├── process ├── process_windows.go ├── process_linux.go └── process_other.go ├── CHANGELOG ├── LICENSE ├── README.md └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/symfonycorp/croncape 2 | 3 | go 1.16 4 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod tidy 4 | builds: 5 | - env: 6 | - CGO_ENABLED=0 7 | goos: 8 | - linux 9 | - darwin 10 | checksum: 11 | disable: true 12 | changelog: 13 | skip: true 14 | -------------------------------------------------------------------------------- /process/process_windows.go: -------------------------------------------------------------------------------- 1 | package process 2 | 3 | import ( 4 | "os/exec" 5 | "strconv" 6 | "syscall" 7 | ) 8 | 9 | func Deathsig(sysProcAttr *syscall.SysProcAttr) { 10 | } 11 | 12 | func Kill(cmd *exec.Cmd) error { 13 | c := exec.Command("taskkill", "/F", "/T", "/PID", strconv.Itoa(cmd.Process.Pid)) 14 | if err := c.Run(); err == nil { 15 | return nil 16 | } 17 | return cmd.Process.Signal(syscall.SIGKILL) 18 | } 19 | -------------------------------------------------------------------------------- /process/process_linux.go: -------------------------------------------------------------------------------- 1 | package process 2 | 3 | import ( 4 | "os/exec" 5 | "syscall" 6 | ) 7 | 8 | func Deathsig(sysProcAttr *syscall.SysProcAttr) { 9 | // the following helps with killing the main process and its children 10 | // see https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773 11 | sysProcAttr.Setpgid = true 12 | sysProcAttr.Pdeathsig = syscall.SIGKILL 13 | } 14 | 15 | func Kill(cmd *exec.Cmd) error { 16 | return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) 17 | } 18 | -------------------------------------------------------------------------------- /process/process_other.go: -------------------------------------------------------------------------------- 1 | // +build !linux 2 | // +build !windows 3 | 4 | package process 5 | 6 | import ( 7 | "os/exec" 8 | "syscall" 9 | ) 10 | 11 | func Deathsig(sysProcAttr *syscall.SysProcAttr) { 12 | // the following helps with killing the main process and its children 13 | // see https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773 14 | sysProcAttr.Setpgid = true 15 | } 16 | 17 | func Kill(cmd *exec.Cmd) error { 18 | return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) 19 | } 20 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | 1.4.0 2 | ----- 3 | 4 | * Forward exit code 5 | 6 | 1.3.0 7 | ----- 8 | 9 | * Allow to change the hostname used to send emails (via `MAILHOST` env var) 10 | * Allow to set email sender (via `MAILFROM` env var) 11 | * Fix timeout support when the command starts sub-processes 12 | * Add go.mod 13 | 14 | 1.2.0 15 | ----- 16 | 17 | * added --version flag 18 | 19 | 1.1.0 20 | ----- 21 | 22 | * remove the -e flag (use the MAILTO environment variable instead) 23 | * added support for MAILTO as the preferred way to configure croncape 24 | * disabled the timeout by default 25 | * added a way to disable the timeout 26 | * removed the -c flag (pass the command directly) 27 | * fixed environment variables handling 28 | 29 | 1.0.0 30 | ----- 31 | 32 | * Initial release 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017-2021 Fabien Potencier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Croncape 2 | ======== 3 | 4 | Croncape wraps commands run as cron jobs to send emails **only** when an error 5 | or a timeout has occurred. 6 | 7 | Out of the box, crontab can send an email when a job [generates output][5]. But 8 | a command is not necessarily unsuccessful "just" because it used the standard 9 | or error output. Checking the exit code would be better, but that's not how 10 | crontab was [standardized][1]. 11 | 12 | Croncape takes a different approach by wrapping your commands to only send an 13 | email when the command returns a non-zero exit code. 14 | 15 | Croncape plays well with crontab as it never outputs anything except when an 16 | issue occurs in Croncape itself (like a misconfiguration for instance), in 17 | which case crontab would send you an email. 18 | 19 | Installation 20 | ------------ 21 | 22 | Download the [binaries][4] or `go install github.com/symfonycorp/croncape@latest`. 23 | 24 | Usage 25 | ----- 26 | 27 | When adding a command in crontab, prefix it with `croncape`: 28 | 29 | MAILTO=sysadmins@example.com 30 | 0 6 * * * croncape ls -lsa 31 | 32 | That's it! 33 | 34 | Note that the `MAILTO` environment variable can also be defined globally in 35 | `/etc/crontab`; it supports multiple recipients by separating them with a comma. 36 | 37 | You can also customize the email sender by setting the `MAILFROM` environment 38 | variable. 39 | 40 | If you need to use "special" shell characters in your command (like `;` or `|`), 41 | don't forget to quote it and wrap the command in a shell: 42 | 43 | 0 6 * * * croncape bash -c "ls -lsa | true" 44 | 45 | Besides sending emails, croncape can also kill the run command after a given 46 | timeout, via the `-t` flag (disabled by default): 47 | 48 | 0 6 * * * croncape -t 2h ls -lsa 49 | 50 | If you want to send emails even when commands are successful, use the `-v` flag 51 | (useful for testing). 52 | 53 | Use the `-h` flag to display the full help message. 54 | 55 | Croncape is very similar to [cronwrap][2], with some differences: 56 | 57 | * No dependencies (cronwrap is written in Python); 58 | 59 | * Kills a command on a timeout (cronwrap just reports that the command took 60 | more time to execute); 61 | 62 | * Tries to use `sendmail` or `mail` depending on availability (cronwrap only 63 | works with `sendmail`); 64 | 65 | * Reads the email from the standard crontab `MAILTO` environment variable 66 | instead of a `-e` flag. 67 | 68 | For a simpler alternative, have a look at [cronic][3]. 69 | 70 | [1]: http://pubs.opengroup.org/onlinepubs/9699919799/utilities/crontab.html 71 | [2]: https://pypi.python.org/pypi/cronwrap/1.4 72 | [3]: http://habilis.net/cronic/ 73 | [4]: https://github.com/symfonycorp/croncape/releases 74 | [5]: https://xkcd.com/1728/ 75 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "os" 9 | "os/exec" 10 | "strings" 11 | "syscall" 12 | "text/template" 13 | "time" 14 | 15 | "github.com/symfonycorp/croncape/process" 16 | ) 17 | 18 | var version = "dev" 19 | 20 | type request struct { 21 | command []string 22 | emails string 23 | from string 24 | timeout time.Duration 25 | transport string 26 | verbose bool 27 | } 28 | 29 | type result struct { 30 | request request 31 | stdout bytes.Buffer 32 | stderr bytes.Buffer 33 | started time.Time 34 | stopped time.Time 35 | killed bool 36 | code int 37 | } 38 | 39 | func main() { 40 | wd, err := os.Getwd() 41 | if err != nil { 42 | log.Fatalln(err) 43 | } 44 | 45 | req := request{ 46 | emails: os.Getenv("MAILTO"), 47 | from: os.Getenv("MAILFROM"), 48 | } 49 | 50 | var versionf bool 51 | flag.DurationVar(&req.timeout, "t", 0, `Timeout for the command, like "-t 2h", "-t 2m", or "-t 30s". After the timeout, the command is killed, disabled by default`) 52 | flag.StringVar(&req.transport, "p", "auto", `Transport to use, like "-p auto", "-p mail", "-p sendmail"`) 53 | flag.BoolVar(&req.verbose, "v", false, "Enable sending emails even if command is successful") 54 | flag.BoolVar(&versionf, "version", false, "Output the version") 55 | flag.Parse() 56 | 57 | if versionf { 58 | fmt.Println(version) 59 | os.Exit(0) 60 | } 61 | 62 | req.command = flag.Args() 63 | if len(req.command) == 0 { 64 | fmt.Println("You must pass a command to execute") 65 | os.Exit(1) 66 | } 67 | 68 | r := execCmd(wd, req) 69 | 70 | if r.killed || r.code != 0 || r.request.verbose { 71 | if r.request.emails == "" { 72 | fmt.Println(r.render().String()) 73 | } else { 74 | r.sendEmail() 75 | } 76 | } 77 | 78 | os.Exit(r.code) 79 | } 80 | 81 | func execCmd(path string, req request) result { 82 | r := result{ 83 | started: time.Now(), 84 | request: req, 85 | } 86 | cmd := exec.Command(req.command[0], req.command[1:]...) 87 | cmd.Dir = path 88 | cmd.Stdout = &r.stdout 89 | cmd.Stderr = &r.stderr 90 | cmd.Env = os.Environ() 91 | cmd.SysProcAttr = &syscall.SysProcAttr{} 92 | process.Deathsig(cmd.SysProcAttr) 93 | if err := cmd.Start(); err != nil { 94 | r.stderr.WriteString("\n" + err.Error() + "\n") 95 | r.code = 127 96 | } else { 97 | var timer *time.Timer 98 | if req.timeout > 0 { 99 | timer = time.NewTimer(req.timeout) 100 | defer timer.Stop() 101 | go func(timer *time.Timer, cmd *exec.Cmd) { 102 | for range timer.C { 103 | r.killed = true 104 | if err := process.Kill(cmd); err != nil { 105 | r.stderr.WriteString(fmt.Sprintf("\nUnabled to kill the process: %s\n", err)) 106 | } 107 | } 108 | }(timer, cmd) 109 | } 110 | 111 | if err := cmd.Wait(); err != nil { 112 | // unsuccessful exit code? 113 | r.code = -1 114 | if exitError, ok := err.(*exec.ExitError); ok { 115 | r.code = exitError.Sys().(syscall.WaitStatus).ExitStatus() 116 | } 117 | } 118 | } 119 | 120 | r.stopped = time.Now() 121 | 122 | return r 123 | } 124 | 125 | func (r *result) sendEmail() { 126 | emails := strings.Split(r.request.emails, ",") 127 | paths := make(map[string]string) 128 | 129 | switch r.request.transport { 130 | case "auto": 131 | paths = map[string]string{"sendmail": "sendmail", "/usr/sbin/sendmail": "sendmail", "mail": "mail", "/usr/bin/mail": "mail"} 132 | case "sendmail": 133 | paths = map[string]string{"sendmail": "sendmail", "/usr/sbin/sendmail": "sendmail"} 134 | case "mail": 135 | paths = map[string]string{"mail": "mail", "/usr/bin/mail": "mail"} 136 | default: 137 | fmt.Printf("Unsupported transport %s\n", r.request.transport) 138 | os.Exit(1) 139 | } 140 | 141 | var err error 142 | var transportType string 143 | var transportPath string 144 | for p, t := range paths { 145 | p, err = exec.LookPath(p) 146 | if err == nil { 147 | transportType = t 148 | transportPath = p 149 | break 150 | } 151 | } 152 | 153 | switch transportType { 154 | default: 155 | fmt.Printf("Unable to find a path for %s\n", r.request.transport) 156 | os.Exit(1) 157 | 158 | case "mail": 159 | for _, email := range emails { 160 | args := []string{"-s", r.subject()} 161 | if from := r.request.from; from != "" { 162 | args = append(args, "-a", from) 163 | } 164 | args = append(args, strings.TrimSpace(email)) 165 | cmd := exec.Command(transportPath, args...) 166 | cmd.Stdin = r.render() 167 | cmd.Env = os.Environ() 168 | if err := cmd.Run(); err != nil { 169 | fmt.Printf("Could not send email to %s: %s\n", email, err) 170 | os.Exit(1) 171 | } 172 | } 173 | 174 | case "sendmail": 175 | var message string 176 | if len(emails) > 1 { 177 | message = fmt.Sprintf("To: %s\r\nCc: %s\r\nSubject: %s\r\n\r\n%s", emails[0], strings.Join(emails[1:], ","), r.subject(), r.render().String()) 178 | } else { 179 | message = fmt.Sprintf("To: %s\r\nSubject: %s\r\n\r\n%s", emails[0], r.subject(), r.render().String()) 180 | } 181 | if from := r.request.from; from != "" { 182 | message = fmt.Sprintf("From: %s\r\n%s", from, message) 183 | } 184 | cmd := exec.Command(transportPath, "-t") 185 | cmd.Stdin = strings.NewReader(message) 186 | cmd.Stdout = os.Stdout 187 | cmd.Stderr = os.Stderr 188 | cmd.Env = os.Environ() 189 | if err := cmd.Run(); err != nil { 190 | fmt.Printf("Could not send email to %s: %s\n", emails, err) 191 | os.Exit(1) 192 | } 193 | } 194 | } 195 | 196 | func (r *result) subject() string { 197 | hostname := "undefined" 198 | var err error 199 | if env := os.Getenv("MAILHOST"); env != "" { 200 | hostname = env 201 | } else if hostname, err = os.Hostname(); err != nil { 202 | hostname = "undefined" 203 | } 204 | 205 | if r.killed { 206 | return fmt.Sprintf("Cron on host %s: Timeout", hostname) 207 | } 208 | 209 | if r.code == 0 { 210 | return fmt.Sprintf("Cron on host %s: Command Successful", hostname) 211 | } 212 | 213 | return fmt.Sprintf("Cron on host %s: Failure", hostname) 214 | } 215 | 216 | func (r *result) title() string { 217 | var msg string 218 | 219 | if r.killed { 220 | msg = "Cron timeout detected" 221 | } else if r.code == 0 { 222 | msg = "Cron success" 223 | } else { 224 | msg = "Cron failure detected" 225 | } 226 | 227 | return msg + "\n" + strings.Repeat("=", len(msg)) 228 | } 229 | 230 | func (r *result) duration() time.Duration { 231 | return r.stopped.Sub(r.started) 232 | } 233 | 234 | func (r *result) render() *bytes.Buffer { 235 | tpl := template.Must(template.New("email").Parse(`{{.Title}} 236 | 237 | {{.Command}} 238 | 239 | METADATA 240 | -------- 241 | 242 | Exit Code: {{.Code}} 243 | Start: {{.Started}} 244 | Stop: {{.Stopped}} 245 | Duration: {{.Duration}} 246 | 247 | ERROR OUTPUT 248 | ------------ 249 | 250 | {{.Stderr}} 251 | 252 | STANDARD OUTPUT 253 | --------------- 254 | 255 | {{.Stdout}} 256 | `)) 257 | 258 | data := struct { 259 | Title string 260 | Command string 261 | Started time.Time 262 | Stopped time.Time 263 | Duration time.Duration 264 | Code int 265 | Stderr string 266 | Stdout string 267 | }{ 268 | Title: r.title(), 269 | Command: strings.Join(r.request.command, " "), 270 | Started: r.started, 271 | Stopped: r.stopped, 272 | Duration: r.duration(), 273 | Code: r.code, 274 | Stderr: r.stderr.String(), 275 | Stdout: r.stdout.String(), 276 | } 277 | 278 | contents := bytes.Buffer{} 279 | if err := tpl.Execute(&contents, data); err != nil { 280 | fmt.Println(err) 281 | os.Exit(1) 282 | } 283 | 284 | return &contents 285 | } 286 | --------------------------------------------------------------------------------