diff --git a/pkg/cli/environment.go b/pkg/cli/environment.go index 6e09091f1..037699495 100644 --- a/pkg/cli/environment.go +++ b/pkg/cli/environment.go @@ -29,6 +29,7 @@ import ( "os" "strconv" "strings" + "sync" "github.com/spf13/pflag" "k8s.io/cli-runtime/pkg/genericclioptions" @@ -119,6 +120,16 @@ func New() *EnvSettings { } env.Debug, _ = strconv.ParseBool(os.Getenv("HELM_DEBUG")) + // One session ID per settings instance: a CLI invocation creates a single + // EnvSettings, so every Kubernetes client built while running one command + // shares the same session and all of its API requests can be correlated + // in the Kubernetes audit log. SDK clients performing distinct operations + // with separate settings get distinct sessions. Logged once at debug + // level so a user can quote it to their cluster admin, who will see the + // same value in the helm-session request header. + sessionID := kubeenv.NewSessionID() + var logSessionOnce sync.Once + // bind to kubernetes config flags config := &genericclioptions.ConfigFlags{ Namespace: &env.namespace, @@ -134,14 +145,9 @@ func New() *EnvSettings { WrapConfigFn: func(config *rest.Config) *rest.Config { config.Burst = env.BurstLimit config.QPS = env.QPS - // One session ID per client build: a CLI invocation builds one - // client, so all requests from a single command execution share a - // session, while SDK clients building a client per operation get - // distinct sessions. Logged at debug level so a user can quote it - // to their cluster admin, who will see the same value in the - // helm-session request header in the Kubernetes audit log. - sessionID := kubeenv.NewSessionID() - slog.Debug("created Kubernetes client session", "helm-session", sessionID) + logSessionOnce.Do(func() { + slog.Debug("created Kubernetes client session", "helm-session", sessionID) + }) config.Wrap(func(rt http.RoundTripper) http.RoundTripper { return &kubeenv.SessionRoundTripper{ // Session wrapper stays outside the retry wrapper so diff --git a/pkg/cli/environment_test.go b/pkg/cli/environment_test.go index fc71038f8..408649812 100644 --- a/pkg/cli/environment_test.go +++ b/pkg/cli/environment_test.go @@ -17,6 +17,7 @@ limitations under the License. package cli import ( + "net/http" "os" "path/filepath" "strings" @@ -25,8 +26,10 @@ import ( "github.com/spf13/pflag" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "k8s.io/client-go/rest" "helm.sh/helm/v4/internal/version" + "helm.sh/helm/v4/pkg/kubeenv" ) func TestSetNamespace(t *testing.T) { @@ -223,6 +226,69 @@ func TestUserAgentHeaderInK8sRESTClientConfig(t *testing.T) { cleanup := resetEnv() t.Cleanup(cleanup) + t.Setenv("KUBECONFIG", writeTestKubeconfig(t)) + + settings := New() + restConfig, err := settings.RESTClientGetter().ToRESTConfig() + require.NoError(t, err) + + expectedUserAgent := version.GetUserAgent() + assert.Equal(t, expectedUserAgent, restConfig.UserAgent) +} + +func TestSessionIDStableAcrossClientBuilds(t *testing.T) { + cleanup := resetEnv() + t.Cleanup(cleanup) + + t.Setenv("KUBECONFIG", writeTestKubeconfig(t)) + + settings := New() + + // A single command (e.g. helm upgrade) builds several Kubernetes clients; + // every one of them must carry the same session ID. + var ids []string + for i := 0; i < 5; i++ { + restConfig, err := settings.RESTClientGetter().ToRESTConfig() + require.NoError(t, err) + ids = append(ids, sessionIDFromRESTConfig(t, restConfig)) + } + require.NotEmpty(t, ids[0], "session ID must be set on the request") + for _, id := range ids[1:] { + assert.Equal(t, ids[0], id, "all clients built from one settings must share a session") + } + + // A separate settings instance (another command invocation, or an SDK + // client performing a distinct operation) gets a distinct session. + other := New() + otherConfig, err := other.RESTClientGetter().ToRESTConfig() + require.NoError(t, err) + assert.NotEqual(t, ids[0], sessionIDFromRESTConfig(t, otherConfig), + "separate settings must not share a session") +} + +// sessionIDFromRESTConfig applies the config's transport wrappers to a +// capturing RoundTripper and returns the helm-session header that would be +// sent on a real request. +func sessionIDFromRESTConfig(t *testing.T, restConfig *rest.Config) string { + t.Helper() + var got http.Header + capture := roundTripperFunc(func(req *http.Request) (*http.Response, error) { + got = req.Header + return &http.Response{StatusCode: http.StatusOK}, nil + }) + req, err := http.NewRequest(http.MethodGet, "https://127.0.0.1:6443/version", nil) + require.NoError(t, err) + _, err = restConfig.WrapTransport(capture).RoundTrip(req) + require.NoError(t, err) + return got.Get(kubeenv.SessionHeader) +} + +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + +func writeTestKubeconfig(t *testing.T) string { + t.Helper() kubeconfigPath := filepath.Join(t.TempDir(), "config") kubeconfig := `apiVersion: v1 clusters: @@ -243,14 +309,7 @@ users: token: test-token ` require.NoError(t, os.WriteFile(kubeconfigPath, []byte(kubeconfig), 0o600), "failed to create test kubeconfig") - t.Setenv("KUBECONFIG", kubeconfigPath) - - settings := New() - restConfig, err := settings.RESTClientGetter().ToRESTConfig() - require.NoError(t, err) - - expectedUserAgent := version.GetUserAgent() - assert.Equal(t, expectedUserAgent, restConfig.UserAgent) + return kubeconfigPath } func resetEnv() func() { diff --git a/pkg/kubeenv/session.go b/pkg/kubeenv/session.go index 8bef7c1aa..0305e7bd2 100644 --- a/pkg/kubeenv/session.go +++ b/pkg/kubeenv/session.go @@ -44,10 +44,10 @@ func (rt *SessionRoundTripper) RoundTrip(req *http.Request) (*http.Response, err } // NewSessionID generates a fresh 128-bit session identifier. Callers should -// generate one ID per Kubernetes client they build: a Helm CLI invocation -// builds one client, so all requests from a single command execution share a -// session, while SDK clients building a client per operation get distinct -// sessions instead of sharing a process-global one. +// generate one ID per logical operation: the Helm CLI creates a single +// [cli.EnvSettings] per command invocation, so every Kubernetes client built +// while running one command shares a session, while separate settings (e.g. +// SDK clients performing distinct operations) get distinct sessions. func NewSessionID() string { var b [16]byte if _, err := rand.Read(b[:]); err == nil {