From add5f8fc580273fb3ad1252a8c1aa2a40eeaac9b Mon Sep 17 00:00:00 2001 From: Theo Willows Date: Thu, 24 Oct 2019 14:31:16 +0200 Subject: [PATCH] --- .github/workflows/build.yml | 26 +++++ .gitignore | 2 + LICENSE | 21 ++++ README.md | 107 +++++++++++++++++++ check.go | 31 ++++++ cmd/healthcheck/main.go | 201 ++++++++++++++++++++++++++++++++++++ doc.go | 29 ++++++ go.mod | 35 +++++++ go.sum | 102 ++++++++++++++++++ health.go | 144 ++++++++++++++++++++++++++ health_test.go | 60 +++++++++++ response.go | 71 +++++++++++++ server.go | 108 +++++++++++++++++++ server_test.go | 36 +++++++ status.go | 77 ++++++++++++++ status_test.go | 26 +++++ 16 files changed, 1076 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 check.go create mode 100644 cmd/healthcheck/main.go create mode 100644 doc.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 health.go create mode 100644 health_test.go create mode 100644 response.go create mode 100644 server.go create mode 100644 server_test.go create mode 100644 status.go create mode 100644 status_test.go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..d6ff7b8 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,26 @@ +%YAML 1.1 +--- +name: Build + +on: + - push + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Set up Go + uses: actions/setup-go@v1 + with: + go-version: 1.13 + id: go + + - name: Check out code into the Go module directory + uses: actions/checkout@v1 + + - name: Build + run: go build -v ./... + + - name: Test + run: go test -race -v ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a1a769c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*~ +/vendor/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7f1c30a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 The Swedish Internet Foundation + +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/README.md b/README.md new file mode 100644 index 0000000..6c0af61 --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# ⚕ `go-health` + +[![License](https://img.shields.io/github/license/dotse/go-health)](https://opensource.org/licenses/MIT) +[![GoDoc](https://img.shields.io/badge/-Documentation-green?logo=go)](https://godoc.org/github.com/dotse/go-health) +[![Actions](https://github.com/dotse/go-health/workflows/Build/badge.svg?branch=master)](https://github.com/dotse/go-health/actions) +[![Releases](https://img.shields.io/github/v/release/dotse/go-health?sort=semver)](https://github.com/dotse/go-health/releases) +[![Issues](https://img.shields.io/github/issues/dotse/go-health)](https://github.com/dotse/go-health/issues) + +`go-health` is a Go library for easily setting up monitoring of anything within +an application. Anything that can have a health status can be registered, and +then, as if by magic 🧙, an HTTP server is running and serving a combined health +status. + +It follows the proposed standard [_Health Check Response Format for HTTP APIs_] +(but you don’t even have to know that, `go-health` takes care of that for you). + +## Example + +ℹ️ The code below is minimal. There’s more to `go-health`, but this is enough to +get something up and running. + +### Setting up Health Checks + +Let‘s say your application has a database handle, and you want to monitor that +it can actually communicate with the database. Simply implement the `Checker` +interface: + +```go +import ( + "github.com/dotse/go-health" +) + +type MyApplication struct { + db sql.DB + + // ... +} + +func (app *MyApplication) CheckHealth() []health.Check { + c := health.Check{} + if err := app.db.Ping(); err != nil{ + c.Status = health.StatusFail + c.Output = err.Error() + } + return []health.Check{ c } +} +``` + +Then whenever you create your application register it as a health check: + +```go +app := NewMyApplication() +health.Register(true, "my-application", app) +``` + +Either like the above, e.g. in `main()`, or the application could even register +_itself_ on creation. You can register as many times as you want. The reported +health status will be the ‘worst’ of all the registered checkers. + +Then there will be an HTTP server listening on and +serving a fresh health [response] on each request. + +### Checking the Health + +To then check the health, GET the response and look at its `status` field. + +`go-health` has a function for this too: + +```go +resp, err := health.CheckHealth(c.config) +if err == nil { + fmt.Printf("Status: %s\n", resp.Status) +} else { + fmt.Printf("ERROR: %v\n", err) +} +``` + +### Using as a Docker Health Check + +An easy way to create a health check for a Docker image is to use the same +binary as your application to do the checking. E.g. if the application is +invoked with the first argument `healthcheck`: + +```go +func main() { + if os.Args[1] == "healthcheck" { + health.CheckHealthCommand() + } + + // Your other code... +} +``` + +`CheckHealthCommand()` will GET the current health from the local HTTP server, +parse the response and `os.Exit()` either 0 or 1, depending on the health. + +Then in your `Dockerfile` add: + +```dockerfile +HEALTHCHECK --interval=10s --timeout=30s CMD ./app healthcheck +``` + +💁 Voilà! A few lines of code and your Docker image has a built-in health check +for all the things you want monitored. + +[_health check response format for http apis_]: https://inadarei.github.io/rfc-healthcheck/ +[response]: https://inadarei.github.io/rfc-healthcheck/#rfc.section.3 diff --git a/check.go b/check.go new file mode 100644 index 0000000..e670ab6 --- /dev/null +++ b/check.go @@ -0,0 +1,31 @@ +// Copyright © 2019 The Swedish Internet Foundation +// +// Distributed under the MIT License. (See accompanying LICENSE file or copy at +// .) + +package health + +import ( + "net/url" + "time" +) + +// Check represent a single health check point. +type Check struct { + ComponentID string `json:"componentId,omitempty"` + ComponentType string `json:"componentType,omitempty"` + ObservedValue interface{} `json:"observedValue,omitempty"` + ObservedUnit string `json:"observedUnit,omitempty"` + Status Status `json:"status"` + AffectedEndpoints []url.URL `json:"affectedEndpoints,omitempty"` + Time time.Time `json:"time,omitempty"` + Output string `json:"output,omitempty"` + Links []url.URL `json:"links,omitempty"` +} + +// SetObservedTime sets the observedValue field to a time duration (and the +// observedUnit field to the correct unit). +func (check *Check) SetObservedTime(duration time.Duration) { + check.ObservedValue = duration.Nanoseconds() + check.ObservedUnit = "ns" +} diff --git a/cmd/healthcheck/main.go b/cmd/healthcheck/main.go new file mode 100644 index 0000000..6e52853 --- /dev/null +++ b/cmd/healthcheck/main.go @@ -0,0 +1,201 @@ +// Copyright © 2019 The Swedish Internet Foundation +// +// Distributed under the MIT License. (See accompanying LICENSE file or copy at +// .) + +package main + +import ( + "bytes" + "context" + "flag" + "fmt" + "log" + "os" + "os/signal" + "strings" + "time" + + "github.com/docker/docker/client" + "github.com/tidwall/pretty" + "golang.org/x/crypto/ssh/terminal" + + "github.com/dotse/go-health" +) + +const ( + errorKey = "error" +) + +type cmd struct { + config health.CheckHealthConfig + continuous bool + interval time.Duration + isatty bool + print func(*health.Response) + short bool + stats map[string]uint64 + stop bool +} + +func newCmd() cmd { + c := cmd{} + + var ( + docker bool + port int + timeout time.Duration + ) + + flag.BoolVar(&c.continuous, "c", false, "Run continuously (stop with Ctrl+C).") + flag.BoolVar(&docker, "d", false, "Address is the name of a Docker container.") + flag.DurationVar(&c.interval, "n", 0, "Interval between continuous checks (implies -c) (default: 2s).") + flag.IntVar(&port, "p", 0, "Port.") + flag.BoolVar(&c.short, "s", false, "Short output (just the status).") + flag.DurationVar(&timeout, "t", 0, "HTTP timeout.") + + flag.Parse() + + // Setting interval implies continuous + c.continuous = c.continuous || c.interval != 0 + if c.continuous && c.interval == 0 { + c.interval = 2 * time.Second + } + + var host string + + if docker { + var err error + if host, err = getContainerAddress(flag.Arg(0)); err != nil { + log.Fatal(err) + } + } else { + host = flag.Arg(0) + } + + c.config = health.CheckHealthConfig{ + Port: port, + Host: host, + Timeout: timeout, + } + + c.isatty = terminal.IsTerminal(int(os.Stdout.Fd())) + c.print = c.makePrint() + + return c +} + +func (c *cmd) exit() { + if c.continuous && c.isatty { + var str []string + + for status, count := range c.stats { + str = append(str, fmt.Sprintf("\033[%dm%d %s\033[0m", map[string]int{ + health.StatusPass.String(): 32, + health.StatusWarn.String(): 33, + health.StatusFail.String(): 31, + errorKey: 91, + }[status], count, status)) + } + + fmt.Printf("\n---\n%s\n", strings.Join(str, ", ")) + } + + for status, count := range c.stats { + if status == health.StatusPass.String() { + continue + } + + if count > 0 { + os.Exit(1) + } + } + + os.Exit(0) +} + +func (c *cmd) makePrint() func(*health.Response) { + switch { + case c.continuous && c.isatty && c.short: + return func(resp *health.Response) { + fmt.Printf("%s %s\r", time.Now().Format(time.RFC3339), resp.Status) + } + + case c.short: + return func(resp *health.Response) { + fmt.Println(resp.Status) + } + + case c.isatty: + return func(resp *health.Response) { + var buffer bytes.Buffer + _, _ = resp.Write(&buffer) + fmt.Println(string(pretty.Color(pretty.Pretty(buffer.Bytes()), nil))) + } + + default: + return func(resp *health.Response) { + _, _ = resp.Write(os.Stdout) + } + } +} + +func (c *cmd) run() { + c.stats = make(map[string]uint64) + + go c.wait() + + for !c.stop { + resp, err := health.CheckHealth(c.config) + if err == nil { + c.stats[resp.Status.String()]++ + c.print(resp) + } else { + c.stats[errorKey]++ + log.Println(err) + } + + if !c.continuous { + c.exit() + } + + time.Sleep(c.interval) + } +} + +func (c *cmd) wait() { + channel := make(chan os.Signal, 1) + + signal.Notify(channel, os.Interrupt) + + <-channel + + c.stop = true + + c.exit() +} + +func getContainerAddress(container string) (string, error) { + cli, err := client.NewClientWithOpts(client.FromEnv) + if err != nil { + return "", err + } + + containerJSON, err := cli.ContainerInspect(context.Background(), container) + if err != nil { + return "", err + } + + for _, network := range containerJSON.NetworkSettings.Networks { + if network.IPAddress != "" { + return network.IPAddress, nil + } + } + + return "", fmt.Errorf("couldn’t find address of %q", container) +} + +func main() { + c := newCmd() + c.run() +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..ff63374 --- /dev/null +++ b/doc.go @@ -0,0 +1,29 @@ +// Copyright © 2019 The Swedish Internet Foundation +// +// Distributed under the MIT License. (See accompanying LICENSE file or copy at +// .) + +/* + +Package health contains health checking utilities. + +The most interesting part of the API is Register (and RegisterFunc), which is a +simple way to set up a health check for ‘anything’. + +As soon as any health checks are registered a summary of them is served at +http://0.0.0.0:9999. + +For services there is `HealthCheckCommand()` to put (early) in `main()`, e.g: + + if len(os.Args) >= 2 && os.Args[1] == "healthcheck" { + health.CheckHealthCommand() + } + +Docker images can then use the following: + + HEALTHCHECK --interval=10s --timeout=30s CMD ./app healthcheck + +See . + +*/ +package health diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d2f9952 --- /dev/null +++ b/go.mod @@ -0,0 +1,35 @@ +module github.com/dotse/go-health + +go 1.13 + +require ( + github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 // indirect + github.com/Microsoft/go-winio v0.4.14 // indirect + github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect + github.com/docker/distribution v2.7.1+incompatible // indirect + github.com/docker/docker v1.13.2-0.20170601211448-f5ec1e2936dc + github.com/docker/go-connections v0.4.0 // indirect + github.com/docker/go-units v0.4.0 // indirect + github.com/gogo/protobuf v1.3.1 // indirect + github.com/google/go-cmp v0.3.1 // indirect + github.com/gorilla/mux v1.7.3 // indirect + github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect + github.com/kr/pretty v0.1.0 // indirect + github.com/opencontainers/go-digest v1.0.0-rc1 // indirect + github.com/opencontainers/image-spec v1.0.1 // indirect + github.com/sirupsen/logrus v1.4.2 // indirect + github.com/stretchr/testify v1.4.0 + github.com/tidwall/pretty v1.0.0 + golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 + golang.org/x/net v0.0.0-20191021144547-ec77196f6094 // indirect + golang.org/x/sys v0.0.0-20191024073052-e66fe6eb8e0c // indirect + golang.org/x/text v0.3.2 // indirect + golang.org/x/time v0.0.0-20191024005414-555d28b269f0 // indirect + google.golang.org/genproto v0.0.0-20191009194640-548a555dbc03 // indirect + google.golang.org/grpc v1.24.0 // indirect + gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect + gopkg.in/yaml.v2 v2.2.4 // indirect + gotest.tools v2.2.0+incompatible // indirect +) + +replace github.com/docker/docker => github.com/docker/engine v0.0.0-20180816081446-320063a2ad06 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..73fed93 --- /dev/null +++ b/go.sum @@ -0,0 +1,102 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8= +github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU= +github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug= +github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/engine v0.0.0-20180816081446-320063a2ad06 h1:CcxlLWAS/9b46iqHDTBlALJZF9atXVNjeymdCNrUfnY= +github.com/docker/engine v0.0.0-20180816081446-320063a2ad06/go.mod h1:3CPr2caMgTHxxIAZgEMd3uLYPDlRvPqCpyeRf6ncPcY= +github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= +github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= +github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw= +github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= +github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20191021144547-ec77196f6094 h1:5O4U9trLjNpuhpynaDsqwCk+Tw6seqJz1EbqbnzHrc8= +golang.org/x/net v0.0.0-20191021144547-ec77196f6094/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b h1:ag/x1USPSsqHud38I9BAC88qdNLDHHtQ4mlgQIZPPNA= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191024073052-e66fe6eb8e0c h1:usSYQsGq37L8RjJc5eznJ/AbwBxn3QFFEVkWNPAejLs= +golang.org/x/sys v0.0.0-20191024073052-e66fe6eb8e0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0 h1:/5xXl8Y5W96D+TtHSlonuFqGHIWVuyCkGJLwGh9JJFs= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8 h1:Nw54tB0rB7hY/N0NQvRW8DG4Yk3Q6T9cu9RcFQDu1tc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20191009194640-548a555dbc03 h1:4HYDjxeNXAOTv3o1N2tjo8UUSlhQgAD52FVkwxnWgM8= +google.golang.org/genproto v0.0.0-20191009194640-548a555dbc03/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.24.0/go.mod h1:XDChyiUovWa60DnaeDeZmSW86xtLtjtZbwvSiRnRtcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/health.go b/health.go new file mode 100644 index 0000000..b39790b --- /dev/null +++ b/health.go @@ -0,0 +1,144 @@ +// Copyright © 2019 The Swedish Internet Foundation +// +// Distributed under the MIT License. (See accompanying LICENSE file or copy at +// .) + +package health + +import ( + "fmt" + "net" + "net/http" + "os" + "strconv" + "time" +) + +const ( + // ComponentTypeComponent is "component". + ComponentTypeComponent = "component" + // ComponentTypeDatastore is "datastore". + ComponentTypeDatastore = "datastore" + // ComponentTypeSystem is "system". + ComponentTypeSystem = "system" + + port = 9999 + timeout = 30 * time.Second +) + +// CheckHealthConfig contains configuration for the CheckHealth() function. +type CheckHealthConfig struct { + // The hostname. Defaults to 127.0.0.1. + Host string + + // The port number. Defaults to 9,999. + Port int + + // HTTP timeout. Defaults to 30 seconds. + Timeout time.Duration +} + +// CheckHealth gets a Response from an HTTP server. +func CheckHealth(config CheckHealthConfig) (*Response, error) { + if config.Host == "" { + config.Host = "127.0.0.1" + } + + if config.Port == 0 { + config.Port = port + } + + if config.Timeout == 0 { + config.Timeout = timeout + } + + var ( + client = http.Client{ + Timeout: config.Timeout, + } + httpResp, err = client.Get(fmt.Sprintf("http://%s/", net.JoinHostPort(config.Host, strconv.Itoa(config.Port)))) + ) + + if err != nil { + return nil, err + } + defer httpResp.Body.Close() // nolint: errcheck + + resp, err := ReadResponse(httpResp.Body) + if err != nil { + return nil, err + } + + return resp, nil +} + +// CheckHealthCommand is a utility for services that exits the current process +// with 0 or 1 for a healthy or unhealthy state, respectively. +func CheckHealthCommand() { + resp, err := CheckHealth(CheckHealthConfig{}) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v", err) + os.Exit(1) + } + + _, _ = resp.Write(os.Stdout) + + if resp.Good() { + os.Exit(0) + } + + os.Exit(1) +} + +// Checker can be implemented by anything whose health can be checked. +type Checker interface { + CheckHealth() []Check +} + +// Registered is returned when registering a health check. It can be used to +// deregister that particular check at a later time, e.g. when closing whatever +// is being checked. +type Registered string + +// Deregister removes a previously registered health checker. +func (r Registered) Deregister() { + s := getServer() + + s.mtx.Lock() + defer s.mtx.Unlock() + + delete(s.checkers, string(r)) +} + +// Register registers a health checker. Can also make sure the server is +// started. +func Register(startServer bool, name string, checker Checker) Registered { + if startServer { + StartServer() + } + + s := getServer() + + s.mtx.Lock() + defer s.mtx.Unlock() + + name = insertUnique(s.checkers, name, checker) + + return Registered(name) +} + +// RegisterFunc registers a health check function. Can also make sure the server +// is started. +func RegisterFunc(startServer bool, name string, f func() []Check) Registered { + return Register(startServer, name, &checkFuncWrapper{ + Func: f, + }) +} + +type checkFuncWrapper struct { + Func func() []Check +} + +func (wrapper *checkFuncWrapper) CheckHealth() []Check { + return wrapper.Func() +} diff --git a/health_test.go b/health_test.go new file mode 100644 index 0000000..f6341e7 --- /dev/null +++ b/health_test.go @@ -0,0 +1,60 @@ +// Copyright © 2019 The Swedish Internet Foundation +// +// Distributed under the MIT License. (See accompanying LICENSE file or copy at +// .) + +package health + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type MyTypeWithHealthCheck struct{} + +func (*MyTypeWithHealthCheck) CheckHealth() []Check { + return []Check{{}} +} + +func Example() { + // Register an instance of some type that implements HealthChecker: + m := new(MyTypeWithHealthCheck) + Register(true, "mytype", m) + + // Register a function: + RegisterFunc(true, "func", func() (checks []Check) { + // Checkers can return any number of checks. + for i := 0; i < 3; i++ { + var check Check + // Make the relevant changes to `check` here, most importantly + // `check.Status`. + checks = append(checks, check) + } + return + }) +} + +func TestReadResponse(t *testing.T) { + r := strings.NewReader(`{ "status": "pass" }`) + + resp, err := ReadResponse(r) + assert.NoError(t, err) + require.NotNil(t, resp) + + assert.EqualValues(t, StatusPass, resp.Status) +} + +func TestResponse_Write(t *testing.T) { + var ( + b strings.Builder + resp Response + ) + + _, err := resp.Write(&b) + require.NoError(t, err) + + assert.EqualValues(t, `{"status":"pass"}`, b.String()) +} diff --git a/response.go b/response.go new file mode 100644 index 0000000..a47e050 --- /dev/null +++ b/response.go @@ -0,0 +1,71 @@ +// Copyright © 2019 The Swedish Internet Foundation +// +// Distributed under the MIT License. (See accompanying LICENSE file or copy at +// .) + +package health + +import ( + "bytes" + "encoding/json" + "io" + "net/url" +) + +// Response represents a health check response, containing any number of Checks. +type Response struct { + Status Status `json:"status"` + Version string `json:"version,omitempty"` + ReleaseID string `json:"releaseId,omitempty"` + Notes []string `json:"notes,omitempty"` + Output string `json:"output,omitempty"` + Checks map[string][]Check `json:"checks,omitempty"` + Links []url.URL `json:"links,omitempty"` + ServiceID string `json:"serviceID,omitempty"` + Description string `json:"description,omitempty"` +} + +// ReadResponse reads a JSON Response from an io.Reader. +func ReadResponse(r io.Reader) (*Response, error) { + var resp Response + + if err := json.NewDecoder(r). + Decode(&resp); err != nil { + return nil, err + } + + return &resp, nil +} + +// AddChecks adds Checks to a Response and sets the status of the Response to +// the ‘worst’ status. +func (resp *Response) AddChecks(name string, checks ...Check) { + for _, check := range checks { + resp.Status = WorstStatus(resp.Status, check.Status) + } + + if resp.Checks == nil { + resp.Checks = make(map[string][]Check) + } else if old, ok := resp.Checks[name]; ok { + checks = append(old, checks...) + } + + resp.Checks[name] = checks +} + +// Good returns true if the Response is good, i.e. its status is ‘pass’ or +// ‘warn’. +func (resp *Response) Good() bool { + return resp.Status == StatusPass || resp.Status == StatusWarn +} + +// Write writes a JSON Response to an io.Writer. +func (resp *Response) Write(w io.Writer) (int64, error) { + bajts, err := json.Marshal(resp) + if err != nil { + return 0, err + } + + return bytes.NewBuffer(bajts). + WriteTo(w) +} diff --git a/server.go b/server.go new file mode 100644 index 0000000..da723ec --- /dev/null +++ b/server.go @@ -0,0 +1,108 @@ +// Copyright © 2019 The Swedish Internet Foundation +// +// Distributed under the MIT License. (See accompanying LICENSE file or copy at +// .) + +package health + +import ( + "fmt" + "net" + "net/http" + "strconv" + "sync" + "time" +) + +// nolint: gochecknoglobals +var ( + initOnce sync.Once + s server +) + +// StartServer starts an HTTP server at 0.0.0.0:9999 serving health checks. Can +// be called multiple times but will only start one server. +// +// Doesn’t usually need to be called explicitly, as Register and RegisterFunc +// take care of that. +func StartServer() { + getServer().once.Do(func() { + go func() { + _ = getServer().httpServer.ListenAndServe() + }() + }) +} + +type server struct { + checkers map[string]Checker + httpServer http.Server + mtx sync.RWMutex + once sync.Once +} + +func (s *server) handle(w http.ResponseWriter, req *http.Request) { + if req.URL.Path != "/" { + http.Error(w, "", http.StatusNotFound) + return + } + + if req.Method != http.MethodGet && req.Method != http.MethodHead { + http.Error(w, "", http.StatusMethodNotAllowed) + return + } + + var resp Response + + s.mtx.RLock() + defer s.mtx.RUnlock() + + for name, checker := range s.checkers { + checks := checker.CheckHealth() + resp.AddChecks(name, checks...) + } + + if req.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/health+json") + + if _, err := resp.Write(w); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } +} + +func getServer() *server { + initOnce.Do(func() { + s.checkers = make(map[string]Checker) + + s.httpServer = http.Server{ + Addr: net.JoinHostPort("0.0.0.0", strconv.Itoa(port)), + Handler: http.HandlerFunc(s.handle), + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + MaxHeaderBytes: 1 << 20, + } + }) + + return &s +} + +func insertUnique(m map[string]Checker, name string, checker Checker) string { + var ( + inc uint64 + unique = name + ) + + for { + if _, ok := m[unique]; !ok { + break + } + + inc++ + + unique = fmt.Sprintf("%s-%d", name, inc) + } + + m[unique] = checker + + return unique +} diff --git a/server_test.go b/server_test.go new file mode 100644 index 0000000..d68a6e0 --- /dev/null +++ b/server_test.go @@ -0,0 +1,36 @@ +// Copyright © 2019 The Swedish Internet Foundation +// +// Distributed under the MIT License. (See accompanying LICENSE file or copy at +// .) + +package health + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestInsertUnique(t *testing.T) { + m := make(map[string]Checker) + + unique := insertUnique(m, "foo", nil) + assert.Equal(t, "foo", unique) + assert.Equal(t, 1, len(m)) + + unique = insertUnique(m, "foo", nil) + assert.Equal(t, "foo-1", unique) + assert.Equal(t, 2, len(m)) + + unique = insertUnique(m, "foo", nil) + assert.Equal(t, "foo-2", unique) + assert.Equal(t, 3, len(m)) + + unique = insertUnique(m, "bar", nil) + assert.Equal(t, "bar", unique) + assert.Equal(t, 4, len(m)) + + unique = insertUnique(m, "foo", nil) + assert.Equal(t, "foo-3", unique) + assert.Equal(t, 5, len(m)) +} diff --git a/status.go b/status.go new file mode 100644 index 0000000..648c20a --- /dev/null +++ b/status.go @@ -0,0 +1,77 @@ +// Copyright © 2019 The Swedish Internet Foundation +// +// Distributed under the MIT License. (See accompanying LICENSE file or copy at +// .) + +package health + +import ( + "encoding/json" +) + +const ( + // StatusPass is "pass". + StatusPass Status = iota + // StatusWarn is "warn". + StatusWarn Status = iota + // StatusFail is "fail". + StatusFail Status = iota +) + +// Status is the status part of a Response or Check. +type Status uint8 + +// WorstStatus returns the worst of a number of statuses, where "warn" is worse +// than "pass" but "fail" is worse than "warn". +func WorstStatus(status Status, statuses ...Status) (worst Status) { + worst = status + for _, other := range statuses { + if other > worst { + worst = other + } + } + + return +} + +// MarshalJSON encodes a status as a JSON string. +func (status Status) MarshalJSON() ([]byte, error) { + return json.Marshal(status.String()) +} + +// MarshalText encodes a status as a string. +func (status Status) MarshalText() ([]byte, error) { + return []byte(status.String()), nil +} + +// String turns a status into a string. +func (status Status) String() string { + return statusStringMap()[status] +} + +// UnmarshalJSON decodes a status from a JSON string. +func (status *Status) UnmarshalJSON(data []byte) error { + var tmp string + if err := json.Unmarshal(data, &tmp); err != nil { + return err + } + + for k, v := range statusStringMap() { + if tmp == v { + *status = k + return nil + } + } + + return &json.UnsupportedValueError{ + Str: tmp, + } +} + +func statusStringMap() map[Status]string { + return map[Status]string{ + StatusPass: "pass", + StatusFail: "fail", + StatusWarn: "warn", + } +} diff --git a/status_test.go b/status_test.go new file mode 100644 index 0000000..6482f09 --- /dev/null +++ b/status_test.go @@ -0,0 +1,26 @@ +// Copyright © 2019 The Swedish Internet Foundation +// +// Distributed under the MIT License. (See accompanying LICENSE file or copy at +// .) + +package health + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestWorstStatus(t *testing.T) { + assert.Equal(t, StatusPass, WorstStatus(StatusPass)) + assert.Equal(t, StatusWarn, WorstStatus(StatusWarn)) + assert.Equal(t, StatusFail, WorstStatus(StatusFail)) + + assert.Equal(t, StatusWarn, WorstStatus(StatusPass, StatusWarn)) + assert.Equal(t, StatusFail, WorstStatus(StatusWarn, StatusFail)) + assert.Equal(t, StatusFail, WorstStatus(StatusPass, StatusFail)) + + assert.Equal(t, StatusWarn, WorstStatus(StatusWarn, StatusWarn)) + assert.Equal(t, StatusFail, WorstStatus(StatusFail, StatusWarn)) + assert.Equal(t, StatusFail, WorstStatus(StatusPass, StatusFail, StatusPass)) +}