From 18191659a48c2c2b819863f2110bf3f1ac7d0ab4 Mon Sep 17 00:00:00 2001 From: Yongjie Gong Date: Wed, 24 Jun 2026 08:13:09 +0000 Subject: [PATCH] fix(version): prevent panic when debug.ReadBuildInfo fails When Helm is built with Bazel or other non-go-build toolchains, runtime/debug.ReadBuildInfo() returns false because no Go module info is embedded. This caused a nil pointer dereference panic when initializing DefaultCapabilities at package load time. The fix addresses two layers: 1. internal/version/clientgo.go: Add defensive nil checks for both the ok flag and the info pointer, as well as a nil Deps guard. Extract ReadBuildInfo as a package variable to enable unit testing of all failure modes. 2. pkg/chart/common/capabilities.go: When K8sIOClientGoModVersion() fails, fall back to the same default Kubernetes version used during testing (v1.20) instead of returning an error that triggers the panic in the DefaultCapabilities initializer. Log a warning via slog so the fallback is observable. Fixes #31904 Signed-off-by: Yongjie Gong --- internal/version/clientgo.go | 12 ++- internal/version/clientgo_test.go | 53 ++++++++++++ pkg/chart/common/capabilities.go | 16 +++- .../common/capabilities_fallback_test.go | 83 +++++++++++++++++++ 4 files changed, 159 insertions(+), 5 deletions(-) create mode 100644 pkg/chart/common/capabilities_fallback_test.go diff --git a/internal/version/clientgo.go b/internal/version/clientgo.go index 50a4fd5cb..b0b7dbc7a 100644 --- a/internal/version/clientgo.go +++ b/internal/version/clientgo.go @@ -24,12 +24,20 @@ import ( _ "k8s.io/client-go/kubernetes" // Force k8s.io/client-go to be included in the build ) +// ReadBuildInfo is a package-level hook for tests to override runtime/debug.ReadBuildInfo. +// It must not be modified by production code +var ReadBuildInfo = debug.ReadBuildInfo + func K8sIOClientGoModVersion() (string, error) { - info, ok := debug.ReadBuildInfo() - if !ok { + info, ok := ReadBuildInfo() + if !ok || info == nil { return "", errors.New("failed to read build info") } + if info.Deps == nil { + return "", errors.New("k8s.io/client-go not found in build info") + } + idx := slices.IndexFunc(info.Deps, func(m *debug.Module) bool { return m.Path == "k8s.io/client-go" }) diff --git a/internal/version/clientgo_test.go b/internal/version/clientgo_test.go index 624c669af..6d81ec017 100644 --- a/internal/version/clientgo_test.go +++ b/internal/version/clientgo_test.go @@ -17,6 +17,7 @@ limitations under the License. package version import ( + "runtime/debug" "testing" "github.com/stretchr/testify/require" @@ -28,3 +29,55 @@ func TestK8sClientGoModVersion(t *testing.T) { _, err := K8sIOClientGoModVersion() require.ErrorContains(t, err, "k8s.io/client-go not found in build info") } + +func TestK8sClientGoModVersion_ReadBuildInfoFalse(t *testing.T) { + // Simulate Bazel builds where ReadBuildInfo returns false + orig := ReadBuildInfo + ReadBuildInfo = func() (*debug.BuildInfo, bool) { + return nil, false + } + t.Cleanup(func() { ReadBuildInfo = orig }) + + _, err := K8sIOClientGoModVersion() + require.ErrorContains(t, err, "failed to read build info") +} + +func TestK8sClientGoModVersion_NilBuildInfo(t *testing.T) { + // Simulate edge case where ok=true but info is nil + orig := ReadBuildInfo + ReadBuildInfo = func() (*debug.BuildInfo, bool) { + return nil, true + } + t.Cleanup(func() { ReadBuildInfo = orig }) + + _, err := K8sIOClientGoModVersion() + require.ErrorContains(t, err, "failed to read build info") +} + +func TestK8sClientGoModVersion_NilDeps(t *testing.T) { + // Simulate Bazel builds with empty build info (no deps) + orig := ReadBuildInfo + ReadBuildInfo = func() (*debug.BuildInfo, bool) { + return &debug.BuildInfo{}, true + } + t.Cleanup(func() { ReadBuildInfo = orig }) + + _, err := K8sIOClientGoModVersion() + require.ErrorContains(t, err, "k8s.io/client-go not found in build info") +} + +func TestK8sClientGoModVersion_WithClientGo(t *testing.T) { + orig := ReadBuildInfo + ReadBuildInfo = func() (*debug.BuildInfo, bool) { + return &debug.BuildInfo{ + Deps: []*debug.Module{ + {Path: "k8s.io/client-go", Version: "v0.31.0"}, + }, + }, true + } + t.Cleanup(func() { ReadBuildInfo = orig }) + + v, err := K8sIOClientGoModVersion() + require.NoError(t, err) + require.Equal(t, "v0.31.0", v) +} diff --git a/pkg/chart/common/capabilities.go b/pkg/chart/common/capabilities.go index 16910acaa..92d678a46 100644 --- a/pkg/chart/common/capabilities.go +++ b/pkg/chart/common/capabilities.go @@ -17,6 +17,7 @@ package common import ( "fmt" + "log/slog" "slices" "strconv" "strings" @@ -37,6 +38,9 @@ const ( kubeVersionMinorTesting = 20 ) +// isTesting is a package-level variable to allow testing the non-testing code path. +var isTesting = testing.Testing + var ( // DefaultVersionSet is the default version set, which includes only Core V1 ("v1"). DefaultVersionSet = allKnownVersions() @@ -146,18 +150,24 @@ func makeDefaultCapabilities() (*Capabilities, error) { // Test builds don't include debug info / module info // (And even if they did, we probably want stable capabilities for tests anyway) // Return a default value for test builds - if testing.Testing() { + if isTesting() { return newCapabilities(kubeVersionMajorTesting, kubeVersionMinorTesting) } vstr, err := helmversion.K8sIOClientGoModVersion() if err != nil { - return nil, fmt.Errorf("failed to retrieve k8s.io/client-go version: %w", err) + // Non-go-build toolchains (Bazel, Nix, etc.) don't embed module info. + // Fall back to the same defaults used during testing rather than panicking. + slog.Warn("unable to determine k8s.io/client-go version from build info, using defaults", + slog.Any("error", err)) + return newCapabilities(kubeVersionMajorTesting, kubeVersionMinorTesting) } v, err := semver.NewVersion(vstr) if err != nil { - return nil, fmt.Errorf("unable to parse k8s.io/client-go version %q: %w", vstr, err) + slog.Warn("unable to parse k8s.io/client-go version, using defaults", + slog.String("version", vstr), slog.Any("error", err)) + return newCapabilities(kubeVersionMajorTesting, kubeVersionMinorTesting) } kubeVersionMajor := v.Major() + 1 diff --git a/pkg/chart/common/capabilities_fallback_test.go b/pkg/chart/common/capabilities_fallback_test.go new file mode 100644 index 000000000..697f111bd --- /dev/null +++ b/pkg/chart/common/capabilities_fallback_test.go @@ -0,0 +1,83 @@ +/* +Copyright The Helm 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 common + +import ( + "runtime/debug" + "testing" + + helmversion "helm.sh/helm/v4/internal/version" +) + +// TestMakeDefaultCapabilities_FallbackOnBuildInfoFailure simulates the Bazel +// build scenario (issue #31904) where debug.ReadBuildInfo() returns false. +// Verifies that makeDefaultCapabilities falls back to defaults instead of +// returning an error that would trigger the panic in DefaultCapabilities. +func TestMakeDefaultCapabilities_FallbackOnBuildInfoFailure(t *testing.T) { + // Override isTesting to return false so we bypass the testing early-return + origIsTesting := isTesting + isTesting = func() bool { return false } + t.Cleanup(func() { isTesting = origIsTesting }) + + // Override ReadBuildInfo to simulate Bazel (returns false) by which go binary doesn't contain build info. + origReadBuildInfo := helmversion.ReadBuildInfo + helmversion.ReadBuildInfo = func() (*debug.BuildInfo, bool) { + return nil, false + } + t.Cleanup(func() { helmversion.ReadBuildInfo = origReadBuildInfo }) + + caps, err := makeDefaultCapabilities() + if err != nil { + t.Fatalf("makeDefaultCapabilities() returned error (would cause panic): %v", err) + } + if caps == nil { + t.Fatal("makeDefaultCapabilities() returned nil capabilities") + } + // Should fall back to testing defaults + if caps.KubeVersion.Major != "1" { + t.Errorf("Expected fallback Major=1, got %q", caps.KubeVersion.Major) + } + if caps.KubeVersion.Minor != "20" { + t.Errorf("Expected fallback Minor=20, got %q", caps.KubeVersion.Minor) + } +} + +// TestMakeDefaultCapabilities_FallbackOnInvalidVersion simulates a scenario +// where ReadBuildInfo succeeds but the client-go version string is unparseable. +func TestMakeDefaultCapabilities_FallbackOnInvalidVersion(t *testing.T) { + origIsTesting := isTesting + isTesting = func() bool { return false } + t.Cleanup(func() { isTesting = origIsTesting }) + + // Return a valid BuildInfo but with an unparseable version + origReadBuildInfo := helmversion.ReadBuildInfo + helmversion.ReadBuildInfo = func() (*debug.BuildInfo, bool) { + return &debug.BuildInfo{ + Deps: []*debug.Module{ + {Path: "k8s.io/client-go", Version: "not-a-semver"}, + }, + }, true + } + t.Cleanup(func() { helmversion.ReadBuildInfo = origReadBuildInfo }) + + caps, err := makeDefaultCapabilities() + if err != nil { + t.Fatalf("makeDefaultCapabilities() returned error (would cause panic): %v", err) + } + if caps.KubeVersion.Major != "1" || caps.KubeVersion.Minor != "20" { + t.Errorf("Expected fallback v1.20, got v%s.%s", caps.KubeVersion.Major, caps.KubeVersion.Minor) + } +}