Add start of lib and test client

This commit is contained in:
Melora Hugues 2024-10-02 15:26:14 +02:00
parent 5ecb803260
commit bc34b2d813
14 changed files with 404 additions and 9 deletions

3
.gitignore vendored
View file

@ -9,6 +9,9 @@
*.so
*.dylib
# build directory
build/
# Test binary, built with `go test -c`
*.test

View file

@ -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.

59
check.go Normal file
View file

@ -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,
}
}

5
cmd/main.go Normal file
View file

@ -0,0 +1,5 @@
package main
func main() {
Execute()
}

42
cmd/root.go Normal file
View file

@ -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")
}

30
cmd/start.go Normal file
View file

@ -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)
}

30
cmd/success.go Normal file
View file

@ -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)
}

36
cmd/utils.go Normal file
View file

@ -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)
}

4
config.go Normal file
View file

@ -0,0 +1,4 @@
package gohealthchecks
type Config struct {
}

3
consts.go Normal file
View file

@ -0,0 +1,3 @@
package gohealthchecks
const HealthchecksIOPingHost = "https://hc-ping.com"

11
error.go Normal file
View file

@ -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")
)

13
go.mod Normal file
View file

@ -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
)

12
go.sum Normal file
View file

@ -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=

156
pingclient.go Normal file
View file

@ -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,
}
}