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) + } +}