From 24432edc1c21f1c0af66f7d2ff988780b2159de9 Mon Sep 17 00:00:00 2001 From: Ben Jencks Date: Tue, 27 Oct 2026 14:47:41 -0700 Subject: [PATCH] Add apiserver-monitor problem daemon Add a new built-in problem daemon plugin (apiserver-monitor) that checks connectivity to the Kubernetes API server. It specifically creates a new client on every poll in order to test the full stack from TCP connection up. This required moving kube_rest.Config creation from problemclient into pkg/util (util.NewKubeClientConfig) and initializing util.GlobalKubeClientConfig in npdMain for both problemclient and apiserver-monitor to consume. --- CHANGELOG.md | 4 + README.md | 7 + .../node_problem_detector.go | 7 + .../apiserver_monitor_plugin.go | 23 + config/apiserver-monitor.json | 13 + pkg/apiservermonitor/apiserver_monitor.go | 287 +++++++++++ .../apiserver_monitor_test.go | 468 ++++++++++++++++++ pkg/apiservermonitor/config.go | 122 +++++ pkg/apiservermonitor/config_test.go | 195 ++++++++ .../problemclient/problem_client.go | 18 +- .../configs.go => util/kube_client_config.go} | 66 ++- pkg/util/kube_client_config_test.go | 48 ++ 12 files changed, 1227 insertions(+), 31 deletions(-) create mode 100644 cmd/nodeproblemdetector/problemdaemonplugins/apiserver_monitor_plugin.go create mode 100644 config/apiserver-monitor.json create mode 100644 pkg/apiservermonitor/apiserver_monitor.go create mode 100644 pkg/apiservermonitor/apiserver_monitor_test.go create mode 100644 pkg/apiservermonitor/config.go create mode 100644 pkg/apiservermonitor/config_test.go rename pkg/{exporters/k8sexporter/problemclient/configs.go => util/kube_client_config.go} (67%) create mode 100644 pkg/util/kube_client_config_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a901916c..f7541e68d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### Added + +- Add `apiserver-monitor` problem daemon to check connectivity to the Kubernetes API server. + ## [0.8.7] - 2020-02-18 ### Added diff --git a/README.md b/README.md index ced9f66e0..da13c5a96 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ List of supported problem daemons types: | [SystemLogMonitor](https://github.com/kubernetes/node-problem-detector/tree/master/pkg/systemlogmonitor) | KernelDeadlock ReadonlyFilesystem FrequentKubeletRestart FrequentDockerRestart FrequentContainerdRestart | A system log monitor monitors system log and reports problems and metrics according to predefined rules. | [filelog](https://github.com/kubernetes/node-problem-detector/blob/master/config/kernel-monitor-filelog.json), [kmsg](https://github.com/kubernetes/node-problem-detector/blob/master/config/kernel-monitor.json), [kernel](https://github.com/kubernetes/node-problem-detector/blob/master/config/kernel-monitor-counter.json) [abrt](https://github.com/kubernetes/node-problem-detector/blob/master/config/abrt-adaptor.json) [systemd](https://github.com/kubernetes/node-problem-detector/blob/master/config/systemd-monitor-counter.json) | disable_system_log_monitor | [SystemStatsMonitor](https://github.com/kubernetes/node-problem-detector/tree/master/pkg/systemstatsmonitor) | None(Could be added in the future) | A system stats monitor for node-problem-detector to collect various health-related system stats as metrics. See the proposal [here](https://docs.google.com/document/d/1SeaUz6kBavI283Dq8GBpoEUDrHA2a795xtw0OvjM568/edit). | [system-stats-monitor](https://github.com/kubernetes/node-problem-detector/blob/master/config/system-stats-monitor.json) | disable_system_stats_monitor | [CustomPluginMonitor](https://github.com/kubernetes/node-problem-detector/tree/master/pkg/custompluginmonitor) | On-demand(According to users configuration), existing example: NTPProblem | A custom plugin monitor for node-problem-detector to invoke and check various node problems with user-defined check scripts. See the proposal [here](https://docs.google.com/document/d/1jK_5YloSYtboj-DtfjmYKxfNnUxCAvohLnsH5aGCAYQ/edit#). | [example](https://github.com/kubernetes/node-problem-detector/blob/4ad49bbd84b8ced45ac825eac01ec93d9235935e/config/custom-plugin-monitor.json) | disable_custom_plugin_monitor +| [APIServerMonitor](https://github.com/kubernetes/node-problem-detector/tree/master/pkg/apiservermonitor) | APIServerUnreachable | An apiserver monitor for node-problem-detector to check connectivity to the Kubernetes API server. | [apiserver-monitor](https://github.com/kubernetes/node-problem-detector/blob/master/config/apiserver-monitor.json) | disable_apiserver_monitor | [HealthChecker](https://github.com/kubernetes/node-problem-detector/tree/master/pkg/healthchecker) | KubeletUnhealthy ContainerRuntimeUnhealthy| A health checker for node-problem-detector to check kubelet and container runtime health. | [kubelet](https://github.com/kubernetes/node-problem-detector/blob/master/config/health-checker-kubelet.json) [docker](https://github.com/kubernetes/node-problem-detector/blob/master/config/health-checker-docker.json) [containerd](https://github.com/kubernetes/node-problem-detector/blob/master/config/health-checker-containerd.json) | # Exporter @@ -104,6 +105,12 @@ certain backends. Some of them can be disabled at compile-time using a build tag Node problem detector will start a separate custom plugin monitor for each configuration. You can use different custom plugin monitors to monitor different node problems. +#### For APIServer Monitor + +* `--config.apiserver-monitor`: List of paths to apiserver monitor config files, comma-separated, e.g. + [config/apiserver-monitor.json](https://github.com/kubernetes/node-problem-detector/blob/master/config/apiserver-monitor.json). + Node problem detector will start a separate apiserver monitor for each configuration. + #### For Health Checkers diff --git a/cmd/nodeproblemdetector/node_problem_detector.go b/cmd/nodeproblemdetector/node_problem_detector.go index 27cbc8943..3048e64a6 100644 --- a/cmd/nodeproblemdetector/node_problem_detector.go +++ b/cmd/nodeproblemdetector/node_problem_detector.go @@ -32,6 +32,7 @@ import ( "k8s.io/node-problem-detector/pkg/problemdetector" "k8s.io/node-problem-detector/pkg/problemmetrics" "k8s.io/node-problem-detector/pkg/types" + "k8s.io/node-problem-detector/pkg/util" otelutil "k8s.io/node-problem-detector/pkg/util/otel" "k8s.io/node-problem-detector/pkg/version" ) @@ -46,6 +47,12 @@ func npdMain(ctx context.Context, npdo *options.NodeProblemDetectorOptions) erro npdo.SetConfigFromDeprecatedOptionsOrDie() npdo.ValidOrDie() + var err error + util.GlobalKubeClientConfig, err = util.NewKubeClientConfig(npdo.ApiServerOverride, npdo.QPS, npdo.Burst) + if err != nil { + klog.Warningf("Failed to create kube client config: %v", err) + } + // Initialize exporters first to set up the OpenTelemetry readers. defaultExporters := []types.Exporter{} if ke := k8sexporter.NewExporterOrDie(ctx, npdo); ke != nil { diff --git a/cmd/nodeproblemdetector/problemdaemonplugins/apiserver_monitor_plugin.go b/cmd/nodeproblemdetector/problemdaemonplugins/apiserver_monitor_plugin.go new file mode 100644 index 000000000..979ee4917 --- /dev/null +++ b/cmd/nodeproblemdetector/problemdaemonplugins/apiserver_monitor_plugin.go @@ -0,0 +1,23 @@ +//go:build !disable_apiserver_monitor + +/* +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 problemdaemonplugins + +import ( + _ "k8s.io/node-problem-detector/pkg/apiservermonitor" +) diff --git a/config/apiserver-monitor.json b/config/apiserver-monitor.json new file mode 100644 index 000000000..342072efe --- /dev/null +++ b/config/apiserver-monitor.json @@ -0,0 +1,13 @@ +{ + "invokeInterval": "30m", + "timeout": "30s", + "source": "apiserver-monitor", + "metricsReporting": true, + "conditions": [ + { + "type": "APIServerUnreachable", + "reason": "APIServerIsReachable", + "message": "apiserver is reachable" + } + ] +} diff --git a/pkg/apiservermonitor/apiserver_monitor.go b/pkg/apiservermonitor/apiserver_monitor.go new file mode 100644 index 000000000..44ccf6521 --- /dev/null +++ b/pkg/apiservermonitor/apiserver_monitor.go @@ -0,0 +1,287 @@ +/* +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 apiservermonitor + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "slices" + "time" + + utilnet "k8s.io/apimachinery/pkg/util/net" + kube_rest "k8s.io/client-go/rest" + "k8s.io/klog/v2" + + "k8s.io/node-problem-detector/pkg/problemdaemon" + "k8s.io/node-problem-detector/pkg/problemmetrics" + "k8s.io/node-problem-detector/pkg/types" + "k8s.io/node-problem-detector/pkg/util" + "k8s.io/node-problem-detector/pkg/util/tomb" +) + +// APIServerMonitorName is the name of the apiserver problem daemon. +const APIServerMonitorName = "apiserver-monitor" + +type apiServerMonitor struct { + configPath string + config MonitorConfig + kubeConfig *kube_rest.Config + conditions []types.Condition + statusChan chan *types.Status + tomb *tomb.Tomb +} + +func init() { + problemdaemon.Register( + APIServerMonitorName, + types.ProblemDaemonHandler{ + CreateProblemDaemonOrDie: NewAPIServerMonitorOrDie, + CmdOptionDescription: "Set to config file paths.", + }) +} + +// NewAPIServerMonitorOrDie creates a new apiServerMonitor, panics or exits if an error occurs. +func NewAPIServerMonitorOrDie(configPath string) types.Monitor { + a := &apiServerMonitor{ + configPath: configPath, + tomb: tomb.NewTomb(), + } + + f, err := os.ReadFile(configPath) + if err != nil { + klog.Fatalf("Failed to read configuration file %q: %v", configPath, err) + } + if err := json.Unmarshal(f, &a.config); err != nil { + klog.Fatalf("Failed to unmarshal configuration file %q: %v", configPath, err) + } + if err := a.config.ApplyConfiguration(); err != nil { + klog.Fatalf("Failed to apply configuration for %q: %v", configPath, err) + } + if err := a.config.Validate(); err != nil { + klog.Fatalf("Failed to validate apiserver monitor config %+v: %v", a.config, err) + } + + klog.Infof("Finish parsing apiserver monitor config file %s: %+v", a.configPath, a.config) + + if util.GlobalKubeClientConfig == nil { + klog.Fatalf("GlobalKubeClientConfig is not initialized") + } + kubeConfig := kube_rest.CopyConfig(util.GlobalKubeClientConfig) + kubeConfig.Timeout = a.config.Timeout + if kubeConfig.Proxy == nil { + // Explicitly set Proxy so client-go's tlsTransportCache treats this config as + // uncacheable and creates a fresh http.Transport on each RESTClientFor call + // instead of reusing a cached transport and its already-established TCP connection. + kubeConfig.Proxy = http.ProxyFromEnvironment + } + a.kubeConfig = kubeConfig + + // A 1000 size channel should be big enough. + a.statusChan = make(chan *types.Status, 1000) + + if *a.config.EnableMetricsReporting { + initializeProblemMetricsOrDie(a.config.DefaultConditions) + } + return a +} + +// initializeProblemMetricsOrDie creates problem metrics for all configured conditions and sets the initial value to 0. +func initializeProblemMetricsOrDie(conditions []types.Condition) { + if problemmetrics.GlobalProblemMetricsManager == nil { + return + } + for _, cond := range conditions { + reason := cond.Type + if err := problemmetrics.GlobalProblemMetricsManager.SetProblemGauge(cond.Type, reason, false); err != nil { + klog.Fatalf("Failed to initialize problem gauge metrics for problem %q, reason %q: %v", + cond.Type, reason, err) + } + if err := problemmetrics.GlobalProblemMetricsManager.IncrementProblemCounter(reason, 0); err != nil { + klog.Fatalf("Failed to initialize problem counter metrics for %q: %v", reason, err) + } + } +} + +func (a *apiServerMonitor) Start() (<-chan *types.Status, error) { + klog.Infof("Start apiserver monitor %s", a.configPath) + go a.monitorLoop() + return a.statusChan, nil +} + +func (a *apiServerMonitor) Stop() { + klog.Infof("Stop apiserver monitor %s", a.configPath) + a.tomb.Stop() +} + +func (a *apiServerMonitor) monitorLoop() { + defer a.tomb.Done() + + a.initializeConditions() + if *a.config.SkipInitialStatus { + klog.Infof("Skipping sending initial status. Using default conditions: %+v", a.conditions) + } else { + a.sendInitialStatus() + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { + select { + case <-a.tomb.Stopping(): + cancel() + case <-ctx.Done(): + } + }() + + runTicker := time.NewTicker(a.config.InvokeInterval) + defer runTicker.Stop() + + select { + case <-a.tomb.Stopping(): + klog.Infof("APIServer monitor stopped: %s", a.configPath) + return + default: + a.runCheck(ctx) + } + + for { + select { + case <-runTicker.C: + a.runCheck(ctx) + case <-a.tomb.Stopping(): + klog.Infof("APIServer monitor stopped: %s", a.configPath) + return + } + } +} + +func (a *apiServerMonitor) runCheck(ctx context.Context) { + err := a.checkAPIServer(ctx) + select { + case <-a.tomb.Stopping(): + return + default: + } + status := a.generateStatus(err) + klog.V(3).Infof("New status generated: %+v", status) + a.statusChan <- status +} + +func (a *apiServerMonitor) checkAPIServer(ctx context.Context) error { + checkCtx, cancel := context.WithTimeout(ctx, a.config.Timeout) + defer cancel() + + client, err := kube_rest.RESTClientFor(a.kubeConfig) + if err != nil { + return fmt.Errorf("failed to create REST client: %w", err) + } + if client.Client != nil { + defer utilnet.CloseIdleConnectionsFor(client.Client.Transport) + } + return client.Get().AbsPath("/version").Do(checkCtx).Error() +} + +func (a *apiServerMonitor) generateStatus(checkErr error) *types.Status { + timestamp := time.Now() + var activeProblemEvents []types.Event + var inactiveProblemEvents []types.Event + + for i := range a.conditions { + condition := &a.conditions[i] + defaultCondition := a.config.DefaultConditions[i] + + newStatus := types.False + newReason := defaultCondition.Reason + newMessage := defaultCondition.Message + if checkErr != nil { + newStatus = types.True + newReason = condition.Type + newMessage = checkErr.Error() + } + + if condition.Status != newStatus { + condition.Transition = timestamp + condition.Status = newStatus + condition.Reason = newReason + condition.Message = newMessage + + updateEvent := util.GenerateConditionChangeEvent( + condition.Type, + newStatus, + newReason, + newMessage, + timestamp, + ) + if newStatus == types.True { + activeProblemEvents = append(activeProblemEvents, updateEvent) + } else { + inactiveProblemEvents = append(inactiveProblemEvents, updateEvent) + } + } + } + + if *a.config.EnableMetricsReporting && problemmetrics.GlobalProblemMetricsManager != nil { + for _, event := range activeProblemEvents { + if err := problemmetrics.GlobalProblemMetricsManager.IncrementProblemCounter(event.Reason, 1); err != nil { + klog.Errorf("Failed to update problem counter metrics for %q: %v", event.Reason, err) + } + } + for _, condition := range a.conditions { + if err := problemmetrics.GlobalProblemMetricsManager.SetProblemGauge( + condition.Type, condition.Reason, condition.Status == types.True); err != nil { + klog.Errorf("Failed to update problem gauge metrics for problem %q, reason %q: %v", + condition.Type, condition.Reason, err) + } + } + } + + status := &types.Status{ + Source: a.config.Source, + Events: append(activeProblemEvents, inactiveProblemEvents...), + Conditions: slices.Clone(a.conditions), + } + if len(activeProblemEvents) != 0 || len(inactiveProblemEvents) != 0 { + klog.V(0).Infof("New status generated: %+v", status) + } + return status +} + +func (a *apiServerMonitor) sendInitialStatus() { + klog.Infof("Sending initial status for %s with conditions: %+v", a.config.Source, a.conditions) + a.statusChan <- &types.Status{ + Source: a.config.Source, + Conditions: slices.Clone(a.conditions), + } +} + +func (a *apiServerMonitor) initializeConditions() { + a.conditions = initialConditions(a.config.DefaultConditions) + klog.Infof("Initialized conditions for %s: %+v", a.configPath, a.conditions) +} + +func initialConditions(defaults []types.Condition) []types.Condition { + conditions := make([]types.Condition, len(defaults)) + copy(conditions, defaults) + for i := range conditions { + conditions[i].Status = types.False + conditions[i].Transition = time.Now() + } + return conditions +} diff --git a/pkg/apiservermonitor/apiserver_monitor_test.go b/pkg/apiservermonitor/apiserver_monitor_test.go new file mode 100644 index 000000000..0b47c2f99 --- /dev/null +++ b/pkg/apiservermonitor/apiserver_monitor_test.go @@ -0,0 +1,468 @@ +/* +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 apiservermonitor + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "k8s.io/node-problem-detector/pkg/problemdaemon" + "k8s.io/node-problem-detector/pkg/problemmetrics" + "k8s.io/node-problem-detector/pkg/types" + "k8s.io/node-problem-detector/pkg/util" + "k8s.io/node-problem-detector/pkg/util/metrics" + "k8s.io/node-problem-detector/pkg/util/tomb" +) + +const ( + testSource = "apiserver-monitor-test" + testConfigPath = "test-config-path" + testCondition = "APIServerUnreachable" + testConditionOK = "APIServerIsReachable" + testConditionOKMsg = "apiserver is reachable" + testWait = 3 * time.Second +) + +var testEpoch = time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + +func TestRegistration(t *testing.T) { + assert.NotPanics(t, + func() { problemdaemon.GetProblemDaemonHandlerOrDie(APIServerMonitorName) }, + "APIServer monitor failed to register itself as a problem daemon.") +} + +type testOptions struct { + serverURL string + invokeInterval time.Duration + timeout time.Duration + defaultConditions []types.Condition + enableMetricsReporting bool + skipInitialStatus bool +} + +func defaultTestConditions() []types.Condition { + return []types.Condition{ + {Type: testCondition, Reason: testConditionOK, Message: testConditionOKMsg}, + } +} + +func newTestMonitor(t *testing.T, o testOptions) *apiServerMonitor { + t.Helper() + if o.invokeInterval == 0 { + o.invokeInterval = 10 * time.Second + } + if o.timeout == 0 { + o.timeout = 5 * time.Second + } + if len(o.defaultConditions) == 0 { + o.defaultConditions = defaultTestConditions() + } + + cfg := MonitorConfig{ + InvokeIntervalString: o.invokeInterval.String(), + TimeoutString: o.timeout.String(), + Source: testSource, + DefaultConditions: o.defaultConditions, + EnableMetricsReporting: &o.enableMetricsReporting, + SkipInitialStatus: &o.skipInitialStatus, + } + require.NoError(t, cfg.ApplyConfiguration()) + require.NoError(t, cfg.Validate()) + + serverURL := o.serverURL + if serverURL == "" { + serverURL = "http://127.0.0.1:8080" + } + kubeConfig, err := util.NewKubeClientConfig(fmt.Sprintf("%s?inClusterConfig=false", serverURL), 500, 500) + require.NoError(t, err) + kubeConfig.Timeout = cfg.Timeout + if kubeConfig.Proxy == nil { + kubeConfig.Proxy = http.ProxyFromEnvironment + } + + a := &apiServerMonitor{ + configPath: testConfigPath, + config: cfg, + kubeConfig: kubeConfig, + statusChan: make(chan *types.Status, 1000), + tomb: tomb.NewTomb(), + } + a.initializeConditions() + for i := range a.conditions { + a.conditions[i].Transition = testEpoch + } + return a +} + +func stubProblemMetrics(t *testing.T) (*metrics.FakeInt64Metric, *metrics.FakeInt64Metric) { + t.Helper() + original := problemmetrics.GlobalProblemMetricsManager + t.Cleanup(func() { problemmetrics.GlobalProblemMetricsManager = original }) + pmm, counter, gauge := problemmetrics.NewProblemMetricsManagerStub() + problemmetrics.GlobalProblemMetricsManager = pmm + return counter, gauge +} + +func receiveStatus(t *testing.T, ch <-chan *types.Status) *types.Status { + t.Helper() + select { + case status := <-ch: + return status + case <-time.After(testWait): + t.Fatal("timed out waiting for a status") + return nil + } +} + +func requireStop(t *testing.T, a *apiServerMonitor) { + t.Helper() + stopped := make(chan struct{}) + go func() { + a.Stop() + close(stopped) + }() + select { + case <-stopped: + case <-time.After(testWait): + t.Fatal("Stop did not return") + } +} + +func TestCheckAPIServer(t *testing.T) { + var requestedPath atomic.Value + var statusCode atomic.Int32 + var newConnCount atomic.Int32 + statusCode.Store(http.StatusOK) + + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestedPath.Store(r.URL.Path) + code := int(statusCode.Load()) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + if code == http.StatusOK { + _, _ = w.Write([]byte(`{"major":"1","minor":"30"}`)) + } else { + _, _ = w.Write([]byte(`{"kind":"Status","apiVersion":"v1","status":"Failure","message":"internal error","code":500}`)) + } + })) + server.Config.ConnState = func(_ net.Conn, state http.ConnState) { + if state == http.StateNew { + newConnCount.Add(1) + } + } + server.StartTLS() + t.Cleanup(server.Close) + + a := newTestMonitor(t, testOptions{serverURL: server.URL}) + a.kubeConfig.TLSClientConfig.Insecure = true + + err := a.checkAPIServer(context.Background()) + require.NoError(t, err) + assert.Equal(t, "/version", requestedPath.Load()) + assert.Equal(t, int32(1), newConnCount.Load(), "first check should establish a new TCP connection") + + // Second check should establish a fresh TCP connection rather than reusing a cached transport's connection. + err = a.checkAPIServer(context.Background()) + require.NoError(t, err) + assert.Equal(t, int32(2), newConnCount.Load(), "second check should establish a new TCP connection") + + statusCode.Store(http.StatusInternalServerError) + err = a.checkAPIServer(context.Background()) + require.Error(t, err) + assert.Equal(t, int32(3), newConnCount.Load(), "third check should establish a new TCP connection") + + server.Close() + err = a.checkAPIServer(context.Background()) + require.Error(t, err) +} + +func TestGenerateStatusForConditions(t *testing.T) { + type step struct { + checkErr error + wantStatus types.ConditionStatus + wantReason string + wantMessage string + wantEvent bool + } + + errBoom := errors.New("Get \"http://127.0.0.1:8080/version\": dial tcp 127.0.0.1:8080: connect: connection refused") + + testCases := []struct { + name string + steps []step + }{ + { + name: "stays false when healthy, no event", + steps: []step{ + { + checkErr: nil, + wantStatus: types.False, + wantReason: testConditionOK, + wantMessage: testConditionOKMsg, + wantEvent: false, + }, + { + checkErr: nil, + wantStatus: types.False, + wantReason: testConditionOK, + wantMessage: testConditionOKMsg, + wantEvent: false, + }, + }, + }, + { + name: "false to true then back to false restores default", + steps: []step{ + { + checkErr: errBoom, + wantStatus: types.True, + wantReason: testCondition, + wantMessage: errBoom.Error(), + wantEvent: true, + }, + { + checkErr: errBoom, + wantStatus: types.True, + wantReason: testCondition, + wantMessage: errBoom.Error(), + wantEvent: false, + }, + { + checkErr: nil, + wantStatus: types.False, + wantReason: testConditionOK, + wantMessage: testConditionOKMsg, + wantEvent: true, + }, + }, + }, + } + + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + a := newTestMonitor(t, testOptions{}) + for i, s := range test.steps { + got := a.generateStatus(s.checkErr) + + require.Len(t, got.Conditions, 1, "step %d", i) + assert.Equal(t, testSource, got.Source, "step %d", i) + + cond := got.Conditions[0] + assert.Equal(t, testCondition, cond.Type, "step %d", i) + assert.Equal(t, s.wantStatus, cond.Status, "step %d", i) + assert.Equal(t, s.wantReason, cond.Reason, "step %d", i) + assert.Equal(t, s.wantMessage, cond.Message, "step %d", i) + + if !s.wantEvent { + assert.Empty(t, got.Events, "step %d", i) + continue + } + require.Len(t, got.Events, 1, "step %d", i) + event := got.Events[0] + wantSeverity := types.Info + if s.wantStatus == types.True { + wantSeverity = types.Warn + } + assert.Equal(t, wantSeverity, event.Severity, "step %d", i) + want := util.GenerateConditionChangeEvent( + testCondition, s.wantStatus, s.wantReason, s.wantMessage, event.Timestamp) + assert.Equal(t, want, event, "step %d", i) + assert.True(t, cond.Transition.Equal(event.Timestamp), "step %d", i) + assert.False(t, cond.Transition.Equal(testEpoch), "step %d", i) + } + }) + } +} + +func TestGenerateStatusMetrics(t *testing.T) { + errBoom := errors.New("connection refused") + + testCases := []struct { + name string + enableMetricsReporting bool + checkErrs []error + expectedMetrics []metrics.Int64MetricRepresentation + }{ + { + name: "reporting disabled records nothing", + enableMetricsReporting: false, + checkErrs: []error{errBoom}, + expectedMetrics: []metrics.Int64MetricRepresentation{}, + }, + { + name: "problem detected increments counter and raises gauge", + enableMetricsReporting: true, + checkErrs: []error{errBoom}, + expectedMetrics: []metrics.Int64MetricRepresentation{ + {Name: "problem_counter", Labels: map[string]string{"reason": testCondition}, Value: 1}, + {Name: "problem_gauge", Labels: map[string]string{"type": testCondition, "reason": testCondition}, Value: 1}, + }, + }, + { + name: "problem resolved lowers gauge without incrementing counter", + enableMetricsReporting: true, + checkErrs: []error{errBoom, nil}, + expectedMetrics: []metrics.Int64MetricRepresentation{ + {Name: "problem_counter", Labels: map[string]string{"reason": testCondition}, Value: 1}, + {Name: "problem_gauge", Labels: map[string]string{"type": testCondition, "reason": testCondition}, Value: 0}, + {Name: "problem_gauge", Labels: map[string]string{"type": testCondition, "reason": testConditionOK}, Value: 0}, + }, + }, + } + + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + fakeCounter, fakeGauge := stubProblemMetrics(t) + a := newTestMonitor(t, testOptions{ + enableMetricsReporting: test.enableMetricsReporting, + }) + for _, checkErr := range test.checkErrs { + a.generateStatus(checkErr) + } + gotMetrics := append(fakeCounter.ListMetrics(), fakeGauge.ListMetrics()...) + assert.ElementsMatch(t, test.expectedMetrics, gotMetrics) + }) + } +} + +func TestMonitorLoop(t *testing.T) { + var healthy atomic.Bool + healthy.Store(false) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/version" { + w.WriteHeader(http.StatusNotFound) + return + } + if healthy.Load() { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"major":"1","minor":"30"}`)) + return + } + w.WriteHeader(http.StatusServiceUnavailable) + })) + t.Cleanup(server.Close) + + a := newTestMonitor(t, testOptions{ + serverURL: server.URL, + invokeInterval: 50 * time.Millisecond, + timeout: 25 * time.Millisecond, + skipInitialStatus: false, + }) + + statusChan, err := a.Start() + require.NoError(t, err) + + // 1. Initial status is False with no events. + initial := receiveStatus(t, statusChan) + require.Len(t, initial.Conditions, 1) + assert.Equal(t, types.False, initial.Conditions[0].Status) + assert.Empty(t, initial.Events) + + // 2. First check fails -> condition becomes True with a Warn event. + firstCheck := receiveStatus(t, statusChan) + require.Len(t, firstCheck.Conditions, 1) + assert.Equal(t, types.True, firstCheck.Conditions[0].Status) + assert.Equal(t, testCondition, firstCheck.Conditions[0].Reason) + require.Len(t, firstCheck.Events, 1) + assert.Equal(t, types.Warn, firstCheck.Events[0].Severity) + + // 3. Server becomes healthy -> condition returns to False with an Info event. + healthy.Store(true) + var recovered *types.Status + for i := 0; i < 20; i++ { + st := receiveStatus(t, statusChan) + if len(st.Conditions) == 1 && st.Conditions[0].Status == types.False { + recovered = st + break + } + } + require.NotNil(t, recovered, "expected monitor to recover once server returned 200 OK") + assert.Equal(t, testConditionOK, recovered.Conditions[0].Reason) + require.Len(t, recovered.Events, 1) + assert.Equal(t, types.Info, recovered.Events[0].Severity) + + requireStop(t, a) +} + +func TestNewAPIServerMonitorOrDie(t *testing.T) { + fakeCounter, fakeGauge := stubProblemMetrics(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(server.Close) + + originalCfg := util.GlobalKubeClientConfig + t.Cleanup(func() { util.GlobalKubeClientConfig = originalCfg }) + globalCfg, err := util.NewKubeClientConfig(fmt.Sprintf("%s?inClusterConfig=false", server.URL), 123, 456) + require.NoError(t, err) + util.GlobalKubeClientConfig = globalCfg + + raw, err := json.Marshal(map[string]any{ + "invokeInterval": "15s", + "timeout": "4s", + "source": testSource, + "metricsReporting": true, + "conditions": []map[string]any{ + {"type": testCondition, "reason": testConditionOK, "message": testConditionOKMsg}, + }, + }) + require.NoError(t, err) + configPath := filepath.Join(t.TempDir(), "apiserver-monitor.json") + require.NoError(t, os.WriteFile(configPath, raw, 0o600)) + + monitor := NewAPIServerMonitorOrDie(configPath) + require.NotNil(t, monitor) + a, ok := monitor.(*apiServerMonitor) + require.True(t, ok) + + assert.Equal(t, configPath, a.configPath) + assert.Equal(t, testSource, a.config.Source) + assert.Equal(t, 15*time.Second, a.config.InvokeInterval) + assert.Equal(t, 4*time.Second, a.config.Timeout) + require.NotNil(t, a.kubeConfig) + assert.Equal(t, server.URL, a.kubeConfig.Host) + assert.Equal(t, float32(123), a.kubeConfig.QPS) + assert.Equal(t, 456, a.kubeConfig.Burst) + assert.Equal(t, 4*time.Second, a.kubeConfig.Timeout) + assert.NotNil(t, a.kubeConfig.Proxy) + assert.Equal(t, time.Duration(0), util.GlobalKubeClientConfig.Timeout) + assert.Nil(t, util.GlobalKubeClientConfig.Proxy) + assert.NotZero(t, cap(a.statusChan)) + + expectedMetrics := []metrics.Int64MetricRepresentation{ + {Name: "problem_counter", Labels: map[string]string{"reason": testCondition}, Value: 0}, + {Name: "problem_gauge", Labels: map[string]string{"type": testCondition, "reason": testCondition}, Value: 0}, + } + gotMetrics := append(fakeCounter.ListMetrics(), fakeGauge.ListMetrics()...) + assert.ElementsMatch(t, expectedMetrics, gotMetrics) +} diff --git a/pkg/apiservermonitor/config.go b/pkg/apiservermonitor/config.go new file mode 100644 index 000000000..8104c963a --- /dev/null +++ b/pkg/apiservermonitor/config.go @@ -0,0 +1,122 @@ +/* +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 apiservermonitor + +import ( + "fmt" + "time" + + "k8s.io/node-problem-detector/pkg/types" +) + +const ( + defaultInvokeInterval = 10 * time.Second + defaultTimeout = 5 * time.Second + defaultSource = "apiserver-monitor" + + defaultConditionType = "APIServerUnreachable" + defaultConditionReason = "APIServerIsReachable" + defaultConditionMessage = "apiserver is reachable" +) + +var ( + defaultInvokeIntervalString = defaultInvokeInterval.String() + defaultTimeoutString = defaultTimeout.String() + defaultEnableMetricsReporting = true + defaultSkipInitialStatus = false +) + +// MonitorConfig is the configuration of the apiserver monitor. +type MonitorConfig struct { + // InvokeIntervalString is the interval string at which the apiserver check will be invoked. + InvokeIntervalString string `json:"invokeInterval,omitempty"` + // InvokeInterval is the interval at which the apiserver check will be invoked. + InvokeInterval time.Duration `json:"-"` + // TimeoutString is the timeout string for each apiserver check. + TimeoutString string `json:"timeout,omitempty"` + // Timeout is the timeout for each apiserver check. + Timeout time.Duration `json:"-"` + // Source is the source name of the apiserver monitor. + Source string `json:"source,omitempty"` + // DefaultConditions are the default states of all the conditions the apiserver monitor handles. + DefaultConditions []types.Condition `json:"conditions,omitempty"` + // EnableMetricsReporting describes whether to report problems as metrics or not. + EnableMetricsReporting *bool `json:"metricsReporting,omitempty"` + // SkipInitialStatus prevents the first status update with default conditions. + SkipInitialStatus *bool `json:"skipInitialStatus,omitempty"` +} + +// ApplyConfiguration applies default configurations. +func (mc *MonitorConfig) ApplyConfiguration() error { + if mc.InvokeIntervalString == "" { + mc.InvokeIntervalString = defaultInvokeIntervalString + } + if mc.TimeoutString == "" { + mc.TimeoutString = defaultTimeoutString + } + if mc.Source == "" { + mc.Source = defaultSource + } + if len(mc.DefaultConditions) == 0 { + mc.DefaultConditions = []types.Condition{ + { + Type: defaultConditionType, + Reason: defaultConditionReason, + Message: defaultConditionMessage, + }, + } + } + if mc.EnableMetricsReporting == nil { + enableMetricsReporting := defaultEnableMetricsReporting + mc.EnableMetricsReporting = &enableMetricsReporting + } + if mc.SkipInitialStatus == nil { + skipInitialStatus := defaultSkipInitialStatus + mc.SkipInitialStatus = &skipInitialStatus + } + + var err error + mc.InvokeInterval, err = time.ParseDuration(mc.InvokeIntervalString) + if err != nil { + return fmt.Errorf("error in parsing invokeInterval %q: %w", mc.InvokeIntervalString, err) + } + mc.Timeout, err = time.ParseDuration(mc.TimeoutString) + if err != nil { + return fmt.Errorf("error in parsing timeout %q: %w", mc.TimeoutString, err) + } + + return nil +} + +// Validate verifies whether the settings in MonitorConfig are valid. +func (mc *MonitorConfig) Validate() error { + if mc.InvokeInterval <= 0 { + return fmt.Errorf("invokeInterval %v must be above 0s", mc.InvokeInterval) + } + if mc.Timeout <= 0 { + return fmt.Errorf("timeout %v must be above 0s", mc.Timeout) + } + if mc.Timeout > mc.InvokeInterval { + return fmt.Errorf("timeout %v must not be greater than invokeInterval %v", mc.Timeout, mc.InvokeInterval) + } + for _, cond := range mc.DefaultConditions { + if cond.Type == "" { + return fmt.Errorf("condition type must not be empty: %+v", cond) + } + } + return nil +} diff --git a/pkg/apiservermonitor/config_test.go b/pkg/apiservermonitor/config_test.go new file mode 100644 index 000000000..6adcae0e5 --- /dev/null +++ b/pkg/apiservermonitor/config_test.go @@ -0,0 +1,195 @@ +/* +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 apiservermonitor + +import ( + "reflect" + "testing" + "time" + + "k8s.io/node-problem-detector/pkg/types" +) + +func TestApplyConfiguration(t *testing.T) { + enableMetrics := true + disableMetrics := false + skipInitial := false + enableSkipInitial := true + + testCases := []struct { + name string + originalConfig MonitorConfig + wantedConfig MonitorConfig + isError bool + }{ + { + name: "defaults", + originalConfig: MonitorConfig{}, + wantedConfig: MonitorConfig{ + InvokeIntervalString: defaultInvokeIntervalString, + InvokeInterval: defaultInvokeInterval, + TimeoutString: defaultTimeoutString, + Timeout: defaultTimeout, + Source: defaultSource, + DefaultConditions: []types.Condition{ + { + Type: defaultConditionType, + Reason: defaultConditionReason, + Message: defaultConditionMessage, + }, + }, + EnableMetricsReporting: &enableMetrics, + SkipInitialStatus: &skipInitial, + }, + isError: false, + }, + { + name: "custom settings", + originalConfig: MonitorConfig{ + InvokeIntervalString: "30s", + TimeoutString: "10s", + Source: "custom-apiserver-monitor", + DefaultConditions: []types.Condition{ + { + Type: "CustomUnreachable", + Reason: "CustomReachable", + Message: "custom apiserver reachable", + }, + }, + EnableMetricsReporting: &disableMetrics, + SkipInitialStatus: &enableSkipInitial, + }, + wantedConfig: MonitorConfig{ + InvokeIntervalString: "30s", + InvokeInterval: 30 * time.Second, + TimeoutString: "10s", + Timeout: 10 * time.Second, + Source: "custom-apiserver-monitor", + DefaultConditions: []types.Condition{ + { + Type: "CustomUnreachable", + Reason: "CustomReachable", + Message: "custom apiserver reachable", + }, + }, + EnableMetricsReporting: &disableMetrics, + SkipInitialStatus: &enableSkipInitial, + }, + isError: false, + }, + { + name: "invalid invokeInterval", + originalConfig: MonitorConfig{ + InvokeIntervalString: "invalid", + }, + isError: true, + }, + { + name: "invalid timeout", + originalConfig: MonitorConfig{ + TimeoutString: "invalid", + }, + isError: true, + }, + } + + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + err := test.originalConfig.ApplyConfiguration() + if err == nil && test.isError { + t.Errorf("Wanted an error, got nil") + } + if err != nil && !test.isError { + t.Errorf("Wanted nil, got error: %v", err) + } + if !test.isError && !reflect.DeepEqual(test.originalConfig, test.wantedConfig) { + t.Errorf("Wanted: %+v, got: %+v", test.wantedConfig, test.originalConfig) + } + }) + } +} + +func TestValidate(t *testing.T) { + testCases := []struct { + name string + config MonitorConfig + isError bool + }{ + { + name: "valid default config", + config: MonitorConfig{ + InvokeIntervalString: "10s", + TimeoutString: "5s", + }, + isError: false, + }, + { + name: "non-positive invokeInterval", + config: MonitorConfig{ + InvokeIntervalString: "-1s", + TimeoutString: "5s", + }, + isError: true, + }, + { + name: "non-positive timeout", + config: MonitorConfig{ + InvokeIntervalString: "10s", + TimeoutString: "-1s", + }, + isError: true, + }, + { + name: "timeout greater than invokeInterval", + config: MonitorConfig{ + InvokeIntervalString: "5s", + TimeoutString: "10s", + }, + isError: true, + }, + { + name: "empty condition type", + config: MonitorConfig{ + InvokeIntervalString: "10s", + TimeoutString: "5s", + DefaultConditions: []types.Condition{ + { + Type: "", + Reason: "SomeReason", + Message: "some message", + }, + }, + }, + isError: true, + }, + } + + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + if err := test.config.ApplyConfiguration(); err != nil { + t.Fatalf("ApplyConfiguration() unexpected error: %v", err) + } + err := test.config.Validate() + if test.isError && err == nil { + t.Errorf("Validate() = nil, want error for config %+v", test.config) + } + if !test.isError && err != nil { + t.Errorf("Validate() = %v, want nil for config %+v", err, test.config) + } + }) + } +} diff --git a/pkg/exporters/k8sexporter/problemclient/problem_client.go b/pkg/exporters/k8sexporter/problemclient/problem_client.go index 4c4d63182..1079952ec 100644 --- a/pkg/exporters/k8sexporter/problemclient/problem_client.go +++ b/pkg/exporters/k8sexporter/problemclient/problem_client.go @@ -20,9 +20,6 @@ import ( "context" "encoding/json" "fmt" - "net/url" - "os" - "path/filepath" "sync/atomic" v1 "k8s.io/api/core/v1" @@ -37,7 +34,7 @@ import ( "k8s.io/utils/clock" "k8s.io/node-problem-detector/cmd/options" - "k8s.io/node-problem-detector/pkg/version" + "k8s.io/node-problem-detector/pkg/util" ) // Client is the interface of problem client @@ -69,18 +66,11 @@ type nodeProblemClient struct { func NewClientOrDie(npdo *options.NodeProblemDetectorOptions) Client { c := &nodeProblemClient{clock: clock.RealClock{}} - // we have checked it is a valid URI after command line argument is parsed.:) - uri, _ := url.Parse(npdo.ApiServerOverride) - - cfg, err := getKubeClientConfig(uri) - if err != nil { - panic(err) + if util.GlobalKubeClientConfig == nil { + panic("GlobalKubeClientConfig is not initialized") } - cfg.UserAgent = fmt.Sprintf("%s/%s", filepath.Base(os.Args[0]), version.Version()) - cfg.QPS = npdo.QPS - cfg.Burst = npdo.Burst - c.client = clientset.NewForConfigOrDie(cfg).CoreV1() + c.client = clientset.NewForConfigOrDie(util.GlobalKubeClientConfig).CoreV1() c.nodeName = npdo.NodeName c.eventNamespace = npdo.EventNamespace c.nodeRef = getNodeRef(c.eventNamespace, c.nodeName) diff --git a/pkg/exporters/k8sexporter/problemclient/configs.go b/pkg/util/kube_client_config.go similarity index 67% rename from pkg/exporters/k8sexporter/problemclient/configs.go rename to pkg/util/kube_client_config.go index 745e2df24..1e8a549e6 100644 --- a/pkg/exporters/k8sexporter/problemclient/configs.go +++ b/pkg/util/kube_client_config.go @@ -1,41 +1,48 @@ -// Copyright 2014 Google Inc. 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 problemclient +/* +Copyright 2016 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 util import ( "fmt" "net/url" "os" + "path/filepath" "strconv" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/kubernetes/scheme" kube_rest "k8s.io/client-go/rest" kubeClientCmd "k8s.io/client-go/tools/clientcmd" kubeClientCmdApi "k8s.io/client-go/tools/clientcmd/api" + + "k8s.io/node-problem-detector/pkg/version" ) const ( APIVersion = "v1" - defaultKubeletPort = 10255 - defaultKubeletHttps = false defaultUseServiceAccount = false defaultServiceAccountFile = "/var/run/secrets/kubernetes.io/serviceaccount/token" defaultInClusterConfig = true ) +// GlobalKubeClientConfig is the global Kubernetes REST client configuration initialized at startup. +var GlobalKubeClientConfig *kube_rest.Config + func getConfigOverrides(uri *url.URL) (*kubeClientCmd.ConfigOverrides, error) { kubeConfigOverride := kubeClientCmd.ConfigOverrides{ ClusterInfo: kubeClientCmdApi.Cluster{}, @@ -146,3 +153,28 @@ func getKubeClientConfig(uri *url.URL) (*kube_rest.Config, error) { return kubeConfig, nil } + +// NewKubeClientConfig creates a new kube_rest.Config from the given apiserver override URI, QPS, and burst. +func NewKubeClientConfig(apiServerOverride string, qps float32, burst int) (*kube_rest.Config, error) { + uri, err := url.Parse(apiServerOverride) + if err != nil { + return nil, fmt.Errorf("apiserver-override %q is not a valid HTTP URI: %w", apiServerOverride, err) + } + + cfg, err := getKubeClientConfig(uri) + if err != nil { + return nil, err + } + + if cfg.GroupVersion == nil { + cfg.GroupVersion = &schema.GroupVersion{Version: APIVersion} + } + if cfg.NegotiatedSerializer == nil { + cfg.NegotiatedSerializer = scheme.Codecs.WithoutConversion() + } + + cfg.UserAgent = fmt.Sprintf("%s/%s", filepath.Base(os.Args[0]), version.Version()) + cfg.QPS = qps + cfg.Burst = burst + return cfg, nil +} diff --git a/pkg/util/kube_client_config_test.go b/pkg/util/kube_client_config_test.go new file mode 100644 index 000000000..6c7543947 --- /dev/null +++ b/pkg/util/kube_client_config_test.go @@ -0,0 +1,48 @@ +/* +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 util + +import ( + "testing" + + "k8s.io/client-go/rest" +) + +func TestNewKubeClientConfig(t *testing.T) { + cfg, err := NewKubeClientConfig("http://127.0.0.1:8080?inClusterConfig=false", 100, 200) + if err != nil { + t.Fatalf("NewKubeClientConfig returned an error: %v", err) + } + if cfg.Host != "http://127.0.0.1:8080" { + t.Errorf("expected Host %q, got %q", "http://127.0.0.1:8080", cfg.Host) + } + if cfg.QPS != 100 { + t.Errorf("expected QPS 100, got %v", cfg.QPS) + } + if cfg.Burst != 200 { + t.Errorf("expected Burst 200, got %v", cfg.Burst) + } + if cfg.GroupVersion == nil { + t.Error("expected GroupVersion to be set") + } + if cfg.NegotiatedSerializer == nil { + t.Error("expected NegotiatedSerializer to be set") + } + if _, err := rest.RESTClientFor(cfg); err != nil { + t.Errorf("expected rest.RESTClientFor(cfg) to succeed, got: %v", err) + } +}