Skip to content

Commit

Permalink
Add flags to customize ping type decisions
Browse files Browse the repository at this point in the history
Context:
Healthchecks added a "log" ping type to cater to use cases where a
periodic command is likely to exit with failure status but expected to
eventually succeed in future instantiations.
healthchecks/healthchecks#525 (comment)

Problems:
- "log" ping type is not supported.
- Let the user decide ping type to be sent for success, nonzero exit, or
  execution failure (e.g. command not found, no permission, system ran
  out pids, ...)

Changes:
- Introduce three new flags:
  + -on-success: ping type type to send when command exits successfully.
                 defaults to "success".
  + -on-nonzero-exit: ping type to send when command exits with nonzero code.
                      defaults to "exit-code".
  + -on-exec-fail: ping type to send when runitor cannot execute the command.
                   defaults to "fail"
  + valid values for these flags are: "exit-code", "success", "fail", "log".

- Addresses lack of "log" ping type support and use case in #65.
  • Loading branch information
bdd committed Oct 16, 2022
1 parent 5f60b74 commit 0be294c
Show file tree
Hide file tree
Showing 5 changed files with 196 additions and 41 deletions.
78 changes: 55 additions & 23 deletions cmd/runitor/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions cmd/runitor/pingtype.go
Original file line number Diff line number Diff line change
@@ -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()
}
51 changes: 51 additions & 0 deletions cmd/runitor/pingtype_enumer.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 28 additions & 4 deletions internal/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
29 changes: 15 additions & 14 deletions internal/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package internal_test
import (
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -134,33 +135,33 @@ 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()

type ping func() (*InstanceConfig, error)

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.
Expand Down Expand Up @@ -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)
}
Expand Down

0 comments on commit 0be294c

Please sign in to comment.