diff --git a/xpod/example_test.go b/xpod/example_test.go index d2fb33d..ff7023b 100644 --- a/xpod/example_test.go +++ b/xpod/example_test.go @@ -7,9 +7,15 @@ import ( "github.com/gojekfarm/xtools/xpod" ) -func ExampleProbeHandler() { +func ExampleNewProbeHandler() { h := http.NewServeMux() + + // This method automatically registers the health, ready, and version endpoints + // with the provided prefix. + // If you want to register the endpoints with a custom path, you can use the + // `HealthHandler`, `ReadyHandler`, and `VersionHandler` methods. See the examples below. h.Handle("/probe", xpod.NewProbeHandler(xpod.Options{ + Prefix: "/probe", BuildInfo: &xpod.BuildInfo{ Version: "0.1.0", Tag: "v0.1.0", @@ -18,3 +24,21 @@ func ExampleProbeHandler() { }, })) } + +func ExampleNewProbeHandler_withoutManagedServeMux() { + h := http.NewServeMux() + + ph := xpod.NewProbeHandler(xpod.Options{ + Prefix: "/probe", + BuildInfo: &xpod.BuildInfo{ + Version: "0.1.0", + Tag: "v0.1.0", + Commit: "24b3f5d876ffa402287bfa5c26cf05626a2b3b01", + BuildDate: xpod.BuildDate(time.Now()), + }, + }) + + h.Handle("/health", ph.HealthHandler()) + h.Handle("/ready", ph.ReadyHandler()) + h.Handle("/version", ph.VersionHandler()) +} diff --git a/xpod/handler.go b/xpod/handler.go new file mode 100644 index 0000000..bf779c1 --- /dev/null +++ b/xpod/handler.go @@ -0,0 +1,234 @@ +package xpod + +import ( + "bytes" + "fmt" + "net/http" + "strings" + + "github.com/gojekfarm/xtools/generic" + "github.com/gojekfarm/xtools/generic/slice" +) + +const ( + verboseQueryParam = "verbose" + excludeQueryParam = "exclude" +) + +// Options can be used to provide custom health/readiness checkers and the current BuildInfo. +type Options struct { + HealthCheckers []Checker + ReadyCheckers []Checker + BuildInfo *BuildInfo + + // Prefix is the base path for the health, ready, and version endpoints. + // If not provided, the default value is "/". + // If the prefix is "/probe", the health, ready, and version endpoints + // will be available at: + // - /probe/healthz + // - /probe/readyz + // - /probe/version + // + // HealthPath is the path for the health endpoint. + // If not provided, the default value is "healthz". + // + // ReadyPath is the path for the readiness endpoint. + // If not provided, the default value is "readyz". + // + // VersionPath is the path for the version endpoint. + // If not provided, the default value is "version". + // + // Note: Prefix, HealthPath, ReadyPath, and VersionPath are only used + // for internal http.ServeMux registration. + Prefix, HealthPath, ReadyPath, VersionPath string + + ErrorLogDelegate func(string, map[string]any) + + // ShowErrReasons is used to show the error reasons in the HTTP response. + ShowErrReasons bool +} + +// NewProbeHandler returns a http.Handler which can be used to serve health check and build info endpoints. +func NewProbeHandler(opts Options) *ProbeHandler { + ph := &ProbeHandler{ + sm: http.NewServeMux(), + logDelegate: opts.ErrorLogDelegate, + showErrReasons: opts.ShowErrReasons, + } + + ph.makeHandlers(opts) + ph.registerRoutes(opts) + + return ph +} + +// ProbeHandler implements http.Handler interface to expose [/healthz /readyz /version] endpoints. +type ProbeHandler struct { + sm *http.ServeMux + hh http.Handler + rh http.Handler + vh http.Handler + showErrReasons bool + logDelegate func(string, map[string]interface{}) +} + +func (h *ProbeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.sm.ServeHTTP(w, r) } + +// HealthHandler returns the handler for the health endpoint. +func (h *ProbeHandler) HealthHandler() http.Handler { return h.hh } + +// ReadyHandler returns the handler for the readiness endpoint. +func (h *ProbeHandler) ReadyHandler() http.Handler { return h.rh } + +// VersionHandler returns the handler for the version endpoint. +// Note: It will be nil if the Options.BuildInfo is not provided. +func (h *ProbeHandler) VersionHandler() http.Handler { return h.vh } + +func (h *ProbeHandler) registerRoutes(opts Options) { + prefix := strings.TrimSuffix(opts.Prefix, "/") + + h.sm.HandleFunc( + fmt.Sprintf("%s/%s", prefix, pathOrDefault(opts.HealthPath, "healthz")), + h.hh.ServeHTTP, + ) + + h.sm.HandleFunc( + fmt.Sprintf("%s/%s", prefix, pathOrDefault(opts.ReadyPath, "readyz")), + h.rh.ServeHTTP, + ) + + if h.vh != nil { + h.sm.HandleFunc( + fmt.Sprintf("%s/%s", prefix, pathOrDefault(opts.HealthPath, "version")), + h.vh.ServeHTTP, + ) + } +} + +func pathOrDefault(path, def string) string { + if strings.TrimSpace(path) != "" { + return strings.TrimPrefix(path, "/") + } + + return def +} + +func (h *ProbeHandler) healthHandler(opts Options) http.Handler { + hcs := opts.HealthCheckers + if len(hcs) == 0 { + hcs = append(hcs, PingHealthz) + } + + return h.serveCheckers(hcs) +} + +func (h *ProbeHandler) readyHandler(opts Options) http.Handler { + rcs := opts.ReadyCheckers + if len(rcs) == 0 { + rcs = append(rcs, PingHealthz) + } + + return h.serveCheckers(rcs) +} + +func (h *ProbeHandler) serveCheckers(cs []Checker) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var excluded generic.Set[string] + if reqExcludes, ok := r.URL.Query()[excludeQueryParam]; ok && len(reqExcludes) > 0 { + excluded = generic.NewSet(flattenElems(slice.Map( + r.URL.Query()[excludeQueryParam], + func(s string) []string { return strings.Split(s, ",") }, + ))...) + } + + var output bytes.Buffer + var failedVerboseLogOutput bytes.Buffer + var failedChecks []string + + for _, c := range cs { + if excluded.Has(c.Name()) { + excluded.Delete(c.Name()) + _, _ = fmt.Fprintf(&output, "[+]%s excluded: ok\n", c.Name()) + + continue + } + + if err := c.Check(r); err != nil { + _, _ = fmt.Fprintf(&output, "[-]%s failed:", c.Name()) + + if h.showErrReasons { + _, _ = fmt.Fprintf(&output, "\n\treason: %v\n", err) + } else { + _, _ = fmt.Fprintf(&output, " reason hidden\n") + } + + failedChecks = append(failedChecks, c.Name()) + _, _ = fmt.Fprintf(&failedVerboseLogOutput, "[-]%s failed: %v\n", c.Name(), err) + + continue + } + + _, _ = fmt.Fprintf(&output, "[+]%s ok\n", c.Name()) + } + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + + if excluded.Len() > 0 { + quotedChecks := strings.Join( + slice.Map(excluded.Values(), + func(in string) string { + return fmt.Sprintf("%q", in) + }), ", ") + + _, _ = fmt.Fprintf(&output, "warn: some checks cannot be excluded: no matches for %s\n", quotedChecks) + if h.logDelegate != nil { + h.logDelegate("cannot exclude some checks", map[string]interface{}{ + "checks": quotedChecks, + "reason": "no matches", + }) + } + } + + if len(failedChecks) > 0 { + if h.logDelegate != nil { + h.logDelegate("check failed", map[string]interface{}{ + "failed_checks": strings.Join(failedChecks, ","), + }) + } + + w.WriteHeader(http.StatusInternalServerError) + _, _ = output.WriteTo(w) + + return + } + + if _, found := r.URL.Query()[verboseQueryParam]; !found { + _, _ = fmt.Fprint(w, "ok") + + return + } + + _, _ = output.WriteTo(w) + _, _ = fmt.Fprintf(w, "%s check passed\n", strings.TrimPrefix(r.URL.Path, "/")) + }) +} + +func (h *ProbeHandler) makeHandlers(opts Options) { + h.hh = h.healthHandler(opts) + h.rh = h.readyHandler(opts) + + if opts.BuildInfo != nil { + h.vh = h.versionHandler(opts) + } +} + +func flattenElems(in [][]string) []string { + var out []string + + for _, v := range in { + out = append(out, v...) + } + + return out +} diff --git a/xpod/handler_test.go b/xpod/handler_test.go new file mode 100644 index 0000000..71f219f --- /dev/null +++ b/xpod/handler_test.go @@ -0,0 +1,308 @@ +package xpod + +import ( + "errors" + "io" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + "github.com/gojekfarm/xtools/generic/slice" +) + +func TestProbeHandler_serveCheckers(t *testing.T) { + type args struct { + name string + prefix string + excluded []string + verbose bool + want func(*testing.T, string) + logDelegate *mockLogDelegate + } + + runSpecs := func(t *testing.T, handler http.Handler, a args) { + t.Run(a.name, func(t *testing.T) { + path := a.prefix + "/healthz?" + + if len(a.excluded) > 0 { + for _, e := range a.excluded { + path += "exclude=" + e + "&" + } + } + + if a.verbose { + path += "verbose" + } + + req := httptest.NewRequest(http.MethodGet, path, nil) + rw := httptest.NewRecorder() + + handler.ServeHTTP(rw, req) + + rc := rw.Result().Body + b, err := io.ReadAll(rc) + + assert.NoError(t, err) + assert.NoError(t, rc.Close()) + + a.want(t, string(b)) + a.logDelegate.AssertExpectations(t) + }) + } + + tests := []struct { + name string + opts Options + verbose bool + excluded []string + logDelegate func(*testing.T, *mock.Mock) + want func(*testing.T, string) + }{ + { + name: "NoHealthCheckNonVerbose", + want: func(t *testing.T, got string) { + assert.Equal(t, "ok", got) + }, + }, + { + name: "NoHealthCheckVerbose", + verbose: true, + want: func(t *testing.T, got string) { + assert.Equal(t, `[+]ping ok +healthz check passed +`, got) + }, + }, + { + name: "FailingHealthCheckWithHiddenReason", + opts: Options{ + HealthCheckers: []Checker{ + CheckerFunc("redis", func(_ *http.Request) error { + return errors.New("redis-connect-error") + }), + }, + ReadyCheckers: []Checker{ + CheckerFunc("redis", func(_ *http.Request) error { + return errors.New("redis-connect-error") + }), + }, + }, + logDelegate: func(t *testing.T, m *mock.Mock) { + m.On("Logf", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + argsMap := args.Get(1).(map[string]interface{}) + + assert.Equal(t, "check failed", args.String(0)) + assert.Equal(t, "redis", argsMap["failed_checks"]) + }) + }, + want: func(t *testing.T, got string) { + assert.Equal(t, `[-]redis failed: reason hidden +`, got) + }, + }, + { + name: "FailingHealthCheckWithReason", + opts: Options{ + HealthCheckers: []Checker{ + CheckerFunc("redis", func(_ *http.Request) error { + return errors.New("redis-connect-error") + }), + }, + ReadyCheckers: []Checker{ + CheckerFunc("redis", func(_ *http.Request) error { + return errors.New("redis-connect-error") + }), + }, + ShowErrReasons: true, + }, + want: func(t *testing.T, got string) { + assert.Equal(t, `[-]redis failed: + reason: redis-connect-error +`, got) + }, + }, + { + name: "FailingHealthCheckExcluded", + opts: Options{ + HealthCheckers: []Checker{ + CheckerFunc("redis", func(_ *http.Request) error { + return errors.New("redis-connect-error") + }), + PingHealthz, + }, + ReadyCheckers: []Checker{ + CheckerFunc("redis", func(_ *http.Request) error { + return errors.New("redis-connect-error") + }), + PingHealthz, + }, + }, + excluded: []string{"redis"}, + want: func(t *testing.T, got string) { assert.Equal(t, "ok", got) }, + }, + { + name: "FailingHealthCheckWithExtraExcludes", + verbose: true, + excluded: []string{"redis", "foo,bar", "baz"}, + opts: Options{ + HealthCheckers: []Checker{ + PingHealthz, + CheckerFunc("redis", func(_ *http.Request) error { + return errors.New("redis-connect-error") + }), + }, + ReadyCheckers: []Checker{ + PingHealthz, + CheckerFunc("redis", func(_ *http.Request) error { + return errors.New("redis-connect-error") + }), + }, + }, + logDelegate: func(t *testing.T, m *mock.Mock) { + m.On("Logf", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + argsMap := args.Get(1).(map[string]interface{}) + + assert.Equal(t, "cannot exclude some checks", args.String(0)) + assert.ElementsMatch(t, []string{"foo", "bar", "baz"}, slice.Map( + strings.Split(argsMap["checks"].(string), ", "), func(s string) string { + return strings.Trim(s, `"`) + }, + )) + assert.Equal(t, "no matches", argsMap["reason"]) + }) + }, + want: func(t *testing.T, got string) { + assert.True(t, strings.Contains(got, `[+]ping ok`)) + assert.True(t, strings.Contains(got, `[+]redis excluded: ok`)) + + re := regexp.MustCompile(`warn: some checks cannot be excluded: no matches for (.*)`) + assert.True(t, re.MatchString(got)) + + excludedLine := re.FindStringSubmatch(got)[1] + assert.ElementsMatch(t, []string{"foo", "bar", "baz"}, slice.Map( + strings.Split(excludedLine, ", "), func(s string) string { + return strings.Trim(s, `"`) + }, + )) + }, + }, + } + + t.Run("NewProbeHandler", func(t *testing.T) { + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ld := newMockLogDelegate(t) + if tt.logDelegate != nil { + tt.logDelegate(t, &ld.Mock) + tt.opts.ErrorLogDelegate = ld.Logf + } + + handler := NewProbeHandler(tt.opts) + + runSpecs(t, handler, args{ + name: tt.name, + prefix: tt.opts.Prefix, + excluded: tt.excluded, + verbose: tt.verbose, + want: tt.want, + logDelegate: ld, + }) + }) + } + }) + + t.Run("HealthHandler", func(t *testing.T) { + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ld := newMockLogDelegate(t) + if tt.logDelegate != nil { + tt.logDelegate(t, &ld.Mock) + tt.opts.ErrorLogDelegate = ld.Logf + } + + handler := NewProbeHandler(tt.opts).HealthHandler() + runSpecs(t, handler, args{ + name: tt.name, + prefix: tt.opts.Prefix, + excluded: tt.excluded, + verbose: tt.verbose, + want: tt.want, + logDelegate: ld, + }) + }) + } + }) + + t.Run("ReadyHandler", func(t *testing.T) { + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ld := newMockLogDelegate(t) + if tt.logDelegate != nil { + tt.logDelegate(t, &ld.Mock) + tt.opts.ErrorLogDelegate = ld.Logf + } + + handler := NewProbeHandler(tt.opts).ReadyHandler() + runSpecs(t, handler, args{ + name: tt.name, + prefix: tt.opts.Prefix, + excluded: tt.excluded, + verbose: tt.verbose, + want: tt.want, + logDelegate: ld, + }) + }) + } + }) +} + +func newMockLogDelegate(t *testing.T) *mockLogDelegate { + m := &mockLogDelegate{} + m.Test(t) + return m +} + +type mockLogDelegate struct{ mock.Mock } + +func (m *mockLogDelegate) Logf(format string, args map[string]interface{}) { m.Called(format, args) } + +func Test_pathOrDefault(t *testing.T) { + tests := []struct { + name string + path string + def string + want string + }{ + { + name: "EmptyPath", + def: "healthz", + want: "healthz", + }, + { + name: "EmptyDefault", + path: "healthz", + want: "healthz", + }, + { + name: "NonEmptyPath", + path: "readyz", + def: "healthz", + want: "readyz", + }, + { + name: "BothEmpty", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, pathOrDefault(tt.path, tt.def), "pathOrDefault(%v, %v)", tt.path, tt.def) + }) + } +} diff --git a/xpod/health.go b/xpod/health.go index 72a46a5..a79a99a 100644 --- a/xpod/health.go +++ b/xpod/health.go @@ -4,31 +4,31 @@ import ( "net/http" ) -// HealthChecker is a named healthz checker. -type HealthChecker interface { +// Checker is a named resource/component checker. +type Checker interface { Name() string Check(r *http.Request) error } -// HealthCheckerFunc implements HealthChecker interface. -func HealthCheckerFunc(name string, check func(*http.Request) error) *HealthCheckerFun { - return &HealthCheckerFun{name: name, checker: check} +// CheckerFunc implements Checker interface. +func CheckerFunc(name string, check func(*http.Request) error) *CheckerFun { + return &CheckerFun{name: name, checker: check} } -// HealthCheckerFun implements HealthChecker interface. -type HealthCheckerFun struct { +// CheckerFun implements Checker interface. +type CheckerFun struct { name string checker func(r *http.Request) error } // Name returns the name of the health check. -func (f *HealthCheckerFun) Name() string { return f.name } +func (f *CheckerFun) Name() string { return f.name } // Check is used to invoke health check when a request is received. -func (f *HealthCheckerFun) Check(req *http.Request) error { return f.checker(req) } +func (f *CheckerFun) Check(req *http.Request) error { return f.checker(req) } // PingHealthz returns true automatically when checked. -var PingHealthz HealthChecker = ping{} +var PingHealthz Checker = ping{} // ping implements the simplest possible healthz checker. type ping struct{} diff --git a/xpod/probe.go b/xpod/probe.go deleted file mode 100644 index cb1fa0f..0000000 --- a/xpod/probe.go +++ /dev/null @@ -1,162 +0,0 @@ -package xpod - -import ( - "bytes" - "fmt" - "net/http" - "runtime" - "strings" - - "github.com/gojekfarm/xtools/generic" - "github.com/gojekfarm/xtools/generic/slice" -) - -const ( - verboseQueryParam = "verbose" - excludeQueryParam = "exclude" -) - -// Options can be used to provide custom health/readiness checkers and the current BuildInfo. -type Options struct { - Prefix string - HealthCheckers []HealthChecker - ReadyCheckers []HealthChecker - BuildInfo *BuildInfo - ErrorLogDelegate func(string, map[string]interface{}) - ShowErrReasons bool -} - -// NewProbeHandler returns a http.Handler which can be used to serve health check and build info endpoints. -func NewProbeHandler(opts Options) *ProbeHandler { - ph := &ProbeHandler{sm: http.NewServeMux(), bi: &buildInfo{ - BuildInfo: opts.BuildInfo, - GoVersion: runtime.Version(), - OS: runtime.GOOS, - Arch: runtime.GOARCH, - }, logDelegate: opts.ErrorLogDelegate, showErrReasons: opts.ShowErrReasons} - - ph.registerRoutes(strings.TrimSuffix(opts.Prefix, "/"), opts.HealthCheckers, opts.ReadyCheckers) - - return ph -} - -// ProbeHandler implements http.Handler interface to expose [/healthz /readyz /version] endpoints. -type ProbeHandler struct { - sm *http.ServeMux - bi *buildInfo - showErrReasons bool - logDelegate func(string, map[string]interface{}) -} - -func (h *ProbeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.sm.ServeHTTP(w, r) } - -func (h *ProbeHandler) registerRoutes(prefix string, hcs []HealthChecker, rcs []HealthChecker) { - if len(hcs) == 0 { - hcs = append(hcs, PingHealthz) - } - - h.sm.HandleFunc(prefix+"/healthz", h.serveHealth(hcs).ServeHTTP) - - if len(rcs) == 0 { - rcs = append(rcs, PingHealthz) - } - - h.sm.HandleFunc(prefix+"/readyz", h.serveHealth(rcs).ServeHTTP) - - if h.bi.BuildInfo != nil { - h.sm.HandleFunc(prefix+"/version", h.serveBuildInfo) - } -} - -func (h *ProbeHandler) serveHealth(hcs []HealthChecker) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var excluded generic.Set[string] - if reqExcludes, ok := r.URL.Query()[excludeQueryParam]; ok && len(reqExcludes) > 0 { - excluded = generic.NewSet(flattenElems(slice.Map( - r.URL.Query()[excludeQueryParam], - func(s string) []string { return strings.Split(s, ",") }, - ))...) - } - - var output bytes.Buffer - var failedVerboseLogOutput bytes.Buffer - var failedChecks []string - - for _, hc := range hcs { - if excluded.Has(hc.Name()) { - excluded.Delete(hc.Name()) - _, _ = fmt.Fprintf(&output, "[+]%s excluded: ok\n", hc.Name()) - - continue - } - - if err := hc.Check(r); err != nil { - _, _ = fmt.Fprintf(&output, "[-]%s failed:", hc.Name()) - - if h.showErrReasons { - _, _ = fmt.Fprintf(&output, "\n\treason: %v\n", err) - } else { - _, _ = fmt.Fprintf(&output, " reason hidden\n") - } - - failedChecks = append(failedChecks, hc.Name()) - _, _ = fmt.Fprintf(&failedVerboseLogOutput, "[-]%s failed: %v\n", hc.Name(), err) - - continue - } - - _, _ = fmt.Fprintf(&output, "[+]%s ok\n", hc.Name()) - } - - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - w.Header().Set("X-Content-Type-Options", "nosniff") - - if excluded.Len() > 0 { - quotedChecks := strings.Join( - slice.Map(excluded.Values(), - func(in string) string { - return fmt.Sprintf("%q", in) - }), ", ") - - _, _ = fmt.Fprintf(&output, "warn: some health checks cannot be excluded: no matches for %s\n", quotedChecks) - if h.logDelegate != nil { - h.logDelegate("cannot exclude some health checks", map[string]interface{}{ - "checks": quotedChecks, - "reason": "no matches", - }) - } - } - - if len(failedChecks) > 0 { - if h.logDelegate != nil { - h.logDelegate("health check failed", map[string]interface{}{ - "failed_checks": strings.Join(failedChecks, ","), - }) - } - - w.WriteHeader(http.StatusInternalServerError) - _, _ = output.WriteTo(w) - - return - } - - if _, found := r.URL.Query()[verboseQueryParam]; !found { - _, _ = fmt.Fprint(w, "ok") - - return - } - - _, _ = output.WriteTo(w) - _, _ = fmt.Fprintf(w, "%s check passed\n", strings.TrimPrefix(r.URL.Path, "/")) - }) -} - -func flattenElems(in [][]string) []string { - var out []string - - for _, v := range in { - out = append(out, v...) - } - - return out -} diff --git a/xpod/probe_test.go b/xpod/probe_test.go deleted file mode 100644 index 81477a3..0000000 --- a/xpod/probe_test.go +++ /dev/null @@ -1,182 +0,0 @@ -package xpod - -import ( - "errors" - "io" - "net/http" - "net/http/httptest" - "regexp" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - - "github.com/gojekfarm/xtools/generic/slice" -) - -func TestProbeHandler_serveHealth(t *testing.T) { - tests := []struct { - name string - opts Options - verbose bool - excluded []string - logDelegate func(*testing.T, *mock.Mock) - want func(*testing.T, string) - }{ - { - name: "NoHealthCheckNonVerbose", - want: func(t *testing.T, got string) { - assert.Equal(t, "ok", got) - }, - }, - { - name: "NoHealthCheckVerbose", - verbose: true, - want: func(t *testing.T, got string) { - assert.Equal(t, `[+]ping ok -healthz check passed -`, got) - }, - }, - { - name: "FailingHealthCheckWithHiddenReason", - opts: Options{ - HealthCheckers: []HealthChecker{ - HealthCheckerFunc("redis", func(_ *http.Request) error { - return errors.New("redis-connect-error") - }), - }, - }, - logDelegate: func(t *testing.T, m *mock.Mock) { - m.On("Logf", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { - argsMap := args.Get(1).(map[string]interface{}) - - assert.Equal(t, "health check failed", args.String(0)) - assert.Equal(t, "redis", argsMap["failed_checks"]) - }) - }, - want: func(t *testing.T, got string) { - assert.Equal(t, `[-]redis failed: reason hidden -`, got) - }, - }, - { - name: "FailingHealthCheckWithReason", - opts: Options{ - HealthCheckers: []HealthChecker{ - HealthCheckerFunc("redis", func(_ *http.Request) error { - return errors.New("redis-connect-error") - }), - }, - ShowErrReasons: true, - }, - want: func(t *testing.T, got string) { - assert.Equal(t, `[-]redis failed: - reason: redis-connect-error -`, got) - }, - }, - { - name: "FailingHealthCheckExcluded", - opts: Options{ - HealthCheckers: []HealthChecker{ - HealthCheckerFunc("redis", func(_ *http.Request) error { - return errors.New("redis-connect-error") - }), - PingHealthz, - }, - }, - excluded: []string{"redis"}, - want: func(t *testing.T, got string) { assert.Equal(t, "ok", got) }, - }, - { - name: "FailingHealthCheckWithExtraExcludes", - verbose: true, - excluded: []string{"redis", "foo,bar", "baz"}, - opts: Options{ - HealthCheckers: []HealthChecker{ - PingHealthz, - HealthCheckerFunc("redis", func(_ *http.Request) error { - return errors.New("redis-connect-error") - }), - }, - }, - logDelegate: func(t *testing.T, m *mock.Mock) { - m.On("Logf", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { - argsMap := args.Get(1).(map[string]interface{}) - - assert.Equal(t, "cannot exclude some health checks", args.String(0)) - assert.ElementsMatch(t, []string{"foo", "bar", "baz"}, slice.Map( - strings.Split(argsMap["checks"].(string), ", "), func(s string) string { - return strings.Trim(s, `"`) - }, - )) - assert.Equal(t, "no matches", argsMap["reason"]) - }) - }, - want: func(t *testing.T, got string) { - assert.True(t, strings.Contains(got, `[+]ping ok`)) - assert.True(t, strings.Contains(got, `[+]redis excluded: ok`)) - - re := regexp.MustCompile(`warn: some health checks cannot be excluded: no matches for (.*)`) - assert.True(t, re.MatchString(got)) - - excludedLine := re.FindStringSubmatch(got)[1] - assert.ElementsMatch(t, []string{"foo", "bar", "baz"}, slice.Map( - strings.Split(excludedLine, ", "), func(s string) string { - return strings.Trim(s, `"`) - }, - )) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ld := newMockLogDelegate(t) - - path := tt.opts.Prefix + "/healthz?" - - if len(tt.excluded) > 0 { - for _, e := range tt.excluded { - path += "exclude=" + e + "&" - } - } - - if tt.verbose { - path += "verbose" - } - - req := httptest.NewRequest(http.MethodGet, path, nil) - rw := httptest.NewRecorder() - - if tt.logDelegate != nil { - tt.logDelegate(t, &ld.Mock) - tt.opts.ErrorLogDelegate = ld.Logf - } - - NewProbeHandler(tt.opts).ServeHTTP(rw, req) - - rc := rw.Result().Body - b, err := io.ReadAll(rc) - - assert.NoError(t, err) - assert.NoError(t, rc.Close()) - - tt.want(t, string(b)) - - ld.AssertExpectations(t) - }) - } -} - -func newMockLogDelegate(t *testing.T) *mockLogDelegate { - m := &mockLogDelegate{} - m.Test(t) - return m -} - -type mockLogDelegate struct{ mock.Mock } - -func (m *mockLogDelegate) Logf(format string, args map[string]interface{}) { m.Called(format, args) } diff --git a/xpod/version.go b/xpod/version.go index 89e51ab..6c87023 100644 --- a/xpod/version.go +++ b/xpod/version.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "runtime" "time" ) @@ -34,19 +35,28 @@ type buildInfo struct { Arch string `json:"arch"` } -func (h *ProbeHandler) serveBuildInfo(w http.ResponseWriter, r *http.Request) { - if _, found := r.URL.Query()[verboseQueryParam]; !found { - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - w.Header().Set("X-Content-Type-Options", "nosniff") +func (h *ProbeHandler) versionHandler(opts Options) http.Handler { + bi := &buildInfo{ + BuildInfo: opts.BuildInfo, + GoVersion: runtime.Version(), + OS: runtime.GOOS, + Arch: runtime.GOARCH, + } - _, _ = fmt.Fprint(w, h.bi.Version) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, found := r.URL.Query()[verboseQueryParam]; !found { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") - return - } + _, _ = fmt.Fprint(w, bi.Version) + + return + } - w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Type", "application/json") - e := json.NewEncoder(w) - e.SetIndent("", " ") - _ = e.Encode(h.bi) + e := json.NewEncoder(w) + e.SetIndent("", " ") + _ = e.Encode(bi) + }) } diff --git a/xpod/version_test.go b/xpod/version_test.go index 0eb3311..34a6b17 100644 --- a/xpod/version_test.go +++ b/xpod/version_test.go @@ -13,6 +13,17 @@ import ( ) func TestProbeHandler_serveVersion(t *testing.T) { + verboseResponse := fmt.Sprintf(`{ + "version": "0.1.0", + "tag": "v0.1.0", + "commit": "24b3f5d876ffa402287bfa5c26cf05626a2b3b01", + "build_date": "Wed, 20 Apr 2022 04:20:04 UTC", + "go_version": "%s", + "os": "%s", + "arch": "%s" +} +`, runtime.Version(), runtime.GOOS, runtime.GOARCH) + tests := []struct { name string opts Options @@ -39,38 +50,84 @@ func TestProbeHandler_serveVersion(t *testing.T) { time.Date(2022, 04, 20, 4, 20, 4, 20, time.UTC), ), }}, - want: fmt.Sprintf(`{ - "version": "0.1.0", - "tag": "v0.1.0", - "commit": "24b3f5d876ffa402287bfa5c26cf05626a2b3b01", - "build_date": "Wed, 20 Apr 2022 04:20:04 UTC", - "go_version": "%s", - "os": "%s", - "arch": "%s" -} -`, runtime.Version(), runtime.GOOS, runtime.GOARCH), + want: verboseResponse, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - path := tt.opts.Prefix + "/version" - if tt.verbose { - path += "?verbose" - } + t.Run("ServeMux", func(t *testing.T) { + path := tt.opts.Prefix + "/version" + if tt.verbose { + path += "?verbose" + } - req := httptest.NewRequest(http.MethodGet, path, nil) - rw := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, path, nil) + rw := httptest.NewRecorder() - NewProbeHandler(tt.opts).ServeHTTP(rw, req) + NewProbeHandler(tt.opts).ServeHTTP(rw, req) - rc := rw.Result().Body - b, err := io.ReadAll(rc) + rc := rw.Result().Body + b, err := io.ReadAll(rc) - assert.NoError(t, err) - assert.NoError(t, rc.Close()) + assert.NoError(t, err) + assert.NoError(t, rc.Close()) - assert.Equal(t, tt.want, string(b)) + assert.Equal(t, tt.want, string(b)) + }) }) } + + t.Run("HandlerFunc", func(t *testing.T) { + t.Run("NilVersionHandler", func(t *testing.T) { + assert.Nil(t, NewProbeHandler(Options{}).VersionHandler()) + }) + + tests := []struct { + name string + opts Options + verbose bool + want string + }{ + { + name: "NonVerboseVersion", + opts: Options{BuildInfo: &BuildInfo{Version: "0.1.0"}}, + want: "0.1.0", + }, + { + name: "VerboseVersion", + verbose: true, + opts: Options{BuildInfo: &BuildInfo{ + Version: "0.1.0", + Tag: "v0.1.0", + Commit: "24b3f5d876ffa402287bfa5c26cf05626a2b3b01", + BuildDate: BuildDate( + time.Date(2022, 04, 20, 4, 20, 4, 20, time.UTC), + ), + }}, + want: verboseResponse, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := "/" + if tt.verbose { + path += "?verbose" + } + req := httptest.NewRequest(http.MethodGet, path, nil) + rw := httptest.NewRecorder() + + NewProbeHandler(tt.opts).VersionHandler().ServeHTTP(rw, req) + + rc := rw.Result().Body + b, err := io.ReadAll(rc) + + assert.NoError(t, err) + assert.NoError(t, rc.Close()) + + assert.Equal(t, tt.want, string(b)) + }) + } + }) }