From 1ec355ad0209ecfa95f075a295e16e59ec0eb457 Mon Sep 17 00:00:00 2001 From: Irvin Lim Date: Sun, 26 Feb 2023 20:28:16 +0800 Subject: [PATCH] feat(httphandler): Implement debug/pprof endpoints (#131) --- apis/config/v1alpha1/managerconfig_types.go | 48 ++++- apis/config/v1alpha1/zz_generated.deepcopy.go | 122 +++++++---- cmd/execution-controller/main.go | 2 +- cmd/execution-webhook/main.go | 2 +- pkg/runtime/httphandler/debug.go | 125 ++++++++++++ pkg/runtime/httphandler/debug_test.go | 187 +++++++++++++++++ pkg/runtime/httphandler/health.go | 34 +++- pkg/runtime/httphandler/health_test.go | 189 ++++++++++++++++++ pkg/runtime/httphandler/listener.go | 128 ++++++++++++ pkg/runtime/httphandler/metrics.go | 6 +- pkg/runtime/httphandler/metrics_test.go | 81 ++++++++ pkg/runtime/httphandler/pprof.go | 59 ++++++ pkg/runtime/httphandler/pprof_test.go | 126 ++++++++++++ pkg/runtime/httphandler/server.go | 108 +++------- pkg/runtime/httphandler/testing/testing.go | 131 ++++++++++++ pkg/runtime/httphandler/tls.go | 4 +- pkg/utils/testutils/assert.go | 38 ++++ 17 files changed, 1257 insertions(+), 133 deletions(-) create mode 100644 pkg/runtime/httphandler/debug.go create mode 100644 pkg/runtime/httphandler/debug_test.go create mode 100644 pkg/runtime/httphandler/health_test.go create mode 100644 pkg/runtime/httphandler/listener.go create mode 100644 pkg/runtime/httphandler/metrics_test.go create mode 100644 pkg/runtime/httphandler/pprof.go create mode 100644 pkg/runtime/httphandler/pprof_test.go create mode 100644 pkg/runtime/httphandler/testing/testing.go diff --git a/apis/config/v1alpha1/managerconfig_types.go b/apis/config/v1alpha1/managerconfig_types.go index da3bccd..78579c4 100644 --- a/apis/config/v1alpha1/managerconfig_types.go +++ b/apis/config/v1alpha1/managerconfig_types.go @@ -146,15 +146,23 @@ type HTTPSpec struct { // Metrics controls metrics serving. // +optional - Metrics *MetricsSpec `json:"metrics,omitempty"` + Metrics *HTTPMetricsSpec `json:"metrics,omitempty"` // Health controls health status serving. // +optional - Health *HealthSpec `json:"health,omitempty"` + Health *HTTPHealthSpec `json:"health,omitempty"` + + // Debug controls debug endpoint serving. + // +optional + Debug *HTTPDebugSpec `json:"debug,omitempty"` + + // Pprof controls HTTP serving of runtime profiling data (i.e. pprof). + // +optional + Pprof *HTTPPprofSpec `json:"pprof,omitempty"` } -type MetricsSpec struct { - // Enabled is whether the controller manager enables serving Prometheus metrics. +type HTTPMetricsSpec struct { + // Enabled is whether the HTTP server enables serving Prometheus metrics. // // Default: true // +optional @@ -167,8 +175,8 @@ type MetricsSpec struct { MetricsPath string `json:"metricsPath,omitempty"` } -type HealthSpec struct { - // Enabled is whether the controller manager enables serving health probes. +type HTTPHealthSpec struct { + // Enabled is whether the HTTP server enables serving health probes. // // Default: true // +optional @@ -187,6 +195,34 @@ type HealthSpec struct { LivenessProbePath string `json:"livenessProbePath,omitempty"` } +type HTTPDebugSpec struct { + // Enabled is whether the HTTP server enables debug endpoints. + // + // Default: false + // +optional + Enabled *bool `json:"enabled,omitempty"` + + // BasePath is the base path for debug endpoints. + // + // Default: /debug + // +optional + BasePath string `json:"basePath,omitempty"` +} + +type HTTPPprofSpec struct { + // Enabled is whether the HTTP server enables pprof endpoints. + // + // Default: false + // +optional + Enabled *bool `json:"enabled,omitempty"` + + // IndexPath is the index path for pprof endpoints. + // + // Default: /debug/pprof + // +optional + IndexPath string `json:"indexPath,omitempty"` +} + type ExecutionControllerConcurrencySpec struct { // Control the concurrency for the Job controller. // diff --git a/apis/config/v1alpha1/zz_generated.deepcopy.go b/apis/config/v1alpha1/zz_generated.deepcopy.go index d983a8a..e8f51f6 100644 --- a/apis/config/v1alpha1/zz_generated.deepcopy.go +++ b/apis/config/v1alpha1/zz_generated.deepcopy.go @@ -257,32 +257,67 @@ func (in *ExecutionWebhookConfig) DeepCopyObject() runtime.Object { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *HTTPSpec) DeepCopyInto(out *HTTPSpec) { +func (in *HTTPDebugSpec) DeepCopyInto(out *HTTPDebugSpec) { *out = *in - if in.Metrics != nil { - in, out := &in.Metrics, &out.Metrics - *out = new(MetricsSpec) - (*in).DeepCopyInto(*out) + if in.Enabled != nil { + in, out := &in.Enabled, &out.Enabled + *out = new(bool) + **out = **in } - if in.Health != nil { - in, out := &in.Health, &out.Health - *out = new(HealthSpec) - (*in).DeepCopyInto(*out) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HTTPDebugSpec. +func (in *HTTPDebugSpec) DeepCopy() *HTTPDebugSpec { + if in == nil { + return nil } + out := new(HTTPDebugSpec) + in.DeepCopyInto(out) + return out } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HTTPSpec. -func (in *HTTPSpec) DeepCopy() *HTTPSpec { +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HTTPHealthSpec) DeepCopyInto(out *HTTPHealthSpec) { + *out = *in + if in.Enabled != nil { + in, out := &in.Enabled, &out.Enabled + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HTTPHealthSpec. +func (in *HTTPHealthSpec) DeepCopy() *HTTPHealthSpec { if in == nil { return nil } - out := new(HTTPSpec) + out := new(HTTPHealthSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HTTPMetricsSpec) DeepCopyInto(out *HTTPMetricsSpec) { + *out = *in + if in.Enabled != nil { + in, out := &in.Enabled, &out.Enabled + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HTTPMetricsSpec. +func (in *HTTPMetricsSpec) DeepCopy() *HTTPMetricsSpec { + if in == nil { + return nil + } + out := new(HTTPMetricsSpec) in.DeepCopyInto(out) return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *HealthSpec) DeepCopyInto(out *HealthSpec) { +func (in *HTTPPprofSpec) DeepCopyInto(out *HTTPPprofSpec) { *out = *in if in.Enabled != nil { in, out := &in.Enabled, &out.Enabled @@ -291,12 +326,47 @@ func (in *HealthSpec) DeepCopyInto(out *HealthSpec) { } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HealthSpec. -func (in *HealthSpec) DeepCopy() *HealthSpec { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HTTPPprofSpec. +func (in *HTTPPprofSpec) DeepCopy() *HTTPPprofSpec { + if in == nil { + return nil + } + out := new(HTTPPprofSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HTTPSpec) DeepCopyInto(out *HTTPSpec) { + *out = *in + if in.Metrics != nil { + in, out := &in.Metrics, &out.Metrics + *out = new(HTTPMetricsSpec) + (*in).DeepCopyInto(*out) + } + if in.Health != nil { + in, out := &in.Health, &out.Health + *out = new(HTTPHealthSpec) + (*in).DeepCopyInto(*out) + } + if in.Debug != nil { + in, out := &in.Debug, &out.Debug + *out = new(HTTPDebugSpec) + (*in).DeepCopyInto(*out) + } + if in.Pprof != nil { + in, out := &in.Pprof, &out.Pprof + *out = new(HTTPPprofSpec) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HTTPSpec. +func (in *HTTPSpec) DeepCopy() *HTTPSpec { if in == nil { return nil } - out := new(HealthSpec) + out := new(HTTPSpec) in.DeepCopyInto(out) return out } @@ -392,26 +462,6 @@ func (in *LeaderElectionSpec) DeepCopy() *LeaderElectionSpec { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MetricsSpec) DeepCopyInto(out *MetricsSpec) { - *out = *in - if in.Enabled != nil { - in, out := &in.Enabled, &out.Enabled - *out = new(bool) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetricsSpec. -func (in *MetricsSpec) DeepCopy() *MetricsSpec { - if in == nil { - return nil - } - out := new(MetricsSpec) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ObjectReference) DeepCopyInto(out *ObjectReference) { *out = *in diff --git a/cmd/execution-controller/main.go b/cmd/execution-controller/main.go index 3bed07c..d142695 100644 --- a/cmd/execution-controller/main.go +++ b/cmd/execution-controller/main.go @@ -124,7 +124,7 @@ func main() { // Start HTTP server in background. go func() { - if err := httphandler.ListenAndServe(ctx, options.HTTP, mgr); err != nil { + if err := httphandler.ListenAndServeHTTP(ctx, options.HTTP, mgr, &options, ctrlContext.Configs()); err != nil { klog.Fatalf("cannot start http handlers: %v", err) } }() diff --git a/cmd/execution-webhook/main.go b/cmd/execution-webhook/main.go index c7ae18f..13436ef 100644 --- a/cmd/execution-webhook/main.go +++ b/cmd/execution-webhook/main.go @@ -107,7 +107,7 @@ func main() { // Start HTTP server in background. go func() { - if err := httphandler.ListenAndServe(ctx, options.HTTP, mgr); err != nil { + if err := httphandler.ListenAndServeHTTP(ctx, options.HTTP, mgr, &options, ctrlContext.Configs()); err != nil { klog.Fatalf("cannot start http handlers: %v", err) } }() diff --git a/pkg/runtime/httphandler/debug.go b/pkg/runtime/httphandler/debug.go new file mode 100644 index 0000000..e49e098 --- /dev/null +++ b/pkg/runtime/httphandler/debug.go @@ -0,0 +1,125 @@ +/* + * Copyright 2022 The Furiko Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package httphandler + +import ( + "net/http" + "path" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/json" + "k8s.io/klog/v2" + + configv1alpha1 "github.com/furiko-io/furiko/apis/config/v1alpha1" + "github.com/furiko-io/furiko/pkg/runtime/controllercontext" +) + +const ( + defaultDebugBasePath = "/debug" +) + +// DebugHandler is a delegate to fetch debug information. +type DebugHandler interface { + GetBootstrapConfig() runtime.Object + GetDynamicConfig() (map[configv1alpha1.ConfigName]runtime.Object, error) +} + +type defaultDebugHandler struct { + bootstrapConfig runtime.Object + configsGetter controllercontext.Configs +} + +var _ DebugHandler = (*defaultDebugHandler)(nil) + +func (h *defaultDebugHandler) GetBootstrapConfig() runtime.Object { + return h.bootstrapConfig +} + +func (h *defaultDebugHandler) GetDynamicConfig() (map[configv1alpha1.ConfigName]runtime.Object, error) { + return h.configsGetter.AllConfigs() +} + +// ServeDebug adds debug handlers to the given serve mux. +func ServeDebug(mux *http.ServeMux, cfg *configv1alpha1.HTTPDebugSpec, handler DebugHandler) { + if cfg == nil { + return + } + if enabled := cfg.Enabled; enabled == nil || !*enabled { + return + } + + basePath := cfg.BasePath + if basePath == "" { + basePath = defaultDebugBasePath + } + + // Clean the path to remove any trailing slashes. + basePath = path.Clean(basePath) + + server := newDebugServer(handler) + mux.HandleFunc(basePath+"/config/bootstrap", server.HandleBootstrapConfig()) + mux.HandleFunc(basePath+"/config/dynamic", server.HandleDynamicConfig()) + + klog.V(4).Infof("httphandler: added http handler for health probes") +} + +type debugServer struct { + handler DebugHandler +} + +func newDebugServer(handler DebugHandler) *debugServer { + return &debugServer{ + handler: handler, + } +} + +func (s *debugServer) HandleBootstrapConfig() http.HandlerFunc { + return s.makeJSONHandler(s.handler.GetBootstrapConfig()) +} + +func (s *debugServer) HandleDynamicConfig() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + configs, err := s.handler.GetDynamicConfig() + if err != nil { + s.makeErrorHandler(err).ServeHTTP(w, r) + return + } + s.makeJSONHandler(configs).ServeHTTP(w, r) + } +} + +func (s *debugServer) makeJSONHandler(data interface{}) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + body, err := json.Marshal(data) + if err != nil { + s.makeErrorHandler(err).ServeHTTP(w, r) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) + } +} + +func (s *debugServer) makeErrorHandler(err error) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + body, _ := json.Marshal(map[string]interface{}{ + "error": err.Error(), + }) + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write(body) + } +} diff --git a/pkg/runtime/httphandler/debug_test.go b/pkg/runtime/httphandler/debug_test.go new file mode 100644 index 0000000..69c93a4 --- /dev/null +++ b/pkg/runtime/httphandler/debug_test.go @@ -0,0 +1,187 @@ +/* + * Copyright 2022 The Furiko Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package httphandler_test + +import ( + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/utils/pointer" + + configv1alpha1 "github.com/furiko-io/furiko/apis/config/v1alpha1" + "github.com/furiko-io/furiko/pkg/runtime/httphandler" + httphandlertesting "github.com/furiko-io/furiko/pkg/runtime/httphandler/testing" + "github.com/furiko-io/furiko/pkg/utils/testutils" +) + +var ( + mockBootstrapConfig = &configv1alpha1.ExecutionControllerConfig{ + BootstrapConfigSpec: configv1alpha1.BootstrapConfigSpec{ + DefaultResync: metav1.Duration{Duration: time.Hour}, + HTTP: &configv1alpha1.HTTPSpec{ + BindAddress: ":18080", + }, + }, + } + + mockDynamicConfig = map[configv1alpha1.ConfigName]runtime.Object{ + configv1alpha1.JobExecutionConfigName: &configv1alpha1.JobExecutionConfig{ + DefaultPendingTimeoutSeconds: pointer.Int64(1800), + }, + } + + defaultDebugHandler = &mockDebugHandler{ + bootstrapConfig: mockBootstrapConfig, + dynamicConfig: mockDynamicConfig, + } +) + +type mockDebugHandler struct { + bootstrapConfig runtime.Object + dynamicConfig map[configv1alpha1.ConfigName]runtime.Object + dynamicConfigError error +} + +var _ httphandler.DebugHandler = (*mockDebugHandler)(nil) + +func (m *mockDebugHandler) GetBootstrapConfig() runtime.Object { + return m.bootstrapConfig +} + +func (m *mockDebugHandler) GetDynamicConfig() (map[configv1alpha1.ConfigName]runtime.Object, error) { + if m.dynamicConfigError != nil { + return nil, m.dynamicConfigError + } + return m.dynamicConfig, nil +} + +func TestServeDebug(t *testing.T) { + makeTestCasesFor404 := func() []*httphandlertesting.Case { + return []*httphandlertesting.Case{ + { + Name: "bootstrap config should not be enabled", + Path: "/debug/config/bootstrap", + WantCode: 404, + }, + { + Name: "dynamic config should not be enabled", + Path: "/debug/config/dynamic", + WantCode: 404, + }, + } + } + + makeTestCasesFor200 := func(basePath string) []*httphandlertesting.Case { + return []*httphandlertesting.Case{ + { + Name: "bootstrap config should not be enabled", + Path: basePath + "/config/bootstrap", + AssertBody: testutils.AssertValueAll( + testutils.AssertValueJSONEq(mockBootstrapConfig), + ), + }, + { + Name: "dynamic config should not be enabled", + Path: basePath + "/config/dynamic", + AssertBody: testutils.AssertValueAll( + testutils.AssertValueJSONEq(mockDynamicConfig), + ), + }, + } + } + + type testCase struct { + name string + cfg *configv1alpha1.HTTPDebugSpec + handler httphandler.DebugHandler + cases []*httphandlertesting.Case + } + for _, tt := range []testCase{ + { + name: "default config", + handler: defaultDebugHandler, + cases: makeTestCasesFor404(), + }, + { + name: "explicitly disabled", + cfg: &configv1alpha1.HTTPDebugSpec{ + Enabled: pointer.Bool(false), + }, + handler: defaultDebugHandler, + cases: makeTestCasesFor404(), + }, + { + name: "handler is enabled", + handler: defaultDebugHandler, + cfg: &configv1alpha1.HTTPDebugSpec{ + Enabled: pointer.Bool(true), + }, + cases: makeTestCasesFor200("/debug"), + }, + { + name: "custom base path", + handler: defaultDebugHandler, + cfg: &configv1alpha1.HTTPDebugSpec{ + Enabled: pointer.Bool(true), + BasePath: "/state", + }, + cases: append(makeTestCasesFor404(), makeTestCasesFor200("/state")...), + }, + { + name: "custom base path with trailing slash", + handler: defaultDebugHandler, + cfg: &configv1alpha1.HTTPDebugSpec{ + Enabled: pointer.Bool(true), + BasePath: "/state/", + }, + cases: append(makeTestCasesFor404(), makeTestCasesFor200("/state")...), + }, + { + name: "error getting dynamic configs", + handler: &mockDebugHandler{ + bootstrapConfig: mockBootstrapConfig, + dynamicConfig: mockDynamicConfig, + dynamicConfigError: assert.AnError, + }, + cfg: &configv1alpha1.HTTPDebugSpec{ + Enabled: pointer.Bool(true), + }, + cases: []*httphandlertesting.Case{ + { + Name: "error while getting dynamic config", + Path: "/debug/config/dynamic", + WantCode: 500, + AssertBody: testutils.AssertValueAll( + testutils.AssertValueJSONEq(map[string]any{"error": assert.AnError.Error()}), + ), + }, + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + suite := httphandlertesting.NewSuite(&httphandlertesting.Config{ + Method: http.MethodGet, + }) + httphandler.ServeDebug(suite.GetMux(), tt.cfg, tt.handler) + suite.Run(t, tt.cases) + }) + } +} diff --git a/pkg/runtime/httphandler/health.go b/pkg/runtime/httphandler/health.go index 947e4bf..7af9021 100644 --- a/pkg/runtime/httphandler/health.go +++ b/pkg/runtime/httphandler/health.go @@ -17,13 +17,14 @@ package httphandler import ( - "encoding/json" "fmt" "net/http" + "k8s.io/apimachinery/pkg/util/json" "k8s.io/klog/v2" configv1alpha1 "github.com/furiko-io/furiko/apis/config/v1alpha1" + "github.com/furiko-io/furiko/pkg/runtime/controllermanager" ) const ( @@ -31,8 +32,14 @@ const ( defaultLivenessProbePath = "/healthz" ) +// HealthHandler is a delegate to get health information. +type HealthHandler interface { + GetReadiness() error + GetHealth() []controllermanager.HealthStatus +} + // ServeHealth adds health probe handlers to the given serve mux. -func ServeHealth(mux *http.ServeMux, cfg *configv1alpha1.HealthSpec, mgr Manager) { +func ServeHealth(mux *http.ServeMux, cfg *configv1alpha1.HTTPHealthSpec, handler HealthHandler) { // Not enabled. if cfg == nil { return @@ -50,17 +57,28 @@ func ServeHealth(mux *http.ServeMux, cfg *configv1alpha1.HealthSpec, mgr Manager livenessProbePath = defaultLivenessProbePath } - mux.HandleFunc(readinessProbePath, handleReadinessProbes(mgr)) - mux.HandleFunc(livenessProbePath, handleLivenessProbes(mgr)) + server := newHealthServer(handler) + mux.HandleFunc(readinessProbePath, server.HandleReadinessProbes()) + mux.HandleFunc(livenessProbePath, server.HandleLivenessProbes()) klog.V(4).Infof("httphandler: added http handler for health probes") } -func handleReadinessProbes(mgr Manager) http.HandlerFunc { +type healthServer struct { + handler HealthHandler +} + +func newHealthServer(handler HealthHandler) *healthServer { + return &healthServer{ + handler: handler, + } +} + +func (s *healthServer) HandleReadinessProbes() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { statusCode := http.StatusOK msg := "ok" - if err := mgr.GetReadiness(); err != nil { + if err := s.handler.GetReadiness(); err != nil { statusCode = http.StatusServiceUnavailable msg = fmt.Sprintf("controller manager is not ready: %v", err) } @@ -69,12 +87,12 @@ func handleReadinessProbes(mgr Manager) http.HandlerFunc { } } -func handleLivenessProbes(mgr Manager) http.HandlerFunc { +func (s *healthServer) HandleLivenessProbes() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { healthy := true statusCode := http.StatusAccepted - ctrlHealth := mgr.GetHealth() + ctrlHealth := s.handler.GetHealth() for _, workerHealth := range ctrlHealth { if !workerHealth.Healthy { statusCode = http.StatusInternalServerError diff --git a/pkg/runtime/httphandler/health_test.go b/pkg/runtime/httphandler/health_test.go new file mode 100644 index 0000000..0c26f62 --- /dev/null +++ b/pkg/runtime/httphandler/health_test.go @@ -0,0 +1,189 @@ +/* + * Copyright 2022 The Furiko Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package httphandler_test + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/utils/pointer" + + configv1alpha1 "github.com/furiko-io/furiko/apis/config/v1alpha1" + "github.com/furiko-io/furiko/pkg/runtime/controllermanager" + "github.com/furiko-io/furiko/pkg/runtime/httphandler" + httphandlertesting "github.com/furiko-io/furiko/pkg/runtime/httphandler/testing" + "github.com/furiko-io/furiko/pkg/utils/testutils" +) + +var ( + defaultHealthHandler = &mockHealthHandler{ + healthStatus: []controllermanager.HealthStatus{ + { + Name: "TestController", + Healthy: true, + }, + }, + } +) + +type mockHealthHandler struct { + readiness error + healthStatus []controllermanager.HealthStatus +} + +var _ httphandler.HealthHandler = (*mockHealthHandler)(nil) + +func (m *mockHealthHandler) GetReadiness() error { + return m.readiness +} + +func (m *mockHealthHandler) GetHealth() []controllermanager.HealthStatus { + return m.healthStatus +} + +func TestServeHealth(t *testing.T) { + makeTestCasesFor404 := func() []*httphandlertesting.Case { + return []*httphandlertesting.Case{ + { + Name: "readiness probe path should not be enabled", + Path: "/readyz", + WantCode: 404, + }, + { + Name: "liveness probe path should not be enabled", + Path: "/healthz", + WantCode: 404, + }, + } + } + + makeTestCasesFor200 := func(readinessPath, livenessPath string) []*httphandlertesting.Case { + return []*httphandlertesting.Case{ + { + Name: "readiness probe should be ok", + Path: readinessPath, + WantCode: 200, + AssertBody: testutils.AssertValueAll( + testutils.AssertValueContains("ok"), + ), + }, + { + Name: "liveness probe should be ok", + Path: livenessPath, + WantCode: 202, + AssertBody: testutils.AssertValueAll( + testutils.AssertValueJSONEq(map[string]any{ + "healthy": true, + "status": defaultHealthHandler.healthStatus, + }), + ), + }, + } + } + + type testCase struct { + name string + cfg *configv1alpha1.HTTPHealthSpec + handler httphandler.HealthHandler + cases []*httphandlertesting.Case + } + for _, tt := range []testCase{ + { + name: "default config", + handler: defaultHealthHandler, + cases: makeTestCasesFor404(), + }, + { + name: "explicitly disabled", + cfg: &configv1alpha1.HTTPHealthSpec{ + Enabled: pointer.Bool(false), + }, + handler: defaultHealthHandler, + cases: makeTestCasesFor404(), + }, + { + name: "handler is enabled", + handler: defaultHealthHandler, + cfg: &configv1alpha1.HTTPHealthSpec{ + Enabled: pointer.Bool(true), + }, + cases: makeTestCasesFor200("/readyz", "/healthz"), + }, + { + name: "custom probe paths", + handler: defaultHealthHandler, + cfg: &configv1alpha1.HTTPHealthSpec{ + Enabled: pointer.Bool(true), + LivenessProbePath: "/liveness", + ReadinessProbePath: "/readiness", + }, + cases: append( + makeTestCasesFor404(), + makeTestCasesFor200("/readiness", "/liveness")..., + ), + }, + { + name: "error scenarios", + handler: &mockHealthHandler{ + readiness: assert.AnError, + healthStatus: []controllermanager.HealthStatus{ + { + Name: "TestController", + Healthy: false, + Message: "Controller is deadlocked", + }, + }, + }, + cfg: &configv1alpha1.HTTPHealthSpec{ + Enabled: pointer.Bool(true), + }, + cases: []*httphandlertesting.Case{ + { + Name: "readiness probe returns error", + Path: "/readyz", + WantCode: 503, + }, + { + Name: "liveness probe returns unhealthy status", + Path: "/healthz", + WantCode: 500, + AssertBody: testutils.AssertValueAll( + testutils.AssertValueJSONEq(map[string]any{ + "healthy": false, + "status": []controllermanager.HealthStatus{ + { + Name: "TestController", + Healthy: false, + Message: "Controller is deadlocked", + }, + }, + }), + ), + }, + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + suite := httphandlertesting.NewSuite(&httphandlertesting.Config{ + Method: http.MethodGet, + }) + httphandler.ServeHealth(suite.GetMux(), tt.cfg, tt.handler) + suite.Run(t, tt.cases) + }) + } +} diff --git a/pkg/runtime/httphandler/listener.go b/pkg/runtime/httphandler/listener.go new file mode 100644 index 0000000..b90ef25 --- /dev/null +++ b/pkg/runtime/httphandler/listener.go @@ -0,0 +1,128 @@ +/* + * Copyright 2022 The Furiko Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package httphandler + +import ( + "context" + "net/http" + "time" + + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/metrics" + + configv1alpha1 "github.com/furiko-io/furiko/apis/config/v1alpha1" + "github.com/furiko-io/furiko/pkg/runtime/controllercontext" + "github.com/furiko-io/furiko/pkg/runtime/controllermanager" +) + +var ( + defaultHTTPConfig = &configv1alpha1.HTTPSpec{ + BindAddress: ":8080", + } + + defaultWebhooksConfig = &configv1alpha1.WebhookServerSpec{ + BindAddress: ":9443", + } +) + +// ListenAndServeHTTP listens on the given TCP address and gracefully stops when the +// given context is canceled, setting up all HTTP handlers. +func ListenAndServeHTTP( + ctx context.Context, + config *configv1alpha1.HTTPSpec, + healthHandler HealthHandler, + bootstrapConfig runtime.Object, + configsGetter controllercontext.Configs, +) error { + if config == nil { + config = defaultHTTPConfig + } + addr := config.BindAddress + if addr == "" { + addr = defaultHTTPConfig.BindAddress + } + + server := NewServer(addr) + server.RegisterCommonHandlers( + config, + metrics.Registry, + healthHandler, + &defaultDebugHandler{ + bootstrapConfig: bootstrapConfig, + configsGetter: configsGetter, + }, + ) + return server.ListenAndServe(ctx) +} + +// ListenAndServeWebhooks listens on the given TCP address and gracefully stops when the +// given context is canceled, setting up all webhooks handlers. +func ListenAndServeWebhooks( + ctx context.Context, + config *configv1alpha1.WebhookServerSpec, + webhooks []controllermanager.Webhook, +) error { + if config == nil { + config = defaultWebhooksConfig + } + addr := config.BindAddress + if addr == "" { + addr = defaultWebhooksConfig.BindAddress + } + + mux := http.NewServeMux() + server := newTLSServer(&http.Server{ + Addr: addr, + Handler: mux, + }, config.TLSCertFile, config.TLSPrivateKeyFile) + + ServeWebhooks(mux, webhooks) + return listenAndServe(ctx, addr, server) +} + +// ListeningServer is implemented by both http.Server (HTTP) and tlsServer (HTTPS). +type ListeningServer interface { + ListenAndServe() error + Shutdown(context.Context) error +} + +var _ ListeningServer = (*http.Server)(nil) + +// listenAndServe binds to the given address, listening for connections to be served by the given ListeningServer. +func listenAndServe(ctx context.Context, addr string, server ListeningServer) error { + go func() { + <-ctx.Done() + klog.Infof("httphandler: shutting down http server on %v", addr) + shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second*15) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + klog.ErrorS(err, "httphandler: error while shutting down", "addr", addr) + } + klog.Infof("httphandler: http server shut down on %v", addr) + }() + + klog.Infof("httphandler: http server listening on %v", addr) + if err := server.ListenAndServe(); errors.Is(err, http.ErrServerClosed) { + return nil + } else if err != nil { + return err + } + + return nil +} diff --git a/pkg/runtime/httphandler/metrics.go b/pkg/runtime/httphandler/metrics.go index a466bc9..96eae95 100644 --- a/pkg/runtime/httphandler/metrics.go +++ b/pkg/runtime/httphandler/metrics.go @@ -19,9 +19,9 @@ package httphandler import ( "net/http" + "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" "k8s.io/klog/v2" - "sigs.k8s.io/controller-runtime/pkg/metrics" configv1alpha1 "github.com/furiko-io/furiko/apis/config/v1alpha1" ) @@ -31,7 +31,7 @@ const ( ) // ServeMetrics adds Prometheus metrics handlers to the given serve mux. -func ServeMetrics(mux *http.ServeMux, cfg *configv1alpha1.MetricsSpec) { +func ServeMetrics(mux *http.ServeMux, cfg *configv1alpha1.HTTPMetricsSpec, registry prometheus.Gatherer) { // Not enabled. if cfg == nil { return @@ -46,7 +46,7 @@ func ServeMetrics(mux *http.ServeMux, cfg *configv1alpha1.MetricsSpec) { } // Use registry from controller-runtime - mux.Handle(path, promhttp.HandlerFor(metrics.Registry, promhttp.HandlerOpts{ + mux.Handle(path, promhttp.HandlerFor(registry, promhttp.HandlerOpts{ ErrorHandling: promhttp.HTTPErrorOnError, })) diff --git a/pkg/runtime/httphandler/metrics_test.go b/pkg/runtime/httphandler/metrics_test.go new file mode 100644 index 0000000..665a08a --- /dev/null +++ b/pkg/runtime/httphandler/metrics_test.go @@ -0,0 +1,81 @@ +/* + * Copyright 2022 The Furiko Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package httphandler_test + +import ( + "net/http" + "testing" + + "github.com/prometheus/client_golang/prometheus" + "k8s.io/utils/pointer" + + configv1alpha1 "github.com/furiko-io/furiko/apis/config/v1alpha1" + "github.com/furiko-io/furiko/pkg/runtime/httphandler" + httphandlertesting "github.com/furiko-io/furiko/pkg/runtime/httphandler/testing" +) + +func TestServeMetrics(t *testing.T) { + type testCase struct { + name string + cfg *configv1alpha1.HTTPMetricsSpec + cases []*httphandlertesting.Case + } + for _, tt := range []testCase{ + { + name: "default config", + cases: []*httphandlertesting.Case{ + { + Name: "should not be enabled", + WantCode: 404, + }, + }, + }, + { + name: "explicitly disabled", + cfg: &configv1alpha1.HTTPMetricsSpec{ + Enabled: pointer.Bool(false), + }, + cases: []*httphandlertesting.Case{ + { + Name: "should not be enabled", + WantCode: 404, + }, + }, + }, + { + name: "config is enabled", + cfg: &configv1alpha1.HTTPMetricsSpec{ + Enabled: pointer.Bool(true), + }, + cases: []*httphandlertesting.Case{ + { + Name: "should be ok", + WantCode: 200, + }, + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + suite := httphandlertesting.NewSuite(&httphandlertesting.Config{ + Method: http.MethodGet, + Path: "/metrics", + }) + httphandler.ServeMetrics(suite.GetMux(), tt.cfg, prometheus.NewRegistry()) + suite.Run(t, tt.cases) + }) + } +} diff --git a/pkg/runtime/httphandler/pprof.go b/pkg/runtime/httphandler/pprof.go new file mode 100644 index 0000000..bbfbe7f --- /dev/null +++ b/pkg/runtime/httphandler/pprof.go @@ -0,0 +1,59 @@ +/* + * Copyright 2022 The Furiko Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package httphandler + +import ( + "net/http" + "net/http/pprof" + "path" + + "k8s.io/klog/v2" + + configv1alpha1 "github.com/furiko-io/furiko/apis/config/v1alpha1" +) + +const ( + defaultPprofIndexPath = "/debug/pprof" +) + +// ServePprof adds the pprof debug handler to the given serve mux. +func ServePprof(mux *http.ServeMux, cfg *configv1alpha1.HTTPPprofSpec) { + // Not enabled. + if cfg == nil { + return + } + if enabled := cfg.Enabled; enabled == nil || !*enabled { + return + } + + indexPath := cfg.IndexPath + if indexPath == "" { + indexPath = defaultPprofIndexPath + } + + // Clean the path to remove any trailing slashes. + indexPath = path.Clean(indexPath) + + // Register all handlers. + mux.HandleFunc(indexPath+"/", pprof.Index) + mux.HandleFunc(indexPath+"/cmdline", pprof.Cmdline) + mux.HandleFunc(indexPath+"/profile", pprof.Profile) + mux.HandleFunc(indexPath+"/symbol", pprof.Symbol) + mux.HandleFunc(indexPath+"/trace", pprof.Trace) + + klog.V(4).Infof("httphandler: added http handler for pprof") +} diff --git a/pkg/runtime/httphandler/pprof_test.go b/pkg/runtime/httphandler/pprof_test.go new file mode 100644 index 0000000..f547877 --- /dev/null +++ b/pkg/runtime/httphandler/pprof_test.go @@ -0,0 +1,126 @@ +/* + * Copyright 2022 The Furiko Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package httphandler_test + +import ( + "net/http" + "testing" + + "k8s.io/utils/pointer" + + configv1alpha1 "github.com/furiko-io/furiko/apis/config/v1alpha1" + "github.com/furiko-io/furiko/pkg/runtime/httphandler" + httphandlertesting "github.com/furiko-io/furiko/pkg/runtime/httphandler/testing" +) + +func TestServePprof(t *testing.T) { + type testCase struct { + name string + cfg *configv1alpha1.HTTPPprofSpec + cases []*httphandlertesting.Case + } + for _, tt := range []testCase{ + { + name: "default config", + cases: []*httphandlertesting.Case{ + { + Name: "should not be enabled", + Path: "/debug/pprof/", + WantCode: 404, + }, + }, + }, + { + name: "explicitly disabled", + cfg: &configv1alpha1.HTTPPprofSpec{ + Enabled: pointer.Bool(false), + }, + cases: []*httphandlertesting.Case{ + { + Name: "should not be enabled", + Path: "/debug/pprof/", + WantCode: 404, + }, + }, + }, + { + name: "config is enabled", + cfg: &configv1alpha1.HTTPPprofSpec{ + Enabled: pointer.Bool(true), + }, + cases: []*httphandlertesting.Case{ + { + Name: "should have redirect", + Path: "/debug/pprof", + WantCode: 301, + }, + { + Name: "should be ok", + Path: "/debug/pprof/", + WantCode: 200, + }, + { + Name: "can load goroutine", + Path: "/debug/pprof/goroutine", + WantCode: 200, + }, + { + Name: "cannot load invalid profile", + Path: "/debug/pprof/invalid", + WantCode: 404, + }, + }, + }, + { + name: "custom path", + cfg: &configv1alpha1.HTTPPprofSpec{ + Enabled: pointer.Bool(true), + IndexPath: "/_internal/debug/pprof", + }, + cases: []*httphandlertesting.Case{ + { + Name: "default route is not registered", + Path: "/debug/pprof/", + WantCode: 404, + }, + { + Name: "should have redirect", + Path: "/_internal/debug/pprof", + WantCode: 301, + }, + { + Name: "should be ok", + Path: "/_internal/debug/pprof/", + WantCode: 200, + }, + { + Name: "can load goroutine", + Path: "/_internal/debug/pprof/goroutine", + WantCode: 200, + }, + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + suite := httphandlertesting.NewSuite(&httphandlertesting.Config{ + Method: http.MethodGet, + }) + httphandler.ServePprof(suite.GetMux(), tt.cfg) + suite.Run(t, tt.cases) + }) + } +} diff --git a/pkg/runtime/httphandler/server.go b/pkg/runtime/httphandler/server.go index 70f857a..b9cfdc9 100644 --- a/pkg/runtime/httphandler/server.go +++ b/pkg/runtime/httphandler/server.go @@ -19,100 +19,56 @@ package httphandler import ( "context" "net/http" - "time" - "github.com/pkg/errors" - "k8s.io/klog/v2" + "github.com/prometheus/client_golang/prometheus" configv1alpha1 "github.com/furiko-io/furiko/apis/config/v1alpha1" - "github.com/furiko-io/furiko/pkg/runtime/controllermanager" ) -var ( - defaultHTTPConfig = &configv1alpha1.HTTPSpec{ - BindAddress: ":8080", - } - - defaultWebhooksConfig = &configv1alpha1.WebhookServerSpec{ - BindAddress: ":9443", - } -) - -type Manager interface { - GetReadiness() error - GetHealth() []controllermanager.HealthStatus +// Server is a thin wrapper around HTTP servers. +type Server struct { + addr string + mux *http.ServeMux + server ListeningServer } -// ListenAndServe listens on the given TCP address and gracefully stops when the -// given context is canceled, setting up all HTTP handlers. -func ListenAndServe(ctx context.Context, config *configv1alpha1.HTTPSpec, mgr Manager) error { - if config == nil { - config = defaultHTTPConfig - } - addr := config.BindAddress - if addr == "" { - addr = defaultHTTPConfig.BindAddress - } - +func NewServer(addr string) *Server { mux := http.NewServeMux() server := &http.Server{ Addr: addr, Handler: mux, } - ServeMetrics(mux, config.Metrics) - ServeHealth(mux, config.Health, mgr) - return listenAndServe(ctx, addr, server) -} - -// ListenAndServeWebhooks listens on the given TCP address and gracefully stops when the -// given context is canceled, setting up all webhooks handlers. -func ListenAndServeWebhooks( - ctx context.Context, - config *configv1alpha1.WebhookServerSpec, - webhooks []controllermanager.Webhook, -) error { - if config == nil { - config = defaultWebhooksConfig - } - addr := config.BindAddress - if addr == "" { - addr = defaultWebhooksConfig.BindAddress + return &Server{ + mux: mux, + addr: addr, + server: server, } - - mux := http.NewServeMux() - server := newTLSServer(&http.Server{ - Addr: addr, - Handler: mux, - }, config.TLSCertFile, config.TLSPrivateKeyFile) - - ServeWebhooks(mux, webhooks) - return listenAndServe(ctx, addr, server) } -type Server interface { - ListenAndServe() error - Shutdown(context.Context) error +// Handle registers the handler for the given pattern. +// If a handler already exists for pattern, Handle panics. +func (s *Server) Handle(path string, handler http.Handler) { + s.mux.Handle(path, handler) } -func listenAndServe(ctx context.Context, addr string, server Server) error { - go func() { - <-ctx.Done() - klog.Infof("httphandler: shutting down http server on %v", addr) - shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second*15) - defer cancel() - if err := server.Shutdown(shutdownCtx); err != nil { - klog.ErrorS(err, "httphandler: error while shutting down", "addr", addr) - } - klog.Infof("httphandler: http server shut down on %v", addr) - }() - - klog.Infof("httphandler: http server listening on %v", addr) - if err := server.ListenAndServe(); errors.Is(err, http.ErrServerClosed) { - return nil - } else if err != nil { - return err +// RegisterCommonHandlers registers common handlers from the given HTTPSpec. +func (s *Server) RegisterCommonHandlers( + cfg *configv1alpha1.HTTPSpec, + registry prometheus.Gatherer, + healthHandler HealthHandler, + debugHandler DebugHandler, +) { + if cfg == nil { + cfg = defaultHTTPConfig } + ServeMetrics(s.mux, cfg.Metrics, registry) + ServeHealth(s.mux, cfg.Health, healthHandler) + ServeDebug(s.mux, cfg.Debug, debugHandler) + ServePprof(s.mux, cfg.Pprof) +} - return nil +// ListenAndServe will listen and serve incoming HTTP connections until the context is closed. +func (s *Server) ListenAndServe(ctx context.Context) error { + return listenAndServe(ctx, s.addr, s.server) } diff --git a/pkg/runtime/httphandler/testing/testing.go b/pkg/runtime/httphandler/testing/testing.go new file mode 100644 index 0000000..d770a22 --- /dev/null +++ b/pkg/runtime/httphandler/testing/testing.go @@ -0,0 +1,131 @@ +/* + * Copyright 2022 The Furiko Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package testing + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +// Config contains common configuration for a single Suite. +type Config struct { + // Defines the default path to request. + Path string + + // Defines the default HTTP method to use. + Method string +} + +// Case represents a single test case. +type Case struct { + // Name of the test case. + Name string + + // Path to request. + // If empty, uses the default path in the Config. + Path string + + // Method to request. + // If empty, uses the default method in the Config. + Method string + + // Request body. + Body []byte + + // WantError defines whether the HTTP request is expected to have a non-2xx response code. + WantError bool + + // WantCode defines the HTTP code that is expected. + // If not defined, then HTTP code checking is omitted. + WantCode int + + // AssertBody validates the response body. + AssertBody assert.ValueAssertionFunc +} + +// Suite is a test suite for HTTP testing. +type Suite struct { + mux *http.ServeMux + suite *Config +} + +func NewSuite(test *Config) *Suite { + return &Suite{ + mux: http.NewServeMux(), + suite: test, + } +} + +// GetMux returns the *http.ServeMux. +func (s *Suite) GetMux() *http.ServeMux { + return s.mux +} + +// Run the test suite. +func (s *Suite) Run(t *testing.T, cases []*Case) { + for _, tt := range cases { + t.Run(tt.Name, func(t *testing.T) { + s.runTestCase(t, tt) + }) + } +} + +func (s *Suite) runTestCase(t *testing.T, tt *Case) { + rec := httptest.NewRecorder() + + method := tt.Method + if method == "" { + method = s.suite.Method + } + path := tt.Path + if path == "" { + path = s.suite.Path + } + var body io.Reader + if len(tt.Body) > 0 { + body = bytes.NewBuffer(tt.Body) + } + + req := httptest.NewRequest(method, path, body) + s.GetMux().ServeHTTP(rec, req) + + resp := rec.Result() + respBody, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + defer assert.NoError(t, resp.Body.Close()) + + // Check the status code if defined. + if tt.WantCode != 0 { + assert.Equal(t, tt.WantCode, resp.StatusCode, "StatusCode not equal") + } + + // Check WantError only if WantCode is not defined. + if tt.WantCode == 0 { + isError := resp.StatusCode >= 400 + assert.Equalf(t, tt.WantError, isError, "WantError = %v, got status code %v", tt.WantError, resp.StatusCode) + } + + // Validate the response body. + if tt.AssertBody != nil { + tt.AssertBody(t, string(respBody), "Response body not equal, got: %v", string(respBody)) + } +} diff --git a/pkg/runtime/httphandler/tls.go b/pkg/runtime/httphandler/tls.go index 23c70b3..693e0ec 100644 --- a/pkg/runtime/httphandler/tls.go +++ b/pkg/runtime/httphandler/tls.go @@ -22,7 +22,7 @@ import ( "github.com/pkg/errors" ) -// tlsServer is a wrapper around *http.Server that overrides the ListenAndServe +// tlsServer is a wrapper around *http.Server that overrides the ListenAndServeHTTP // implementation for TLS. type tlsServer struct { *http.Server @@ -38,7 +38,7 @@ func newTLSServer(server *http.Server, certFile, keyFile string) *tlsServer { } } -var _ Server = (*tlsServer)(nil) +var _ ListeningServer = (*tlsServer)(nil) func (s *tlsServer) ListenAndServe() error { if s.certFile == "" { diff --git a/pkg/utils/testutils/assert.go b/pkg/utils/testutils/assert.go index 1ae732a..1617ec6 100644 --- a/pkg/utils/testutils/assert.go +++ b/pkg/utils/testutils/assert.go @@ -17,6 +17,8 @@ package testutils import ( + "encoding/json" + "github.com/stretchr/testify/assert" apierrors "k8s.io/apimachinery/pkg/api/errors" ) @@ -45,6 +47,42 @@ func AssertErrorContains(str string) assert.ErrorAssertionFunc { } } +// AssertValueContains returns assert.ValueAssertionFunc that asserts that the value contains the given contents. +func AssertValueContains(contains interface{}) assert.ValueAssertionFunc { + return func(t assert.TestingT, val interface{}, i ...interface{}) bool { + return assert.Contains(t, val, contains, i...) + } +} + +// AssertValueJSONEq returns assert.ValueAssertionFunc that asserts that two JSON strings are equivalent. +func AssertValueJSONEq(expected any) assert.ValueAssertionFunc { + return func(t assert.TestingT, val interface{}, i ...interface{}) bool { + actual, ok := val.(string) + if !ok { + t.Errorf("wanted a string, got %T", val) + return false + } + expectedBytes, err := json.Marshal(expected) + if err != nil { + t.Errorf("Marshal() got error: %v", err) + return false + } + return assert.JSONEq(t, string(expectedBytes), actual, i...) + } +} + +// AssertValueAll composes multiple assert.ValueAssertionFunc together. +func AssertValueAll(fns ...assert.ValueAssertionFunc) assert.ValueAssertionFunc { + return func(t assert.TestingT, val interface{}, i ...interface{}) bool { + for _, fn := range fns { + if !fn(t, val, i...) { + return false + } + } + return true + } +} + // WantError checks err against assert.ErrorAssertionFunc, returning true if an // error was encountered for short-circuiting. func WantError(t assert.TestingT, wantErr assert.ErrorAssertionFunc, err error, i ...interface{}) bool {