diff --git a/.gitignore b/.gitignore index 5b90e79..8848cc7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ *.so *.dylib +# build directory +build/ + # Test binary, built with `go test -c` *.test diff --git a/LICENSE b/LICENSE index 951735e..e69de29 100644 --- a/LICENSE +++ b/LICENSE @@ -1,9 +0,0 @@ -MIT License - -Copyright (c) 2024 faercol - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/check.go b/check.go new file mode 100644 index 0000000..166f5ce --- /dev/null +++ b/check.go @@ -0,0 +1,59 @@ +package gohealthchecks + +import ( + "path" + + "github.com/google/uuid" +) + +type Check interface { + path() string + params() map[string]string +} + +type uuidCheck uuid.UUID + +func (c uuidCheck) path() string { + return uuid.UUID(c).String() +} + +func (c uuidCheck) params() map[string]string { + return nil +} + +func NewUUIDCheck(rawUUID string) (Check, error) { + val, err := uuid.Parse(rawUUID) + if err != nil { + return nil, err + } + return uuidCheck(val), nil +} + +type slugCheck struct { + pingKey string + slug string + autoCreate bool +} + +func (c slugCheck) path() string { + return path.Join(c.pingKey, c.slug) +} + +func (c slugCheck) params() map[string]string { + res := map[string]string{} + createVal := "0" + if c.autoCreate { + createVal = "1" + } + res["create"] = createVal + + return res +} + +func NewSlugCheck(pingKey, slug string, autoCreate bool) Check { + return slugCheck{ + pingKey: pingKey, + slug: slug, + autoCreate: autoCreate, + } +} diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..736ef31 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,5 @@ +package main + +func main() { + Execute() +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..7ec9379 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,42 @@ +package main + +import ( + "os" + + gohealthchecks "git.faercol.me/faercol/go-healthchecks" + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "healthchecks-ping", + Short: "Client for the healthchecks ping API", + Long: ``, +} + +var ( + pingHost string + checkUUID string + pingKey string + checkSlug string + autoCreate bool +) + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + err := rootCmd.Execute() + if err != nil { + os.Exit(1) + } +} + +func init() { + rootCmd.PersistentFlags().StringVar(&pingHost, "host", gohealthchecks.HealthchecksIOPingHost, "healthchecks host to use") + rootCmd.PersistentFlags().BoolVar(&autoCreate, "auto-create", false, "auto create check if it does not exist (slug only)") + rootCmd.PersistentFlags().StringVar(&checkUUID, "uuid", "", "check UUID") + rootCmd.PersistentFlags().StringVar(&pingKey, "ping-key", "", "ping key") + rootCmd.PersistentFlags().StringVar(&checkSlug, "slug", "", "check-slug") + rootCmd.MarkFlagsRequiredTogether("ping-key", "slug") + rootCmd.MarkFlagsMutuallyExclusive("uuid", "slug") + rootCmd.MarkFlagsOneRequired("uuid", "slug") +} diff --git a/cmd/start.go b/cmd/start.go new file mode 100644 index 0000000..e8c6621 --- /dev/null +++ b/cmd/start.go @@ -0,0 +1,30 @@ +package main + +import ( + "fmt" + "os" + + gohealthchecks "git.faercol.me/faercol/go-healthchecks" + "github.com/spf13/cobra" +) + +var startCmd = &cobra.Command{ + Use: "start", + Short: "Signal the check has started", + Long: ``, + Run: func(cmd *cobra.Command, args []string) { + check := parseCheckFlags() + clt := gohealthchecks.NewPingClient(pingHost) + err := clt.ReportStart(cmd.Context(), check) + if err != nil { + failf("Failed to notify start: %s\n", err) + } else { + fmt.Println("Notified check start") + os.Exit(0) + } + }, +} + +func init() { + rootCmd.AddCommand(startCmd) +} diff --git a/cmd/success.go b/cmd/success.go new file mode 100644 index 0000000..a4db755 --- /dev/null +++ b/cmd/success.go @@ -0,0 +1,30 @@ +package main + +import ( + "fmt" + "os" + + gohealthchecks "git.faercol.me/faercol/go-healthchecks" + "github.com/spf13/cobra" +) + +var successCmd = &cobra.Command{ + Use: "success", + Short: "Signal the check has successfully completed", + Long: ``, + Run: func(cmd *cobra.Command, args []string) { + check := parseCheckFlags() + clt := gohealthchecks.NewPingClient(pingHost) + err := clt.ReportSuccess(cmd.Context(), check) + if err != nil { + failf("Failed to notify success: %s\n", err) + } else { + fmt.Println("Notified check success") + os.Exit(0) + } + }, +} + +func init() { + rootCmd.AddCommand(successCmd) +} diff --git a/cmd/utils.go b/cmd/utils.go new file mode 100644 index 0000000..7277eb9 --- /dev/null +++ b/cmd/utils.go @@ -0,0 +1,36 @@ +package main + +import ( + "fmt" + "os" + + gohealthchecks "git.faercol.me/faercol/go-healthchecks" +) + +func mustParseUUIDCheck(rawVal string) gohealthchecks.Check { + check, err := gohealthchecks.NewUUIDCheck(rawVal) + if err != nil { + failf("Invalid UUID: %s\n", err) + } + return check +} + +func parseCheckFlags() gohealthchecks.Check { + var check gohealthchecks.Check + if checkUUID != "" { + check = mustParseUUIDCheck(checkUUID) + } else { + check = gohealthchecks.NewSlugCheck(pingKey, checkSlug, autoCreate) + } + return check +} + +func failf(format string, args ...any) { + fmt.Fprintf(os.Stderr, format, args...) + os.Exit(1) +} + +func failln(msg string) { + fmt.Fprintln(os.Stderr, msg) + os.Exit(1) +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..d317656 --- /dev/null +++ b/config.go @@ -0,0 +1,4 @@ +package gohealthchecks + +type Config struct { +} diff --git a/consts.go b/consts.go new file mode 100644 index 0000000..919bf0f --- /dev/null +++ b/consts.go @@ -0,0 +1,3 @@ +package gohealthchecks + +const HealthchecksIOPingHost = "https://hc-ping.com" diff --git a/error.go b/error.go new file mode 100644 index 0000000..1db3f77 --- /dev/null +++ b/error.go @@ -0,0 +1,11 @@ +package gohealthchecks + +import ( + "errors" +) + +var ( + ErrCheckNotFound = errors.New("check not found on remote host") + ErrSlugConflict = errors.New("provided slug has a conflict") + ErrRateLimit = errors.New("rate limit exceeded") +) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..edb1c28 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module git.faercol.me/faercol/go-healthchecks + +go 1.23.1 + +require ( + github.com/google/uuid v1.1.2 + github.com/spf13/cobra v1.8.1 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3b4d2f9 --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pingclient.go b/pingclient.go new file mode 100644 index 0000000..13354f4 --- /dev/null +++ b/pingclient.go @@ -0,0 +1,156 @@ +package gohealthchecks + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/url" + "strconv" +) + +func handleResponse(resp *http.Response) error { + if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated { + return nil // don't do anything more here, we would just have a `OK` or `Created` in the body + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + switch resp.StatusCode { + case http.StatusNotFound: + return ErrCheckNotFound + case http.StatusConflict: + return ErrSlugConflict + case http.StatusTooManyRequests: + return ErrRateLimit + case http.StatusBadRequest: + return fmt.Errorf("invalid URL format: %s", string(respBody)) + default: + return fmt.Errorf("unexpected statuscode %d (%s)", resp.StatusCode, string(respBody)) + } +} + +func addQueryParams(reqURL *url.URL, check Check) { + q := reqURL.Query() + for param, value := range check.params() { + q.Add(param, value) + } + reqURL.RawQuery = q.Encode() +} + +type PingClient interface { + ReportSuccess(ctx context.Context, check Check) error + ReportStart(ctx context.Context, check Check) error + ReportFailure(ctx context.Context, check Check) error + LogMessage(ctx context.Context, check Check, log []byte) error + ReportExitCode(ctx context.Context, check Check, exitCode int) error +} + +type pingClient struct { + httpClt http.Client + host string +} + +func (c *pingClient) ReportSuccess(ctx context.Context, check Check) error { + url, err := url.JoinPath(c.host, check.path()) + if err != nil { + return fmt.Errorf("invalid check or hostname provided: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) + if err != nil { + return fmt.Errorf("failed to build ping request: %w", err) + } + addQueryParams(req.URL, check) + + resp, err := c.httpClt.Do(req) + if err != nil { + return fmt.Errorf("failed to send ping: %w", err) + } + return handleResponse(resp) +} + +func (c *pingClient) ReportStart(ctx context.Context, check Check) error { + url, err := url.JoinPath(c.host, check.path(), "start") + if err != nil { + return fmt.Errorf("invalid check or hostname provided: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) + if err != nil { + return fmt.Errorf("failed to build ping request: %w", err) + } + addQueryParams(req.URL, check) + + resp, err := c.httpClt.Do(req) + if err != nil { + return fmt.Errorf("failed to notify start: %w", err) + } + return handleResponse(resp) +} +func (c *pingClient) ReportFailure(ctx context.Context, check Check) error { + url, err := url.JoinPath(c.host, check.path(), "fail") + if err != nil { + return fmt.Errorf("invalid check or hostname provided: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) + if err != nil { + return fmt.Errorf("failed to build ping request: %w", err) + } + addQueryParams(req.URL, check) + + resp, err := c.httpClt.Do(req) + if err != nil { + return fmt.Errorf("failed to send ping: %w", err) + } + return handleResponse(resp) +} +func (c *pingClient) LogMessage(ctx context.Context, check Check, log []byte) error { + url, err := url.JoinPath(c.host, check.path(), "log") + if err != nil { + return fmt.Errorf("invalid check or hostname provided: %w", err) + } + + reqBody := bytes.NewBuffer(log) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, reqBody) + if err != nil { + return fmt.Errorf("failed to build log request: %w", err) + } + addQueryParams(req.URL, check) + + resp, err := c.httpClt.Do(req) + if err != nil { + return fmt.Errorf("failed to send log: %w", err) + } + return handleResponse(resp) +} + +func (c *pingClient) ReportExitCode(ctx context.Context, check Check, exitCode int) error { + url, err := url.JoinPath(c.host, check.path(), strconv.FormatInt(int64(exitCode), 10)) + if err != nil { + return fmt.Errorf("invalid check or hostname provided: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) + if err != nil { + return fmt.Errorf("failed to build exitcode request: %w", err) + } + addQueryParams(req.URL, check) + + resp, err := c.httpClt.Do(req) + if err != nil { + return fmt.Errorf("failed to send exitcode: %w", err) + } + return handleResponse(resp) +} + +func NewPingClient(host string) PingClient { + return &pingClient{ + httpClt: *http.DefaultClient, + host: host, + } +}