Compare commits

...

8 Commits

Author SHA1 Message Date
IamTheFij a39c188a6c Make Python compatability a flag
continuous-integration/drone/push Build is passing Details
2020-02-19 17:35:28 -08:00
IamTheFij 8f93997b80 Update config to add a default log alert
continuous-integration/drone/push Build is passing Details
2020-02-19 09:57:42 -08:00
IamTheFij 65342fe0dd Add a default log alert
continuous-integration/drone/push Build is failing Details
2020-02-18 00:47:43 +00:00
IamTheFij a7d1b8ab74 Make confg path an arg 2020-02-18 00:47:30 +00:00
IamTheFij 43ba2914de Remove underscore var name 2020-02-18 00:46:56 +00:00
IamTheFij 5ed691fdf3 Try to allow parsing of Minitor-py templates
continuous-integration/drone/push Build is passing Details
This will make transition easier for an interim period. Will remove at
version 1.0
2020-02-18 00:23:37 +00:00
IamTheFij 0a0f6fe7c9 Remove command_shell key from example yaml
continuous-integration/drone/push Build is passing Details
2020-02-16 13:30:43 -08:00
IamTheFij d4e2cb7b9f Switch to a single key for command and command shell
continuous-integration/drone/push Build is passing Details
This makes the configuration more similar to Minitor-py and
docker-compose. If a string is passed, it will be executed in a shell.
If an array is passed, it will be executed in as a command directly.

This breaks compatiblity with previous versions of Minitor-go, but
closer to compatiblity with Minitor-py.
2020-02-16 13:25:11 -08:00
13 changed files with 182 additions and 109 deletions

View File

