diff --git a/core/web/health_controller.go b/core/web/health_controller.go index d6490e5542a..c8489fd6325 100644 --- a/core/web/health_controller.go +++ b/core/web/health_controller.go @@ -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" @@ -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 @@ -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, ``); 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, ` +
`); err != nil { + return err + } + var expand string + if node.Output == "" && len(node.Subs) == 0 { + expand = ` class="noexpand"` + } + if _, err := fmt.Fprintf(w, ` + %s`, node.Name, expand, node.Status, short); err != nil { + return err + } + if node.Output != "" { + if _, err := w.WriteRawLinef("
%s
", 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
"); 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...) +} diff --git a/core/web/health_controller_test.go b/core/web/health_controller_test.go index 7e7c42141ca..ae40a66bca9 100644 --- a/core/web/health_controller_test.go +++ b/core/web/health_controller_test.go @@ -8,6 +8,7 @@ import ( "net/http" "testing" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -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)) + }) + } } diff --git a/core/web/health_template_test.go b/core/web/health_template_test.go new file mode 100644 index 00000000000..fa9750fed22 --- /dev/null +++ b/core/web/health_template_test.go @@ -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) +} diff --git a/core/web/presenters/check.go b/core/web/presenters/check.go index 52e4aa68005..4e1a2147a8c 100644 --- a/core/web/presenters/check.go +++ b/core/web/presenters/check.go @@ -1,5 +1,7 @@ package presenters +import "cmp" + type Check struct { JAID Name string `json:"name"` @@ -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) +} diff --git a/core/web/router.go b/core/web/router.go index 28bd4f2170c..5c5e86a12b6 100644 --- a/core/web/router.go +++ b/core/web/router.go @@ -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) { diff --git a/core/web/testdata/body/health.html b/core/web/testdata/body/health.html new file mode 100644 index 00000000000..5999891a0f6 --- /dev/null +++ b/core/web/testdata/body/health.html @@ -0,0 +1,95 @@ + +
+ EVM +
+ 0 +
+ BalanceMonitor +
+
+ HeadBroadcaster +
+
+ HeadTracker +
+ HeadListener +
Listener is not connected
+
+
+
+ LogBroadcaster +
+
+ Txm +
+ BlockHistoryEstimator +
+
+ Broadcaster +
+
+ Confirmer +
+
+ WrappedEvmEstimator +
+
+
+
+
+ JobSpawner +
+
+ Mercury +
+ WSRPCPool +
+
+
+ Monitor +
+
+ PipelineORM +
+
+ PipelineRunner +
+
+ PromReporter +
+
+ TelemetryManager +
\ No newline at end of file diff --git a/core/web/testdata/body/health.txt b/core/web/testdata/body/health.txt new file mode 100644 index 00000000000..5b636829587 --- /dev/null +++ b/core/web/testdata/body/health.txt @@ -0,0 +1,19 @@ +-EVM.0 +-EVM.0.BalanceMonitor +-EVM.0.HeadBroadcaster +-EVM.0.HeadTracker +!EVM.0.HeadTracker.HeadListener + Listener is not connected +-EVM.0.LogBroadcaster +-EVM.0.Txm +-EVM.0.Txm.BlockHistoryEstimator +-EVM.0.Txm.Broadcaster +-EVM.0.Txm.Confirmer +-EVM.0.Txm.WrappedEvmEstimator +-JobSpawner +-Mercury.WSRPCPool +-Monitor +-PipelineORM +-PipelineRunner +-PromReporter +-TelemetryManager diff --git a/core/web/testdata/health.html b/core/web/testdata/health.html new file mode 100644 index 00000000000..3c007bef96f --- /dev/null +++ b/core/web/testdata/health.html @@ -0,0 +1,67 @@ + +
+ foo +
+ bar +
example error message
+
+ 1 +
+ A +
+
+ B +
+
+
+ 2 +
error:
+this is a multi-line error:
+new line:
+original error
+
+ A +
failure!
+
+
+ B +
+
+
+
+ baz +
+
\ No newline at end of file diff --git a/core/web/testdata/health.txt b/core/web/testdata/health.txt new file mode 100644 index 00000000000..f155d6c0212 --- /dev/null +++ b/core/web/testdata/health.txt @@ -0,0 +1,15 @@ +-foo +!foo.bar + example error message +-foo.bar.1 +-foo.bar.1.A +-foo.bar.1.B +!foo.bar.2 + error: + this is a multi-line error: + new line: + original error +!foo.bar.2.A + failure! +-foo.bar.2.B +-foo.baz