Skip to content

Commit 658dd8c

Browse files
committed
added initial implementation
0 parents  commit 658dd8c

File tree

4 files changed

+345
-0
lines changed

4 files changed

+345
-0
lines changed

CHANGELOG

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
1.0
2+
---
3+
4+
* Initial release

LICENSE

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
Copyright (c) 2017 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.

README.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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. But a
8+
command is not necessarily unsuccessful "just" because it used the standard or
9+
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 get github.com/sensiocloud/croncape`.
23+
24+
Usage
25+
-----
26+
27+
When adding a command in crontab, wrap it with Croncape:
28+
29+
0 6 * * * croncape -e "[email protected]" -c "ls -lsa"
30+
31+
That's it!
32+
33+
You can also send emails to more than one user by separating emails with a comma:
34+
35+
0 6 * * * croncape -e "[email protected],[email protected]" -c "ls -lsa"
36+
37+
Besides sending emails, croncape can also kill the run command after a given timeout, via the `-t` flag (by default, the limit is 1 hour):
38+
39+
0 6 * * * croncape -e "[email protected]" -t 2h -c "ls -lsa"
40+
41+
If you want to send emails even when commands are successful, use the `-v` flag
42+
(useful for testing).
43+
44+
Use the `-h` flag to display the full help message.
45+
46+
Croncape is very similar to [cronwrap][2], with some differences:
47+
48+
* No dependencies (cronwrap is written in Python);
49+
50+
* Kills a command on a timeout (cronwrap just reports that the command took
51+
more time to execute);
52+
53+
* Tries to use `sendmail` or `mail` depending on availability (cronwrap only
54+
works with `sendmail`).
55+
56+
For a simpler alternative, have a look at [cronic][3].
57+
58+
[1]: http://pubs.opengroup.org/onlinepubs/9699919799/utilities/crontab.html
59+
[2]: https://pypi.python.org/pypi/cronwrap/1.4
60+
[3]: http://habilis.net/cronic/
61+
[4]: https://github.com/sensiocloud/croncape/releases

main.go

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
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+
16+
var (
17+
version = "1.0"
18+
)
19+
20+
type request struct {
21+
command string
22+
emails string
23+
timeout time.Duration
24+
transport string
25+
verbose bool
26+
}
27+
28+
type result struct {
29+
request request
30+
stdout bytes.Buffer
31+
stderr bytes.Buffer
32+
started time.Time
33+
stopped time.Time
34+
killed bool
35+
code int
36+
}
37+
38+
func main() {
39+
wd, err := os.Getwd()
40+
if err != nil {
41+
log.Fatalln(err)
42+
}
43+
44+
command := flag.String("c", "", `Command to run, like '-c "ls"'`)
45+
emails := flag.String("e", "", `Emails to send reports when the command fails or exceeds timeout, like '-e "[email protected],[email protected]"'`)
46+
timeout := flag.String("t", "1h", `Timeout for the command, like "-t 2h", "-t 2m", or "-t 30s". After the timeout, the command is killed, defaults to 1 hour "-t 3600"`)
47+
transport := flag.String("p", "auto", `Transport to use, like "-p auto", "-p mail", "-p sendmail"`)
48+
verbose := flag.Bool("v", false, "Enable sending emails even if command is successful")
49+
flag.Parse()
50+
51+
t, err := time.ParseDuration(*timeout)
52+
if err != nil {
53+
fmt.Println(err)
54+
os.Exit(1)
55+
}
56+
57+
req := request{
58+
command: *command,
59+
emails: *emails,
60+
timeout: t,
61+
transport: *transport,
62+
verbose: *verbose,
63+
}
64+
65+
r := execCmd(wd, req)
66+
67+
if r.killed || r.code != 0 || r.request.verbose {
68+
if r.request.emails == "" {
69+
fmt.Println(r.render().String())
70+
} else {
71+
r.sendEmail()
72+
}
73+
}
74+
}
75+
76+
func execCmd(path string, req request) result {
77+
r := result{
78+
started: time.Now(),
79+
request: req,
80+
}
81+
cmd := exec.Command("sh", "-c", req.command)
82+
cmd.Dir = path
83+
cmd.Stdout = &r.stdout
84+
cmd.Stderr = &r.stderr
85+
cmd.Env = []string{fmt.Sprintf("HOME=%s", os.Getenv("HOME"))}
86+
if err := cmd.Start(); err != nil {
87+
fmt.Println(err)
88+
os.Exit(1)
89+
}
90+
91+
timer := time.NewTimer(req.timeout)
92+
go func(timer *time.Timer, cmd *exec.Cmd) {
93+
for _ = range timer.C {
94+
r.killed = true
95+
if err := cmd.Process.Kill(); err != nil {
96+
fmt.Println(err)
97+
os.Exit(1)
98+
}
99+
}
100+
}(timer, cmd)
101+
102+
if err := cmd.Wait(); err != nil {
103+
// unsuccessful exit code?
104+
if exitError, ok := err.(*exec.ExitError); ok {
105+
r.code = exitError.Sys().(syscall.WaitStatus).ExitStatus()
106+
} else {
107+
fmt.Println(err)
108+
os.Exit(1)
109+
}
110+
}
111+
112+
r.stopped = time.Now()
113+
114+
return r
115+
}
116+
117+
func (r *result) sendEmail() {
118+
emails := strings.Split(r.request.emails, ",")
119+
paths := make(map[string]string)
120+
121+
if r.request.transport == "auto" {
122+
paths = map[string]string{"sendmail": "sendmail", "/usr/sbin/sendmail": "sendmail", "mail": "mail", "/usr/bin/mail": "mail"}
123+
} else if r.request.transport == "sendmail" {
124+
paths = map[string]string{"sendmail": "sendmail", "/usr/sbin/sendmail": "sendmail"}
125+
} else if r.request.transport == "mail" {
126+
paths = map[string]string{"mail": "mail", "/usr/bin/mail": "mail"}
127+
} else {
128+
fmt.Printf("Unsupported transport %s\n", r.request.transport)
129+
os.Exit(1)
130+
}
131+
132+
var err error
133+
var transportType string
134+
var transportPath string
135+
for p, t := range paths {
136+
p, err = exec.LookPath(p)
137+
if err == nil {
138+
transportType = t
139+
transportPath = p
140+
break
141+
}
142+
}
143+
144+
if transportType == "" {
145+
fmt.Printf("Unable to find a path for %s\n", r.request.transport)
146+
os.Exit(1)
147+
}
148+
149+
if transportType == "mail" {
150+
for _, email := range emails {
151+
cmd := exec.Command(transportPath, "-s", r.subject(), strings.TrimSpace(email))
152+
cmd.Stdin = r.render()
153+
cmd.Env = []string{fmt.Sprintf("HOME=%s", os.Getenv("HOME"))}
154+
if err := cmd.Run(); err != nil {
155+
fmt.Printf("Could not send email to %s: %s\n", email, err)
156+
os.Exit(1)
157+
}
158+
}
159+
return
160+
}
161+
162+
if transportType == "sendmail" {
163+
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())
164+
cmd := exec.Command(transportPath, "-t")
165+
cmd.Stdin = strings.NewReader(message)
166+
cmd.Env = []string{fmt.Sprintf("HOME=%s", os.Getenv("HOME"))}
167+
if err := cmd.Run(); err != nil {
168+
fmt.Printf("Could not send email to %s: %s\n", emails, err)
169+
os.Exit(1)
170+
}
171+
return
172+
}
173+
}
174+
175+
func (r *result) subject() string {
176+
hostname, err := os.Hostname()
177+
if err != nil {
178+
hostname = "undefined"
179+
}
180+
181+
if r.killed {
182+
return fmt.Sprintf("Cron on host %s: Timeout", hostname)
183+
}
184+
185+
if r.code == 0 {
186+
return fmt.Sprintf("Cron on host %s: Command Successful", hostname)
187+
}
188+
189+
return fmt.Sprintf("Cron on host %s: Failure", hostname)
190+
}
191+
192+
func (r *result) title() string {
193+
var msg string
194+
195+
if r.killed {
196+
msg = "Cron timeout detected"
197+
} else if r.code == 0 {
198+
msg = "Cron success"
199+
} else {
200+
msg = "Cron failure detected"
201+
}
202+
203+
return msg + "\n" + strings.Repeat("=", len(msg))
204+
}
205+
206+
func (r *result) duration() time.Duration {
207+
return r.stopped.Sub(r.started)
208+
}
209+
210+
func (r *result) render() *bytes.Buffer {
211+
tpl := template.Must(template.New("email").Parse(`{{.Title}}
212+
213+
{{.Command}}
214+
215+
METADATA
216+
--------
217+
218+
Exit Code: {{.Code}}
219+
Start: {{.Started}}
220+
Stop: {{.Stopped}}
221+
Duration: {{.Duration}}
222+
223+
ERROR OUTPUT
224+
------------
225+
226+
{{.Stderr}}
227+
228+
STANDARD OUTPUT
229+
---------------
230+
231+
{{.Stdout}}
232+
`))
233+
234+
data := struct {
235+
Title string
236+
Command string
237+
Started time.Time
238+
Stopped time.Time
239+
Duration time.Duration
240+
Code int
241+
Stderr string
242+
Stdout string
243+
}{
244+
Title: r.title(),
245+
Command: r.request.command,
246+
Started: r.started,
247+
Stopped: r.stopped,
248+
Duration: r.duration(),
249+
Code: r.code,
250+
Stderr: r.stderr.String(),
251+
Stdout: r.stdout.String(),
252+
}
253+
254+
contents := bytes.Buffer{}
255+
if err := tpl.Execute(&contents, data); err != nil {
256+
fmt.Println(err)
257+
os.Exit(1)
258+
}
259+
260+
return &contents
261+
}

0 commit comments

Comments
 (0)