Skip to content

Commit

Permalink
core/web: /health - and support for HTML & Plaintext (#11552)
Browse files Browse the repository at this point in the history
  • Loading branch information
jmank88 authored Dec 15, 2023
1 parent 0b99f3a commit b12329e
Show file tree
Hide file tree
Showing 9 changed files with 480 additions and 24 deletions.
194 changes: 186 additions & 8 deletions core/web/health_controller.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package web

import (
"cmp"
"bytes"
"fmt"
"io"
"net/http"
"slices"
"testing"
"strings"

"github.com/gin-gonic/gin"
"golang.org/x/exp/maps"

"github.com/smartcontractkit/chainlink/v2/core/services/chainlink"
"github.com/smartcontractkit/chainlink/v2/core/web/presenters"
Expand Down Expand Up @@ -79,7 +82,6 @@ func (hc *HealthController) Health(c *gin.Context) {
c.Status(status)

checks := make([]presenters.Check, 0, len(errors))

for name, err := range errors {
status := HealthStatusPassing
var output string
Expand All @@ -97,12 +99,188 @@ func (hc *HealthController) Health(c *gin.Context) {
})
}

if testing.Testing() {
slices.SortFunc(checks, func(a, b presenters.Check) int {
return cmp.Compare(a.Name, b.Name)
})
switch c.NegotiateFormat(gin.MIMEJSON, gin.MIMEHTML, gin.MIMEPlain) {
case gin.MIMEJSON:
break // default

case gin.MIMEHTML:
if err := newCheckTree(checks).WriteHTMLTo(c.Writer); err != nil {
hc.App.GetLogger().Errorw("Failed to write HTML health report", "err", err)
c.AbortWithStatus(http.StatusInternalServerError)
}
return

case gin.MIMEPlain:
if err := writeTextTo(c.Writer, checks); err != nil {
hc.App.GetLogger().Errorw("Failed to write plaintext health report", "err", err)
c.AbortWithStatus(http.StatusInternalServerError)
}
return
}

// return a json description of all the checks
slices.SortFunc(checks, presenters.CmpCheckName)
jsonAPIResponseWithStatus(c, checks, "checks", status)
}

func writeTextTo(w io.Writer, checks []presenters.Check) error {
slices.SortFunc(checks, presenters.CmpCheckName)
for _, ch := range checks {
status := "?"
switch ch.Status {
case HealthStatusPassing:
status = "-"
case HealthStatusFailing:
status = "!"
}
if _, err := fmt.Fprintf(w, "%s%s\n", status, ch.Name); err != nil {
return err
}
if ch.Output != "" {
if _, err := fmt.Fprintf(newLinePrefixWriter(w, "\t"), "\t%s", ch.Output); err != nil {
return err
}
if _, err := fmt.Fprintln(w); err != nil {
return err
}
}
}
return nil
}

type checkNode struct {
Name string // full
Status string
Output string

Subs checkTree
}

type checkTree map[string]checkNode

func newCheckTree(checks []presenters.Check) checkTree {
slices.SortFunc(checks, presenters.CmpCheckName)
root := make(checkTree)
for _, c := range checks {
parts := strings.Split(c.Name, ".")
node := root
for _, short := range parts[:len(parts)-1] {
n, ok := node[short]
if !ok {
n = checkNode{Subs: make(checkTree)}
node[short] = n
}
node = n.Subs
}
p := parts[len(parts)-1]
node[p] = checkNode{
Name: c.Name,
Status: c.Status,
Output: c.Output,
Subs: make(checkTree),
}
}
return root
}

func (t checkTree) WriteHTMLTo(w io.Writer) error {
if _, err := io.WriteString(w, `<style>
details {
margin: 0.0em 0.0em 0.0em 0.4em;
padding: 0.3em 0.0em 0.0em 0.4em;
}
pre {
margin-left:1em;
margin-top: 0;
}
summary {
padding-bottom: 0.4em;
}
details {
border: thin solid black;
border-bottom-color: rgba(0,0,0,0);
border-right-color: rgba(0,0,0,0);
}
.passing:after {
color: blue;
content: " - (Passing)";
font-size:small;
text-transform: uppercase;
}
.failing:after {
color: red;
content: " - (Failing)";
font-weight: bold;
font-size:small;
text-transform: uppercase;
}
summary.noexpand::marker {
color: rgba(100,101,10,0);
}
</style>`); err != nil {
return err
}
return t.writeHTMLTo(newLinePrefixWriter(w, ""))
}

func (t checkTree) writeHTMLTo(w *linePrefixWriter) error {
keys := maps.Keys(t)
slices.Sort(keys)
for _, short := range keys {
node := t[short]
if _, err := io.WriteString(w, `
<details open>`); err != nil {
return err
}
var expand string
if node.Output == "" && len(node.Subs) == 0 {
expand = ` class="noexpand"`
}
if _, err := fmt.Fprintf(w, `
<summary title="%s"%s><span class="%s">%s</span></summary>`, node.Name, expand, node.Status, short); err != nil {
return err
}
if node.Output != "" {
if _, err := w.WriteRawLinef(" <pre>%s</pre>", node.Output); err != nil {
return err
}
}
if len(node.Subs) > 0 {
if err := node.Subs.writeHTMLTo(w.new(" ")); err != nil {
return err
}
}
if _, err := io.WriteString(w, "\n</details>"); err != nil {
return err
}
}
return nil
}

type linePrefixWriter struct {
w io.Writer
prefix string
prefixB []byte
}

func newLinePrefixWriter(w io.Writer, prefix string) *linePrefixWriter {
prefix = "\n" + prefix
return &linePrefixWriter{w: w, prefix: prefix, prefixB: []byte(prefix)}
}

func (w *linePrefixWriter) new(prefix string) *linePrefixWriter {
prefix = w.prefix + prefix
return &linePrefixWriter{w: w.w, prefix: prefix, prefixB: []byte(prefix)}
}

func (w *linePrefixWriter) Write(b []byte) (int, error) {
return w.w.Write(bytes.ReplaceAll(b, []byte("\n"), w.prefixB))
}

func (w *linePrefixWriter) WriteString(s string) (n int, err error) {
return io.WriteString(w.w, strings.ReplaceAll(s, "\n", w.prefix))
}

// WriteRawLinef writes a new newline with prefix, followed by s without modification.
func (w *linePrefixWriter) WriteRawLinef(s string, args ...any) (n int, err error) {
return fmt.Fprintf(w.w, w.prefix+s, args...)
}
52 changes: 36 additions & 16 deletions core/web/health_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/http"
"testing"

"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

Expand Down Expand Up @@ -90,24 +91,43 @@ func TestHealthController_Health_status(t *testing.T) {

var (
//go:embed testdata/body/health.json
healthJSON string
bodyJSON string
//go:embed testdata/body/health.html
bodyHTML string
//go:embed testdata/body/health.txt
bodyTXT string
)

func TestHealthController_Health_body(t *testing.T) {
app := cltest.NewApplicationWithKey(t)
require.NoError(t, app.Start(testutils.Context(t)))

client := app.NewHTTPClient(nil)
resp, cleanup := client.Get("/health")
t.Cleanup(cleanup)
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)

// pretty print for comparison
var b bytes.Buffer
require.NoError(t, json.Indent(&b, body, "", " "))
body = b.Bytes()
for _, tc := range []struct {
name string
path string
headers map[string]string
expBody string
}{
{"default", "/health", nil, bodyJSON},
{"json", "/health", map[string]string{"Accept": gin.MIMEJSON}, bodyJSON},
{"html", "/health", map[string]string{"Accept": gin.MIMEHTML}, bodyHTML},
{"text", "/health", map[string]string{"Accept": gin.MIMEPlain}, bodyTXT},
{".txt", "/health.txt", nil, bodyTXT},
} {
t.Run(tc.name, func(t *testing.T) {
app := cltest.NewApplicationWithKey(t)
require.NoError(t, app.Start(testutils.Context(t)))

assert.Equal(t, healthJSON, string(body))
client := app.NewHTTPClient(nil)
resp, cleanup := client.Get(tc.path, tc.headers)
t.Cleanup(cleanup)
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
if tc.expBody == bodyJSON {
// pretty print for comparison
var b bytes.Buffer
require.NoError(t, json.Indent(&b, body, "", " "))
body = b.Bytes()
}
assert.Equal(t, tc.expBody, string(body))
})
}
}
53 changes: 53 additions & 0 deletions core/web/health_template_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package web

import (
"bytes"
_ "embed"
"testing"

"github.com/stretchr/testify/require"

"github.com/smartcontractkit/chainlink/v2/core/web/presenters"
)

var (
//go:embed testdata/health.html
healthHTML string

//go:embed testdata/health.txt
healthTXT string
)

func checks() []presenters.Check {
const passing, failing = HealthStatusPassing, HealthStatusFailing
return []presenters.Check{
{Name: "foo", Status: passing},
{Name: "foo.bar", Status: failing, Output: "example error message"},
{Name: "foo.bar.1", Status: passing},
{Name: "foo.bar.1.A", Status: passing},
{Name: "foo.bar.1.B", Status: passing},
{Name: "foo.bar.2", Status: failing, Output: `error:
this is a multi-line error:
new line:
original error`},
{Name: "foo.bar.2.A", Status: failing, Output: "failure!"},
{Name: "foo.bar.2.B", Status: passing},
{Name: "foo.baz", Status: passing},
}
//TODO truncated error
}

func Test_checkTree_WriteHTMLTo(t *testing.T) {
ct := newCheckTree(checks())
var b bytes.Buffer
require.NoError(t, ct.WriteHTMLTo(&b))
got := b.String()
require.Equalf(t, healthHTML, got, "got: %s", got)
}

func Test_writeTextTo(t *testing.T) {
var b bytes.Buffer
require.NoError(t, writeTextTo(&b, checks()))
got := b.String()
require.Equalf(t, healthTXT, got, "got: %s", got)
}
6 changes: 6 additions & 0 deletions core/web/presenters/check.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package presenters

import "cmp"

type Check struct {
JAID
Name string `json:"name"`
Expand All @@ -10,3 +12,7 @@ type Check struct {
func (c Check) GetName() string {
return "checks"
}

func CmpCheckName(a, b Check) int {
return cmp.Compare(a.Name, b.Name)
}
3 changes: 3 additions & 0 deletions core/web/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,9 @@ func healthRoutes(app chainlink.Application, r *gin.RouterGroup) {
hc := HealthController{app}
r.GET("/readyz", hc.Readyz)
r.GET("/health", hc.Health)
r.GET("/health.txt", func(context *gin.Context) {
context.Request.Header.Set("Accept", gin.MIMEPlain)
}, hc.Health)
}

func loopRoutes(app chainlink.Application, r *gin.RouterGroup) {
Expand Down
Loading

0 comments on commit b12329e

Please sign in to comment.