diff --git a/cmd/nodeproblemdetector/node_problem_detector.go b/cmd/nodeproblemdetector/node_problem_detector.go index 27cbc8943..6858eb16f 100644 --- a/cmd/nodeproblemdetector/node_problem_detector.go +++ b/cmd/nodeproblemdetector/node_problem_detector.go @@ -28,6 +28,7 @@ import ( "k8s.io/node-problem-detector/pkg/exporters" "k8s.io/node-problem-detector/pkg/exporters/k8sexporter" "k8s.io/node-problem-detector/pkg/exporters/prometheusexporter" + "k8s.io/node-problem-detector/pkg/httpserver" "k8s.io/node-problem-detector/pkg/problemdaemon" "k8s.io/node-problem-detector/pkg/problemdetector" "k8s.io/node-problem-detector/pkg/problemmetrics" @@ -48,8 +49,14 @@ func npdMain(ctx context.Context, npdo *options.NodeProblemDetectorOptions) erro // Initialize exporters first to set up the OpenTelemetry readers. defaultExporters := []types.Exporter{} + // The Kubernetes exporter owns the node conditions, so it is what serves + // them on /conditions when it is enabled. + var conditionsGetter httpserver.ConditionsGetter if ke := k8sexporter.NewExporterOrDie(ctx, npdo); ke != nil { defaultExporters = append(defaultExporters, ke) + if getter, ok := ke.(httpserver.ConditionsGetter); ok { + conditionsGetter = getter + } klog.Info("K8s exporter started.") } if pe := prometheusexporter.NewExporterOrDie(npdo); pe != nil { @@ -58,6 +65,10 @@ func npdMain(ctx context.Context, npdo *options.NodeProblemDetectorOptions) erro } plugableExporters := exporters.NewExporters() + // The diagnostic endpoints are controlled by --port alone, so they stay + // available regardless of which exporters are enabled. + httpserver.Start(npdo, conditionsGetter) + // Initialize OpenTelemetry meter provider with all registered readers // This must be called after all exporters have been created and registered their readers meterProvider := otelutil.InitializeMeterProvider() diff --git a/pkg/exporters/k8sexporter/k8s_exporter.go b/pkg/exporters/k8sexporter/k8s_exporter.go index f4cba1673..b67832cdb 100644 --- a/pkg/exporters/k8sexporter/k8s_exporter.go +++ b/pkg/exporters/k8sexporter/k8s_exporter.go @@ -18,10 +18,6 @@ package k8sexporter import ( "context" - "net" - "net/http" - "net/http/pprof" - "strconv" "k8s.io/apimachinery/pkg/util/wait" "k8s.io/klog/v2" @@ -65,7 +61,6 @@ func NewExporterOrDie(ctx context.Context, npdo *options.NodeProblemDetectorOpti updateConditions: npdo.K8sExporterUpdateNodeConditions, } - ke.startHTTPReporting(npdo) ke.conditionManager.Start(ctx) return &ke @@ -84,40 +79,10 @@ func (ke *k8sExporter) ExportProblems(status *types.Status) { } } -func (ke *k8sExporter) startHTTPReporting(npdo *options.NodeProblemDetectorOptions) { - if npdo.ServerPort <= 0 { - return - } - mux := http.NewServeMux() - - // Add healthz http request handler. Always return ok now, add more health check - // logic in the future. - mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - if _, err := w.Write([]byte("ok")); err != nil { - klog.Errorf("Failed to write response: %v", err) - } - }) - - // Add the handler to serve condition http request. - mux.HandleFunc("/conditions", func(w http.ResponseWriter, r *http.Request) { - util.ReturnHTTPJson(w, ke.conditionManager.GetConditions()) - }) - - // register pprof - mux.HandleFunc("/debug/pprof/", pprof.Index) - mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) - mux.HandleFunc("/debug/pprof/profile", pprof.Profile) - mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) - mux.HandleFunc("/debug/pprof/trace", pprof.Trace) - - addr := net.JoinHostPort(npdo.ServerAddress, strconv.Itoa(npdo.ServerPort)) - go func() { - err := http.ListenAndServe(addr, mux) - if err != nil { - klog.Fatalf("Failed to start server: %v", err) - } - }() +// GetConditions returns the node conditions this exporter currently reports. +// It satisfies httpserver.ConditionsGetter, which serves them on /conditions. +func (ke *k8sExporter) GetConditions() []types.Condition { + return ke.conditionManager.GetConditions() } func waitForAPIServerReadyWithTimeout(ctx context.Context, c problemclient.Client, npdo *options.NodeProblemDetectorOptions) error { diff --git a/pkg/httpserver/httpserver.go b/pkg/httpserver/httpserver.go new file mode 100644 index 000000000..74df0485d --- /dev/null +++ b/pkg/httpserver/httpserver.go @@ -0,0 +1,91 @@ +/* +Copyright 2026 The Kubernetes Authors All rights reserved. + +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 httpserver serves NPD's diagnostic endpoints: /healthz, /conditions +// and /debug/pprof. The server is controlled by the --port flag alone, so it +// is available whether or not any particular exporter is enabled. +package httpserver + +import ( + "net" + "net/http" + "net/http/pprof" + "strconv" + + "k8s.io/klog/v2" + + "k8s.io/node-problem-detector/cmd/options" + "k8s.io/node-problem-detector/pkg/types" + "k8s.io/node-problem-detector/pkg/util" +) + +// ConditionsGetter reports the node conditions NPD currently exports. The +// Kubernetes exporter satisfies it; when that exporter is disabled there are +// no conditions to report and the server runs without a getter. +type ConditionsGetter interface { + GetConditions() []types.Condition +} + +// NewHandler builds the handler for the diagnostic endpoints. A nil getter +// serves no conditions, which is the same response as an enabled exporter +// that has not recorded any condition yet. +func NewHandler(getter ConditionsGetter) http.Handler { + mux := http.NewServeMux() + + // Add healthz http request handler. Always return ok now, add more health check + // logic in the future. + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte("ok")); err != nil { + klog.Errorf("Failed to write response: %v", err) + } + }) + + // Add the handler to serve condition http request. + mux.HandleFunc("/conditions", func(w http.ResponseWriter, r *http.Request) { + var conditions []types.Condition + if getter != nil { + conditions = getter.GetConditions() + } + util.ReturnHTTPJson(w, conditions) + }) + + // register pprof + mux.HandleFunc("/debug/pprof/", pprof.Index) + mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) + mux.HandleFunc("/debug/pprof/profile", pprof.Profile) + mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) + mux.HandleFunc("/debug/pprof/trace", pprof.Trace) + + return mux +} + +// Start serves the diagnostic endpoints in a new goroutine. A non-positive +// npdo.ServerPort disables the server. +func Start(npdo *options.NodeProblemDetectorOptions, getter ConditionsGetter) { + if npdo.ServerPort <= 0 { + return + } + + handler := NewHandler(getter) + addr := net.JoinHostPort(npdo.ServerAddress, strconv.Itoa(npdo.ServerPort)) + go func() { + err := http.ListenAndServe(addr, handler) + if err != nil { + klog.Fatalf("Failed to start server: %v", err) + } + }() +} diff --git a/pkg/httpserver/httpserver_test.go b/pkg/httpserver/httpserver_test.go new file mode 100644 index 000000000..bac8d8884 --- /dev/null +++ b/pkg/httpserver/httpserver_test.go @@ -0,0 +1,105 @@ +/* +Copyright 2026 The Kubernetes Authors All rights reserved. + +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 httpserver + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "k8s.io/node-problem-detector/pkg/types" +) + +type fakeConditionsGetter struct { + conditions []types.Condition +} + +func (f *fakeConditionsGetter) GetConditions() []types.Condition { + return f.conditions +} + +// get issues a request against the handler and returns the status and body. +func get(t *testing.T, handler http.Handler, path string) (int, string) { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec.Code, rec.Body.String() +} + +// The diagnostic endpoints are controlled by --port alone, so they must be +// served whether or not the Kubernetes exporter supplied a conditions getter. +func TestHandlerEndpointsServedWithoutConditionsGetter(t *testing.T) { + for _, tc := range []struct { + name string + getter ConditionsGetter + }{ + {"with getter", &fakeConditionsGetter{}}, + {"without getter", nil}, + } { + t.Run(tc.name, func(t *testing.T) { + handler := NewHandler(tc.getter) + + if code, body := get(t, handler, "/healthz"); code != http.StatusOK || body != "ok" { + t.Errorf("/healthz: wanted 200 %q, got %d %q", "ok", code, body) + } + for _, path := range []string{"/debug/pprof/", "/debug/pprof/cmdline", "/conditions"} { + if code, _ := get(t, handler, path); code != http.StatusOK { + t.Errorf("%s: wanted 200, got %d", path, code) + } + } + }) + } +} + +func TestHandlerConditions(t *testing.T) { + want := []types.Condition{{ + Type: "TestCondition", + Status: types.True, + Transition: time.Date(2026, time.August, 26, 10, 0, 0, 0, time.UTC), + Reason: "TestReason", + Message: "test message", + }} + + code, body := get(t, NewHandler(&fakeConditionsGetter{conditions: want}), "/conditions") + if code != http.StatusOK { + t.Fatalf("/conditions: wanted 200, got %d", code) + } + var got []types.Condition + if err := json.Unmarshal([]byte(body), &got); err != nil { + t.Fatalf("/conditions returned undecodable body %q: %v", body, err) + } + if len(got) != 1 || got[0].Type != want[0].Type || got[0].Status != want[0].Status || + got[0].Reason != want[0].Reason || got[0].Message != want[0].Message { + t.Errorf("/conditions: wanted %+v, got %+v", want, got) + } +} + +// Without a getter there is no condition to report. The response must match an +// enabled exporter that has not recorded any condition yet, so that a client +// cannot tell the two apart by parsing the body. +func TestHandlerConditionsWithoutGetterMatchesEmptyExporter(t *testing.T) { + _, withoutGetter := get(t, NewHandler(nil), "/conditions") + _, emptyGetter := get(t, NewHandler(&fakeConditionsGetter{}), "/conditions") + + if withoutGetter != emptyGetter { + t.Errorf("/conditions without a getter returned %q, but an exporter with no conditions returned %q", + withoutGetter, emptyGetter) + } +}