pull/32707/merge
Aniket 1 day ago committed by GitHub
commit a64eca9e68
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -24,10 +24,12 @@ These dependencies are expressed as interfaces so that alternate implementations
package cli
import (
"log/slog"
"net/http"
"os"
"strconv"
"strings"
"sync"
"github.com/spf13/pflag"
"k8s.io/cli-runtime/pkg/genericclioptions"
@ -118,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,
@ -133,8 +145,16 @@ func New() *EnvSettings {
WrapConfigFn: func(config *rest.Config) *rest.Config {
config.Burst = env.BurstLimit
config.QPS = env.QPS
logSessionOnce.Do(func() {
slog.Debug("created Kubernetes client session", "helm-session", sessionID)
})
config.Wrap(func(rt http.RoundTripper) http.RoundTripper {
return &kubeenv.RetryingRoundTripper{Wrapped: rt}
return &kubeenv.SessionRoundTripper{
// Session wrapper stays outside the retry wrapper so
// retries keep the same session ID.
Wrapped: &kubeenv.RetryingRoundTripper{Wrapped: rt},
SessionID: sessionID,
}
})
config.UserAgent = version.GetUserAgent()
return config

@ -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() {

@ -0,0 +1,58 @@
/*
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 kubeenv
import (
"crypto/rand"
"encoding/hex"
"net/http"
"strconv"
"time"
)
// SessionHeader is the HTTP header Helm sends on Kubernetes API requests so
// requests from a single command execution can be correlated for auditing.
const SessionHeader = "helm-session"
// SessionRoundTripper sets the SessionHeader header carrying SessionID on
// every request sent through the wrapped [http.RoundTripper].
type SessionRoundTripper struct {
Wrapped http.RoundTripper
SessionID string
}
// RoundTrip implements [http.RoundTripper].
func (rt *SessionRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
// Clone the request: a RoundTripper must not mutate the request it is given.
r := req.Clone(req.Context())
r.Header.Set(SessionHeader, rt.SessionID)
return rt.Wrapped.RoundTrip(r)
}
// NewSessionID generates a fresh 128-bit session identifier. Callers should
// 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 {
return hex.EncodeToString(b[:])
}
// crypto/rand effectively never fails; fall back to a timestamp-based value.
return "fallback-" + strconv.FormatInt(time.Now().UnixNano(), 16)
}

@ -0,0 +1,93 @@
/*
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 kubeenv
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// captureRoundTripper records the request it receives and returns a canned response.
type captureRoundTripper struct {
got *http.Request
}
func (c *captureRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
c.got = req
return &http.Response{StatusCode: http.StatusOK, Header: http.Header{}}, nil
}
func TestSessionRoundTripper(t *testing.T) {
tests := []struct {
name string
sessionID string
}{
{name: "sets the session header", sessionID: "test-session-id"},
{name: "sets the generated session ID", sessionID: NewSessionID()},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
capture := &captureRoundTripper{}
rt := &SessionRoundTripper{Wrapped: capture, SessionID: tt.sessionID}
req, err := http.NewRequest(http.MethodGet, "https://example.com/api", nil)
require.NoError(t, err)
// A pre-existing header with the same name must be replaced, not duplicated.
req.Header.Set(SessionHeader, "stale")
_, err = rt.RoundTrip(req)
require.NoError(t, err)
require.NotNil(t, capture.got)
assert.Equal(t, tt.sessionID, capture.got.Header.Get(SessionHeader))
assert.Len(t, capture.got.Header.Values(SessionHeader), 1)
// The caller's request must not be mutated.
assert.Equal(t, "stale", req.Header.Get(SessionHeader))
})
}
}
func TestSessionRoundTripper_StableAcrossRequests(t *testing.T) {
capture := &captureRoundTripper{}
rt := &SessionRoundTripper{Wrapped: capture, SessionID: NewSessionID()}
var first string
for range 3 {
req, err := http.NewRequest(http.MethodGet, "https://example.com/api", nil)
require.NoError(t, err)
_, err = rt.RoundTrip(req)
require.NoError(t, err)
got := capture.got.Header.Get(SessionHeader)
require.NotEmpty(t, got)
if first == "" {
first = got
} else {
assert.Equal(t, first, got, "session ID must be stable across requests")
}
}
}
func TestNewSessionID_UniqueAndNonEmpty(t *testing.T) {
id1 := NewSessionID()
id2 := NewSessionID()
assert.NotEmpty(t, id1)
assert.NotEqual(t, id1, id2, "NewSessionID must return a fresh ID per call")
}
Loading…
Cancel
Save