Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

core/web: /health - and support for HTML & Plaintext #11552

Merged
merged 2 commits into from
Dec 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 {
jmank88 marked this conversation as resolved.
Show resolved Hide resolved
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
Loading