@ -8,29 +8,8 @@ Initial target is meant to be roughly compatible requiring only minor changes to
## Differences from Python version
There are a few key differences between the Python version and the v0.x Go version.
First, configuration keys cannot have multiple types in Go, so a different key must be used when specifying a Shell command as a string rather than a list of args. Instead of `command`, you must use `command_shell`. Eg:
minitor-py:
```yaml
monitors:
- name: Exec command
command: ['echo', 'test']
- name: Shell command
command: echo 'test'
```
minitor-go:
```yaml
monitors:
- name: Exec command
command: ['echo', 'test']
- name: Shell command
command_shell: echo 'test'
```
Second, templating for Alert messages has been updated. In the Python version, `str.format(...)` was used with certain keys passed in that could be used to format messages. In the Go version, we use a struct, `AlertNotice` defined in `alert.go` and the built in Go templating format. Eg.
Templating for Alert messages has been updated. In the Python version, `str.format(...)` was used with certain keys passed in that could be used to format messages. In the Go version, we use a struct, `AlertNotice` defined in `alert.go` and the built in Go templating format. Eg.
minitor-py:
```yaml
@ -38,7 +17,7 @@ alerts:
log_command:
command: ['echo', '{monitor_name}']
log_shell:
command_shell: 'echo {monitor_name}'
command: 'echo {monitor_name}'
```
minitor-go:
@ -47,7 +26,7 @@ alerts:
log_command:
command: ['echo', '{{.MonitorName}}']
log_shell:
command_shell: 'echo {{.MonitorName}}'
command: 'echo {{.MonitorName}}'
```
Finally, newlines in a shell command don't terminate a particular command. Semicolons must be used and continuations should not.
@ -56,7 +35,7 @@ minitor-py:
```yaml
alerts:
log_shell:
command_shell: >
command: >
echo "line 1"
echo "line 2"
echo "continued" \
@ -67,7 +46,7 @@ minitor-go:
```yaml
alerts:
log_shell:
command_shell: >
command: >
echo "line 1";
echo "line 2";
echo "continued"
@ -87,6 +66,7 @@ Pairity:
- [x] Implement Prometheus client to export metrics
- [x] Test coverage
- [x] Integration testing (manual or otherwise)
- [x] Allow commands and shell commands in the same config key
Improvement (potentially breaking):

View File

@ -5,6 +5,7 @@ import (
"fmt"
"log"
"os/exec"
"strings"
"text/template"
"time"
)
@ -12,8 +13,7 @@ import (
// Alert is a config driven mechanism for sending a notice
type Alert struct {
Name string
Command []string
CommandShell string `yaml:"command_shell"`
Command CommandOrShell
commandTemplate []*template.Template
commandShellTemplate *template.Template
}
@ -31,26 +31,40 @@ type AlertNotice struct {
// IsValid returns a boolean indicating if the Alert has been correctly
// configured
func (alert Alert) IsValid() bool {
atLeastOneCommand := (alert.CommandShell != "" || alert.Command != nil)
atMostOneCommand := (alert.CommandShell == "" || alert.Command == nil)
return atLeastOneCommand && atMostOneCommand
return !alert.Command.Empty()
}
// BuildTemplates compiles command templates for the Alert
func (alert *Alert) BuildTemplates() error {
// TODO: Remove legacy template support later after 1.0
legacy := strings.NewReplacer(
"{alert_count}", "{{.AlertCount}}",
"{alert_message}", "{{.MonitorName}} check has failed {{.FailureCount}} times",
"{failure_count}", "{{.FailureCount}}",
"{last_output}", "{{.LastCheckOutput}}",
"{last_success}", "{{.LastSuccess}}",
"{monitor_name}", "{{.MonitorName}}",
)
if LogDebug {
log.Printf("DEBUG: Building template for alert %s", alert.Name)
}
if alert.commandTemplate == nil && alert.Command != nil {
if alert.commandTemplate == nil && alert.Command.Command != nil {
alert.commandTemplate = []*template.Template{}
for i, cmdPart := range alert.Command {
for i, cmdPart := range alert.Command.Command {
if PyCompat {
cmdPart = legacy.Replace(cmdPart)
}
alert.commandTemplate = append(alert.commandTemplate, template.Must(
template.New(alert.Name+string(i)).Parse(cmdPart),
))
}
} else if alert.commandShellTemplate == nil && alert.CommandShell != "" {
} else if alert.commandShellTemplate == nil && alert.Command.ShellCommand != "" {
shellCmd := alert.Command.ShellCommand
if PyCompat {
shellCmd = legacy.Replace(shellCmd)
}
alert.commandShellTemplate = template.Must(
template.New(alert.Name).Parse(alert.CommandShell),
template.New(alert.Name).Parse(shellCmd),
)
} else {
return fmt.Errorf("No template provided for alert %s", alert.Name)
@ -60,7 +74,7 @@ func (alert *Alert) BuildTemplates() error {
}
// Send will send an alert notice by executing the command template
func (alert Alert) Send(notice AlertNotice) (output_str string, err error) {
func (alert Alert) Send(notice AlertNotice) (outputStr string, err error) {
log.Printf("INFO: Sending alert %s for %s", alert.Name, notice.MonitorName)
var cmd *exec.Cmd
if alert.commandTemplate != nil {
@ -95,10 +109,23 @@ func (alert Alert) Send(notice AlertNotice) (output_str string, err error) {
var output []byte
output, err = cmd.CombinedOutput()
output_str = string(output)
outputStr = string(output)
if LogDebug {
log.Printf("DEBUG: Alert output for: %s\n---\n%s\n---", alert.Name, output_str)
log.Printf("DEBUG: Alert output for: %s\n---\n%s\n---", alert.Name, outputStr)
}
return output_str, err
return outputStr, err
}
// NewLogAlert creates an alert that does basic logging using echo
func NewLogAlert() *Alert {
return &Alert{
Name: "log",
Command: CommandOrShell{
Command: []string{
"echo",
"{{.MonitorName}} check has failed {{.FailureCount}} times",
},
},
}
}

View File

@ -11,14 +11,9 @@ func TestAlertIsValid(t *testing.T) {
expected bool
name string
}{
{Alert{Command: []string{"echo", "test"}}, true, "Command only"},
{Alert{CommandShell: "echo test"}, true, "CommandShell only"},
{Alert{Command: CommandOrShell{Command: []string{"echo", "test"}}}, true, "Command only"},
{Alert{Command: CommandOrShell{ShellCommand: "echo test"}}, true, "CommandShell only"},
{Alert{}, false, "No commands"},
{
Alert{Command: []string{"echo", "test"}, CommandShell: "echo test"},
false,
"Both commands",
},
}
for _, c := range cases {
@ -39,39 +34,54 @@ func TestAlertSend(t *testing.T) {
expectedOutput string
expectErr bool
name string
pyCompat bool
}{
{
Alert{Command: []string{"echo", "{{.MonitorName}}"}},
Alert{Command: CommandOrShell{Command: []string{"echo", "{{.MonitorName}}"}}},
AlertNotice{MonitorName: "test"},
"test\n",
false,
"Command with template",
false,
},
{
Alert{CommandShell: "echo {{.MonitorName}}"},
Alert{Command: CommandOrShell{ShellCommand: "echo {{.MonitorName}}"}},
AlertNotice{MonitorName: "test"},
"test\n",
false,
"Command shell with template",
false,
},
{
Alert{Command: []string{"echo", "{{.Bad}}"}},
Alert{Command: CommandOrShell{Command: []string{"echo", "{{.Bad}}"}}},
AlertNotice{MonitorName: "test"},
"",
true,
"Command with bad template",
false,
},
{
Alert{CommandShell: "echo {{.Bad}}"},
Alert{Command: CommandOrShell{ShellCommand: "echo {{.Bad}}"}},
AlertNotice{MonitorName: "test"},
"",
true,
"Command shell with bad template",
false,
},
{
Alert{Command: CommandOrShell{ShellCommand: "echo {alert_message}"}},
AlertNotice{MonitorName: "test", FailureCount: 1},
"test check has failed 1 times\n",
false,
"Command shell with legacy template",
true,
},
}
for _, c := range cases {
log.Printf("Testing case %s", c.name)
// Set PyCompat to value of compat flag
PyCompat = c.pyCompat
c.alert.BuildTemplates()
output, err := c.alert.Send(c.notice)
hasErr := (err != nil)
@ -83,6 +93,8 @@ func TestAlertSend(t *testing.T) {
t.Errorf("Send(%v err), expected=%v actual=%v", c.name, "Err", err)
log.Printf("Case failed: %s", c.name)
}
// Set PyCompat back to default value
PyCompat = false
log.Println("-----")
}
}
@ -103,8 +115,8 @@ func TestAlertBuildTemplate(t *testing.T) {
expectErr bool
name string
}{
{Alert{Command: []string{"echo", "test"}}, false, "Command only"},
{Alert{CommandShell: "echo test"}, false, "CommandShell only"},
{Alert{Command: CommandOrShell{Command: []string{"echo", "test"}}}, false, "Command only"},
{Alert{Command: CommandOrShell{ShellCommand: "echo test"}}, false, "CommandShell only"},
{Alert{}, true, "No commands"},
}

View File

@ -15,10 +15,54 @@ type Config struct {
Alerts map[string]*Alert
}
// CommandOrShell type wraps a string or list of strings
// for executing a command directly or in a shell
type CommandOrShell struct {
ShellCommand string
Command []string
}
// Empty checks if the Command has a value
func (cos CommandOrShell) Empty() bool {
return (cos.ShellCommand == "" && cos.Command == nil)
}
// UnmarshalYAML allows unmarshalling either a string or slice of strings
// and parsing them as either a command or a shell command.
func (cos *CommandOrShell) UnmarshalYAML(unmarshal func(interface{}) error) error {
var cmd []string
err := unmarshal(&cmd)
// Error indicates this is shell command
if err != nil {
var shellCmd string
err := unmarshal(&shellCmd)
if err != nil {
return err
}
cos.ShellCommand = shellCmd
} else {
cos.Command = cmd
}
return nil
}
// IsValid checks config validity and returns true if valid
func (config Config) IsValid() (isValid bool) {
isValid = true
// Validate alerts
if config.Alerts == nil || len(config.Alerts) == 0 {
// This should never happen because there is a default alert named 'log' for now
log.Printf("ERROR: Invalid alert configuration: Must provide at least one alert")
isValid = false
}
for _, alert := range config.Alerts {
if !alert.IsValid() {
log.Printf("ERROR: Invalid alert configuration: %s", alert.Name)
isValid = false
}
}
// Validate monitors
if config.Monitors == nil || len(config.Monitors) == 0 {
log.Printf("ERROR: Invalid monitor configuration: Must provide at least one monitor")
@ -43,18 +87,6 @@ func (config Config) IsValid() (isValid bool) {
}
}
// Validate alerts
if config.Alerts == nil || len(config.Alerts) == 0 {
log.Printf("ERROR: Invalid alert configuration: Must provide at least one alert")
isValid = false
}
for _, alert := range config.Alerts {
if !alert.IsValid() {
log.Printf("ERROR: Invalid alert configuration: %s", alert.Name)
isValid = false
}
}
return
}
@ -86,6 +118,17 @@ func LoadConfig(filePath string) (config Config, err error) {
log.Printf("DEBUG: Config values:\n%v\n", config)
}
// Add log alert if not present
if PyCompat {
// Intialize alerts list if not present
if config.Alerts == nil {
config.Alerts = map[string]*Alert{}
}
if _, ok := config.Alerts["log"]; !ok {
config.Alerts["log"] = NewLogAlert()
}
}
if !config.IsValid() {
err = errors.New("Invalid configuration")
return

View File

@ -10,22 +10,29 @@ func TestLoadConfig(t *testing.T) {
configPath string
expectErr bool
name string
pyCompat bool
}{
{"./test/valid-config.yml", false, "Valid config file"},
{"./test/does-not-exist", true, "Invalid config path"},
{"./test/invalid-config-type.yml", true, "Invalid config type for key"},
{"./test/invalid-config-missing-alerts.yml", true, "Invalid config missing alerts"},
{"./test/invalid-config-unknown-alert.yml", true, "Invalid config unknown alert"},
{"./test/valid-config.yml", false, "Valid config file", false},
{"./test/valid-default-log-alert.yml", false, "Valid config file with default log alert PyCompat", true},
{"./test/valid-default-log-alert.yml", true, "Invalid config file no log alert", false},
{"./test/does-not-exist", true, "Invalid config path", false},
{"./test/invalid-config-type.yml", true, "Invalid config type for key", false},
{"./test/invalid-config-missing-alerts.yml", true, "Invalid config missing alerts", false},
{"./test/invalid-config-unknown-alert.yml", true, "Invalid config unknown alert", false},
}
for _, c := range cases {
log.Printf("Testing case %s", c.name)
// Set PyCompat based on compatibility mode
PyCompat = c.pyCompat
_, err := LoadConfig(c.configPath)
hasErr := (err != nil)
if hasErr != c.expectErr {
t.Errorf("LoadConfig(%v), expected_error=%v actual=%v", c.name, c.expectErr, err)
log.Printf("Case failed: %s", c.name)
}
// Set PyCompat to default value
PyCompat = false
log.Println("-----")
}
}
@ -42,7 +49,7 @@ func TestMultiLineConfig(t *testing.T) {
log.Println("-----")
log.Println("TestMultiLineConfig(parse > string)")
expected := "echo 'Some string with stuff'; echo \"<angle brackets>\"; exit 1\n"
actual := config.Monitors[0].CommandShell
actual := config.Monitors[0].Command.ShellCommand
if expected != actual {
t.Errorf("TestMultiLineConfig(>) failed")
t.Logf("string expected=`%v`", expected)
@ -70,7 +77,7 @@ func TestMultiLineConfig(t *testing.T) {
log.Println("-----")
log.Println("TestMultiLineConfig(parse | string)")
expected = "echo 'Some string with stuff'\necho '<angle brackets>'\n"
actual = config.Alerts["log_shell"].CommandShell
actual = config.Alerts["log_shell"].Command.ShellCommand
if expected != actual {
t.Errorf("TestMultiLineConfig(|) failed")
t.Logf("string expected=`%v`", expected)

View File

@ -18,6 +18,9 @@ var (
// Metrics contains all active metrics
Metrics = NewMetrics()
// PyCompat enables support for legacy Python templates
PyCompat = false
// version of minitor being run
version = "dev"
)
@ -83,7 +86,9 @@ func main() {
// Get debug flag
flag.BoolVar(&LogDebug, "debug", false, "Enables debug logs (default: false)")
flag.BoolVar(&ExportMetrics, "metrics", false, "Enables prometheus metrics exporting (default: false)")
flag.BoolVar(&PyCompat, "py-compat", false, "Enables support for legacy Python Minitor config. Will eventually be removed. (default: false)")
var showVersion = flag.Bool("version", false, "Display the version of minitor and exit")
var configPath = flag.String("config", "config.yml", "Alternate configuration path (default: config.yml)")
flag.Parse()
// Print version if flag is provided
@ -93,7 +98,7 @@ func main() {
}
// Load configuration
config, err := LoadConfig("config.yml")
config, err := LoadConfig(*configPath)
if err != nil {
log.Fatalf("Error loading config: %v", err)
}

View File

@ -18,7 +18,7 @@ func TestCheckMonitors(t *testing.T) {
Monitors: []*Monitor{
&Monitor{
Name: "Success",
Command: []string{"true"},
Command: CommandOrShell{Command: []string{"true"}},
},
},
},
@ -30,12 +30,12 @@ func TestCheckMonitors(t *testing.T) {
Monitors: []*Monitor{
&Monitor{
Name: "Failure",
Command: []string{"false"},
Command: CommandOrShell{Command: []string{"false"}},
AlertAfter: 1,
},
&Monitor{
Name: "Failure",
Command: []string{"false"},
Command: CommandOrShell{Command: []string{"false"}},
AlertDown: []string{"unknown"},
AlertAfter: 1,
},
@ -49,12 +49,12 @@ func TestCheckMonitors(t *testing.T) {
Monitors: []*Monitor{
&Monitor{
Name: "Success",
Command: []string{"ls"},
Command: CommandOrShell{Command: []string{"ls"}},
alertCount: 1,
},
&Monitor{
Name: "Success",
Command: []string{"true"},
Command: CommandOrShell{Command: []string{"true"}},
AlertUp: []string{"unknown"},
alertCount: 1,
},
@ -68,14 +68,14 @@ func TestCheckMonitors(t *testing.T) {
Monitors: []*Monitor{
&Monitor{
Name: "Failure",
Command: []string{"false"},
Command: CommandOrShell{Command: []string{"false"}},
AlertDown: []string{"good"},
AlertAfter: 1,
},
},
Alerts: map[string]*Alert{
"good": &Alert{
Command: []string{"true"},
Command: CommandOrShell{Command: []string{"true"}},
},
},
},
@ -87,7 +87,7 @@ func TestCheckMonitors(t *testing.T) {
Monitors: []*Monitor{
&Monitor{
Name: "Failure",
Command: []string{"false"},
Command: CommandOrShell{Command: []string{"false"}},
AlertDown: []string{"bad"},
AlertAfter: 1,
},
@ -95,7 +95,7 @@ func TestCheckMonitors(t *testing.T) {
Alerts: map[string]*Alert{
"bad": &Alert{
Name: "bad",
Command: []string{"false"},
Command: CommandOrShell{Command: []string{"false"}},
},
},
},

View File

@ -11,8 +11,7 @@ import (
type Monitor struct {
// Config values
Name string
Command []string
CommandShell string `yaml:"command_shell"`
Command CommandOrShell
AlertDown []string `yaml:"alert_down"`
AlertUp []string `yaml:"alert_up"`
CheckInterval float64 `yaml:"check_interval"`
@ -29,10 +28,7 @@ type Monitor struct {
// IsValid returns a boolean indicating if the Monitor has been correctly
// configured
func (monitor Monitor) IsValid() bool {
atLeastOneCommand := (monitor.CommandShell != "" || monitor.Command != nil)
atMostOneCommand := (monitor.CommandShell == "" || monitor.Command == nil)
return (atLeastOneCommand &&
atMostOneCommand &&
return (!monitor.Command.Empty() &&
monitor.getAlertAfter() > 0 &&
monitor.AlertDown != nil)
}
@ -52,10 +48,10 @@ func (monitor Monitor) ShouldCheck() bool {
// and a possible AlertNotice
func (monitor *Monitor) Check() (bool, *AlertNotice) {
var cmd *exec.Cmd
if monitor.Command != nil {
cmd = exec.Command(monitor.Command[0], monitor.Command[1:]...)
if monitor.Command.Command != nil {
cmd = exec.Command(monitor.Command.Command[0], monitor.Command.Command[1:]...)
} else {
cmd = ShellCommand(monitor.CommandShell)
cmd = ShellCommand(monitor.Command.ShellCommand)
}
output, err := cmd.CombinedOutput()

View File

@ -13,16 +13,11 @@ func TestMonitorIsValid(t *testing.T) {
expected bool
name string
}{
{Monitor{Command: []string{"echo", "test"}, AlertDown: []string{"log"}}, true, "Command only"},
{Monitor{CommandShell: "echo test", AlertDown: []string{"log"}}, true, "CommandShell only"},
{Monitor{Command: []string{"echo", "test"}}, false, "No AlertDown"},
{Monitor{Command: CommandOrShell{Command: []string{"echo", "test"}}, AlertDown: []string{"log"}}, true, "Command only"},
{Monitor{Command: CommandOrShell{ShellCommand: "echo test"}, AlertDown: []string{"log"}}, true, "CommandShell only"},
{Monitor{Command: CommandOrShell{Command: []string{"echo", "test"}}}, false, "No AlertDown"},
{Monitor{AlertDown: []string{"log"}}, false, "No commands"},
{
Monitor{Command: []string{"echo", "test"}, CommandShell: "echo test", AlertDown: []string{"log"}},
false,
"Both commands",
},
{Monitor{Command: []string{"echo", "test"}, AlertDown: []string{"log"}, AlertAfter: -1}, false, "Invalid alert threshold, -1"},
{Monitor{Command: CommandOrShell{Command: []string{"echo", "test"}}, AlertDown: []string{"log"}, AlertAfter: -1}, false, "Invalid alert threshold, -1"},
}
for _, c := range cases {
@ -254,22 +249,22 @@ func TestMonitorCheck(t *testing.T) {
name string
}{
{
Monitor{Command: []string{"echo", "success"}},
Monitor{Command: CommandOrShell{Command: []string{"echo", "success"}}},
expected{isSuccess: true, hasNotice: false, lastOutput: "success\n"},
"Test successful command",
},
{
Monitor{CommandShell: "echo success"},
Monitor{Command: CommandOrShell{ShellCommand: "echo success"}},
expected{isSuccess: true, hasNotice: false, lastOutput: "success\n"},
"Test successful command shell",
},
{
Monitor{Command: []string{"total", "failure"}},
Monitor{Command: CommandOrShell{Command: []string{"total", "failure"}}},
expected{isSuccess: false, hasNotice: true, lastOutput: ""},
"Test failed command",
},
{
Monitor{CommandShell: "false"},
Monitor{Command: CommandOrShell{ShellCommand: "false"}},
expected{isSuccess: false, hasNotice: true, lastOutput: ""},
"Test failed command shell",
},

View File

@ -25,7 +25,7 @@ alerts:
email_up:
command: [sendmail, "me@minitor.mon", "Recovered: {monitor_name}", "We're back!"]
mailgun_down:
command_shell: >
command: >
curl -s -X POST
-F subject="Alert! {{.MonitorName}} failed"
-F from="Minitor <minitor@minitor.mon>"
@ -34,7 +34,7 @@ alerts:
https://api.mailgun.net/v3/minitor.mon/messages
-u "api:${MAILGUN_API_KEY}"
sms_down:
command_shell: >
command: >
curl -s -X POST -F "Body=Failure! {{.MonitorName}} has failed"
-F "From=${AVAILABLE_NUMBER}" -F "To=${MY_PHONE}"
"https://api.twilio.com/2010-04-01/Accounts/${ACCOUNT_SID}/Messages"

View File

@ -7,7 +7,7 @@ monitors:
alert_down: ['log_command', 'log_shell']
alert_every: 0
- name: Shell
command_shell: >
command: >
echo 'Some string with stuff';
echo 'another line';
echo $PATH;
@ -20,4 +20,4 @@ alerts:
log_command:
command: ['echo', 'regular', '"command!!!"', "{{.MonitorName}}"]
log_shell:
command_shell: echo "Failure on {{.MonitorName}} User is $USER"
command: echo "Failure on {{.MonitorName}} User is $USER"

View File

@ -0,0 +1,8 @@
---
check_interval: 1
monitors:
- name: Command
command: ['echo', '$PATH']
alert_down: ['log']
alert_every: 0

View File

@ -3,7 +3,7 @@ check_interval: 1
monitors:
- name: Shell
command_shell: >
command: >
echo 'Some string with stuff';
echo "<angle brackets>";
exit 1
@ -13,6 +13,6 @@ monitors:
alerts:
log_shell:
command_shell: |
command: |
echo 'Some string with stuff'
echo '<angle brackets>'