diff --git a/cmd/runitor/main.go b/cmd/runitor/main.go index 29459f1..a06f896 100644 --- a/cmd/runitor/main.go +++ b/cmd/runitor/main.go @@ -24,12 +24,15 @@ import ( // RunConfig sets the behavior of a run. type RunConfig struct { - Quiet bool // No cmd stdout - Silent bool // No cmd stdout or stderr - NoStartPing bool // Don't send Start ping - NoOutputInPing bool // Don't send command std{out, err} with Success and Failure pings - PingBodyLimitIsExplicit bool // Explicit limit via flags - PingBodyLimit uint // Truncate ping body to last N bytes + Quiet bool // No cmd stdout + Silent bool // No cmd stdout or stderr + NoStartPing bool // Don't send Start ping + NoOutputInPing bool // Don't send command std{out, err} with Success and Failure pings + PingBodyLimitIsExplicit bool // Explicit limit via flags + PingBodyLimit uint // Truncate ping body to last N bytes + OnSuccess PingType // Ping type to send when command exits successfully + OnNonzeroExit PingType // Ping type to send when command exits with a nonzero code + OnExecFail PingType // Ping type to send when runitor cannot execute the command } // Globals used for building help and identification strings. @@ -109,6 +112,9 @@ func main() { every = flag.Duration("every", 0, "If non-zero, periodically run command at specified interval") quiet = flag.Bool("quiet", false, "Don't capture command's stdout") silent = flag.Bool("silent", false, "Don't capture command's stdout or stderr") + onSuccess = pingTypeFlag("on-success", PingTypeSuccess, "Ping type to send when command exits successfully") + onNonzeroExit = pingTypeFlag("on-nonzero-exit", PingTypeExitCode, "Ping type to send when command exits with a nonzero code") + onExecFail = pingTypeFlag("on-exec-fail", PingTypeFail, "Ping type to send when runitor cannot execute the command") noStartPing = flag.Bool("no-start-ping", false, "Don't send start ping") noOutputInPing = flag.Bool("no-output-in-ping", false, "Don't send command's output in pings") pingBodyLimit = flag.Uint("ping-body-limit", 10_000, "If non-zero, truncate the ping body to its last N bytes, including a truncation notice.") @@ -201,6 +207,9 @@ func main() { NoOutputInPing: *noOutputInPing, PingBodyLimitIsExplicit: pingBodyLimitFromArgs, PingBodyLimit: *pingBodyLimit, + OnSuccess: *onSuccess, + OnNonzeroExit: *onNonzeroExit, + OnExecFail: *onExecFail, } // Save this invocation so we don't repeat ourselves. @@ -232,13 +241,15 @@ func main() { } } -// Do function runs the cmd line, tees its output to terminal & ping body as configured in cfg -// and pings the monitoring API to signal start, and then success or failure of execution. -func Do(cmd []string, cfg RunConfig, handle string, p Pinger) (exitCode int) { +// Do function runs the cmd line, tees its output to terminal & ping body as +// configured in cfg and pings the monitoring API to signal start, and then +// success or failure of execution. Returns the exit code from the ran command +// unless execution has failed, in such case 1 is returned. +func Do(cmd []string, cfg RunConfig, handle string, p Pinger) int { if !cfg.NoStartPing { icfg, err := p.PingStart(handle) if err != nil { - log.Print("PingStart: ", err) + log.Print("Ping(start): ", err) } else if instanceLimit, ok := icfg.PingBodyLimit.Get(); ok { if cfg.PingBodyLimitIsExplicit { // Command line flag `-ping-body-limit` was used and @@ -290,25 +301,46 @@ func Do(cmd []string, cfg RunConfig, handle string, p Pinger) (exitCode int) { } exitCode, err := Run(cmd, cmdStdout, cmdStderr) - if err != nil { - if exitCode > 0 { - fmt.Fprintf(pb, "\n[%s] %v", Name, err) - } - - if exitCode == -1 { - // Write to host stderr and the ping buffer. - w := io.MultiWriter(os.Stderr, pb) - fmt.Fprintf(w, "[%s] %v\n", Name, err) - exitCode = 1 - } + var ping PingType + switch { + case exitCode == 0 && err == nil: + ping = cfg.OnSuccess + + case exitCode > 0 && err != nil: + // Successfully executed the command. + // Command exited with nonzero code. + fmt.Fprintf(pb, "\n[%s] %v", Name, err) + ping = cfg.OnNonzeroExit + + case exitCode == -1 && err != nil: + // Could not execute the command. + // Write to host stderr and the ping body. + w := io.MultiWriter(os.Stderr, pb) + fmt.Fprintf(w, "[%s] %v\n", Name, err) + ping = cfg.OnExecFail + exitCode = 1 } if pbr != nil && pbr.Wrapped() { fmt.Fprintf(pb, "\n[%s] Output truncated to last %d bytes.", Name, cfg.PingBodyLimit) } - if _, err := p.PingStatus(handle, exitCode, pb); err != nil { - log.Print("PingStatus: ", err) + switch ping { + case PingTypeSuccess: + _, err = p.PingSuccess(handle, pb) + case PingTypeFail: + _, err = p.PingFail(handle, pb) + case PingTypeLog: + _, err = p.PingLog(handle, pb) + default: + // A safe default: PingExitCode + // It's too late error out here. + // Command got executed. We need to deliver a ping. + _, err = p.PingExitCode(handle, exitCode, pb) + } + + if err != nil { + log.Printf("Ping(%s): %v\n", ping.String(), err) } return exitCode diff --git a/cmd/runitor/pingtype.go b/cmd/runitor/pingtype.go new file mode 100644 index 0000000..b104b59 --- /dev/null +++ b/cmd/runitor/pingtype.go @@ -0,0 +1,47 @@ +package main + +import ( + "flag" + "fmt" + "strings" +) + +// PingType is an enumerator type for PingType* constants. +type PingType int + +//go:generate enumer -type PingType -trimprefix=PingType -transform=kebab +const ( + PingTypeExitCode PingType = iota + PingTypeSuccess + PingTypeFail + PingTypeLog +) + +func pingTypeFlag(name string, dflt PingType, usage string) *PingType { + p := new(PingType) + *p = dflt + + opts := fmt.Sprintf("%s (default %s)", pingTypeOpts("|"), dflt) + usage = usage + " (" + opts + ")" + flag.Func(name, usage, func(s string) (err error) { + *p, err = PingTypeString(s) + if err != nil { + err = fmt.Errorf("recognized options: %s", opts) + } + return err + }) + + return p +} + +func pingTypeOpts(sep string) string { + var b strings.Builder + for _, v := range PingTypeValues() { + if b.Len() > 0 { + b.WriteString(sep) + } + b.WriteString(v.String()) + } + + return b.String() +} diff --git a/cmd/runitor/pingtype_enumer.go b/cmd/runitor/pingtype_enumer.go new file mode 100644 index 0000000..126bc92 --- /dev/null +++ b/cmd/runitor/pingtype_enumer.go @@ -0,0 +1,51 @@ +// Code generated by "enumer -type PingType -trimprefix=PingType -transform=kebab"; DO NOT EDIT. + +package main + +import ( + "fmt" +) + +const _PingTypeName = "exit-codesuccessfaillog" + +var _PingTypeIndex = [...]uint8{0, 9, 16, 20, 23} + +func (i PingType) String() string { + if i < 0 || i >= PingType(len(_PingTypeIndex)-1) { + return fmt.Sprintf("PingType(%d)", i) + } + return _PingTypeName[_PingTypeIndex[i]:_PingTypeIndex[i+1]] +} + +var _PingTypeValues = []PingType{0, 1, 2, 3} + +var _PingTypeNameToValueMap = map[string]PingType{ + _PingTypeName[0:9]: 0, + _PingTypeName[9:16]: 1, + _PingTypeName[16:20]: 2, + _PingTypeName[20:23]: 3, +} + +// PingTypeString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func PingTypeString(s string) (PingType, error) { + if val, ok := _PingTypeNameToValueMap[s]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to PingType values", s) +} + +// PingTypeValues returns all values of the enum +func PingTypeValues() []PingType { + return _PingTypeValues +} + +// IsAPingType returns "true" if the value is listed in the enum definition. "false" otherwise +func (i PingType) IsAPingType() bool { + for _, v := range _PingTypeValues { + if i == v { + return true + } + } + return false +} diff --git a/internal/api.go b/internal/api.go index 4626c5f..e5f439b 100644 --- a/internal/api.go +++ b/internal/api.go @@ -73,7 +73,10 @@ func retriableResponse(code int) bool { // https://healthchecks.io/docs/http_api/ type Pinger interface { PingStart(handle string) (*InstanceConfig, error) - PingStatus(handle string, exitCode int, body io.Reader) (*InstanceConfig, error) + PingLog(handle string, body io.Reader) (*InstanceConfig, error) + PingSuccess(handle string, body io.Reader) (*InstanceConfig, error) + PingFail(handle string, body io.Reader) (*InstanceConfig, error) + PingExitCode(handle string, exitCode int, body io.Reader) (*InstanceConfig, error) } // APIClient holds API endpoint URL, client behavior configuration, and embeds http.Client. @@ -214,14 +217,35 @@ func (c *APIClient) PingStart(handle string) (*InstanceConfig, error) { return c.ping(handle, "start", nil) } -// PingStatus sends the exit code of the monitored command for the check handle +// PingSuccess sends a success ping for the check handle and attaches body as +// the logged context. +func (c *APIClient) PingSuccess(handle string, body io.Reader) (*InstanceConfig, error) { + return c.ping(handle, "", body) +} + +// PingFail sends a failure ping for the check handle and attaches body as the +// logged context. +func (c *APIClient) PingFail(handle string, body io.Reader) (*InstanceConfig, error) { + return c.ping(handle, "fail", body) +} + +// PingLog sends a logging only ping for the check handle and attaches body as +// the logged context. +func (c *APIClient) PingLog(handle string, body io.Reader) (*InstanceConfig, error) { + return c.ping(handle, "log", body) +} + +// PingExitCode sends the exit code of the monitored command for the check handle // and attaches body as the logged context. -func (c *APIClient) PingStatus(handle string, exitCode int, body io.Reader) (*InstanceConfig, error) { +func (c *APIClient) PingExitCode(handle string, exitCode int, body io.Reader) (*InstanceConfig, error) { return c.ping(handle, fmt.Sprintf("%d", exitCode), body) } func (c *APIClient) ping(handle string, path string, body io.Reader) (*InstanceConfig, error) { - u := fmt.Sprintf("%s/%s/%s", c.BaseURL, handle, path) + u := c.BaseURL + "/" + handle + if len(path) > 0 { + u += "/" + path + } resp, err := c.Post(u, "text/plain", body) if err != nil { diff --git a/internal/api_test.go b/internal/api_test.go index 8fae9ea..c10f6da 100644 --- a/internal/api_test.go +++ b/internal/api_test.go @@ -5,6 +5,7 @@ package internal_test import ( "net/http" "net/http/httptest" + "strings" "sync/atomic" "testing" "time" @@ -48,7 +49,7 @@ func TestPostRequest(t *testing.T) { UserAgent: expUA, } - _, err := c.PingStatus(TestHandle, 0, nil) + _, err := c.PingSuccess(TestHandle, nil) if err != nil { t.Fatalf("expected successful Ping, got error: %+v", err) } @@ -104,7 +105,7 @@ func TestPostRetries(t *testing.T) { Backoff: backoff, } - _, err := c.PingStatus(TestHandle, 0, nil) + _, err := c.PingSuccess(TestHandle, nil) if err != nil { t.Fatalf("expected successful Ping, got error: %+v", err) } @@ -134,13 +135,13 @@ func TestPostNonRetriable(t *testing.T) { Client: ts.Client(), } - _, err := c.PingStatus(TestHandle, 0, nil) + _, err := c.PingSuccess(TestHandle, nil) if err == nil { - t.Errorf("expected PingStatus to return non-nil error after non-retriable API response") + t.Errorf("expected PingSuccess to return non-nil error after non-retriable API response") } } -// Tests if Ping{Start,Status} functions hit the correct URI paths. +// Tests if Ping{Start,Log,Status} functions hit the correct URI paths. func TestPostURIs(t *testing.T) { t.Parallel() @@ -148,19 +149,19 @@ func TestPostURIs(t *testing.T) { c := &APIClient{} - uriPrefix := "/" + TestHandle + "/" - // uriPath -> pingFunction testCases := map[string]ping{ - uriPrefix + "start": func() (*InstanceConfig, error) { return c.PingStart(TestHandle) }, - uriPrefix + "0": func() (*InstanceConfig, error) { return c.PingStatus(TestHandle, 0, nil) }, - uriPrefix + "1": func() (*InstanceConfig, error) { return c.PingStatus(TestHandle, 1, nil) }, + "/start": func() (*InstanceConfig, error) { return c.PingStart(TestHandle) }, + "": func() (*InstanceConfig, error) { return c.PingSuccess(TestHandle, nil) }, + "/fail": func() (*InstanceConfig, error) { return c.PingFail(TestHandle, nil) }, + "/log": func() (*InstanceConfig, error) { return c.PingLog(TestHandle, nil) }, + "/42": func() (*InstanceConfig, error) { return c.PingExitCode(TestHandle, 42, nil) }, } ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - uriPath := r.URL.Path - _, ok := testCases[uriPath] + tail := strings.TrimPrefix(r.URL.Path, "/"+TestHandle) + _, ok := testCases[tail] if !ok { - t.Fatalf("Unknown URI path '%v' received", uriPath) + t.Fatalf("Unexpected request to URL path '%v'", r.URL.Path) } // TODO(bdd): Find an equivalent replacement for this. @@ -214,7 +215,7 @@ func TestPostReqHeaders(t *testing.T) { ReqHeaders: expReqHeaders, } - _, err := c.PingStatus(TestHandle, 0, nil) + _, err := c.PingSuccess(TestHandle, nil) if err != nil { t.Fatalf("expected successful Ping, got error: %+v", err) }