Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions cmd/nodeproblemdetector/node_problem_detector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand All @@ -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()
Expand Down
43 changes: 4 additions & 39 deletions pkg/exporters/k8sexporter/k8s_exporter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -65,7 +61,6 @@ func NewExporterOrDie(ctx context.Context, npdo *options.NodeProblemDetectorOpti
updateConditions: npdo.K8sExporterUpdateNodeConditions,
}

ke.startHTTPReporting(npdo)
ke.conditionManager.Start(ctx)

return &ke
Expand All @@ -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 {
Expand Down
91 changes: 91 additions & 0 deletions pkg/httpserver/httpserver.go
Original file line number Diff line number Diff line change
@@ -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)
}
}()
}
105 changes: 105 additions & 0 deletions pkg/httpserver/httpserver_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}