Skip to content

Commit 9e35aad

Browse files
carlydfclaude
andauthored
Read API key from K8s secret on every RPC call (#301)
## Summary Follow-up to #300. Solves #295 more gracefully. - Replaces the static `secret.Data` capture in the `NewAPIKeyDynamicCredentials` closure with a live K8s secret read via a new `fetchAPIKeyFromSecret` helper - A rotated API key now takes effect on the next outgoing Temporal RPC — no permission-denied cycle needed to evict and re-dial the cached client - The k8s client is controller-runtime's cache-backed client, so reads hit the local informer cache (cheap in-memory lookup, not a raw API server call) - `fetchAPIKeyFromSecret` is extracted as a testable method; new test verifies the live-read and rotation behavior directly ## Test plan - [ ] `go test ./internal/controller/clientpool/...` — new `TestFetchAPIKey_CredentialClosureReadsLiveSecret` passes (verifies initial read and post-rotation read return correct values) - [ ] `go test ./internal/controller/...` — existing tests still pass - [ ] `go build ./...` — compiles clean 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent cd5b2d2 commit 9e35aad

2 files changed

Lines changed: 56 additions & 5 deletions

File tree

internal/controller/clientpool/clientpool.go

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ func (cp *ClientPool) fetchClientUsingMTLSSecret(secret corev1.Secret, opts NewC
189189
return &clientOpts, &key, &auth, nil
190190
}
191191

192-
func (cp *ClientPool) fetchClientUsingAPIKeySecret(secret corev1.Secret, opts NewClientOptions) (*sdkclient.Options, *ClientPoolKey, *ClientAuth, error) {
192+
func (cp *ClientPool) fetchClientUsingAPIKeySecret(opts NewClientOptions) (*sdkclient.Options, *ClientPoolKey, *ClientAuth, error) {
193193
clientOpts := sdkclient.Options{
194194
Logger: cp.logger,
195195
HostPort: opts.Spec.HostPort,
@@ -200,8 +200,11 @@ func (cp *ClientPool) fetchClientUsingAPIKeySecret(secret corev1.Secret, opts Ne
200200
},
201201
}
202202

203+
secretName := opts.Spec.APIKeySecretRef.Name
204+
secretKey := opts.Spec.APIKeySecretRef.Key
205+
k8sNamespace := opts.K8sNamespace
203206
clientOpts.Credentials = sdkclient.NewAPIKeyDynamicCredentials(func(ctx context.Context) (string, error) {
204-
return string(secret.Data[opts.Spec.APIKeySecretRef.Key]), nil
207+
return cp.fetchAPIKeyFromSecret(ctx, secretName, k8sNamespace, secretKey)
205208
})
206209

207210
key := ClientPoolKey{
@@ -271,7 +274,7 @@ func (cp *ClientPool) ParseClientSecret(
271274
err := fmt.Errorf("secret %s must be of type kubernetes.io/opaque", secret.Name)
272275
return nil, nil, nil, err
273276
}
274-
return cp.fetchClientUsingAPIKeySecret(secret, opts)
277+
return cp.fetchClientUsingAPIKeySecret(opts)
275278

276279
case AuthModeNoCredentials:
277280
return cp.fetchClientUsingNoCredentials(opts)
@@ -327,6 +330,14 @@ func (cp *ClientPool) Close() {
327330
cp.clients = make(map[ClientPoolKey]ClientInfo)
328331
}
329332

333+
func (cp *ClientPool) fetchAPIKeyFromSecret(ctx context.Context, secretName, k8sNamespace, secretKey string) (string, error) {
334+
var s corev1.Secret
335+
if err := cp.k8sClient.Get(ctx, types.NamespacedName{Name: secretName, Namespace: k8sNamespace}, &s); err != nil {
336+
return "", fmt.Errorf("failed to read API key secret %q: %w", secretName, err)
337+
}
338+
return string(s.Data[secretKey]), nil
339+
}
340+
330341
func calculateCertificateExpirationTime(certBytes []byte, bufferTime time.Duration) (time.Time, error) {
331342
if len(certBytes) == 0 {
332343
return time.Time{}, errors.New("no certificate bytes provided")

internal/controller/clientpool/clientpool_test.go

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import (
2323
sdkclient "go.temporal.io/sdk/client"
2424
corev1 "k8s.io/api/core/v1"
2525
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
26+
"k8s.io/apimachinery/pkg/runtime"
27+
"sigs.k8s.io/controller-runtime/pkg/client/fake"
2628
)
2729

2830
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -235,17 +237,30 @@ func TestFetchMTLS_ValidCert_Succeeds(t *testing.T) {
235237
assert.Len(t, clientOpts.ConnectionOptions.TLS.Certificates, 1)
236238
}
237239

240+
func newTestPoolWithFakeClient(objects ...runtime.Object) *ClientPool {
241+
scheme := runtime.NewScheme()
242+
_ = corev1.AddToScheme(scheme)
243+
k8sClient := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(objects...).Build()
244+
return &ClientPool{
245+
logger: noopLogger{},
246+
clients: make(map[ClientPoolKey]ClientInfo),
247+
k8sClient: k8sClient,
248+
dialFn: sdkclient.Dial,
249+
systemCertPoolFn: x509.SystemCertPool,
250+
}
251+
}
252+
238253
// ─── Tests: fetchClientUsingAPIKeySecret ──────────────────────────────────────
239254

240255
// TestFetchAPIKey_CredentialsAndTLSSet verifies that API key auth sets credentials and
241256
// an empty (non-nil) TLS config, which gRPC requires for TLS transport even with token auth.
242257
func TestFetchAPIKey_CredentialsAndTLSSet(t *testing.T) {
243-
cp := newTestPool()
244258
secret := corev1.Secret{
245259
ObjectMeta: metav1.ObjectMeta{Name: "api-key-secret", Namespace: "test-ns"},
246260
Type: corev1.SecretTypeOpaque,
247261
Data: map[string][]byte{"apikey": []byte("test-api-key-value")},
248262
}
263+
cp := newTestPoolWithFakeClient(&secret)
249264
apiKeySelector := &corev1.SecretKeySelector{
250265
LocalObjectReference: corev1.LocalObjectReference{Name: "api-key-secret"},
251266
Key: "apikey",
@@ -259,7 +274,7 @@ func TestFetchAPIKey_CredentialsAndTLSSet(t *testing.T) {
259274
},
260275
}
261276

262-
clientOpts, key, auth, err := cp.fetchClientUsingAPIKeySecret(secret, opts)
277+
clientOpts, key, auth, err := cp.fetchClientUsingAPIKeySecret(opts)
263278

264279
require.NoError(t, err)
265280
assert.Equal(t, AuthModeAPIKey, key.AuthMode)
@@ -269,6 +284,31 @@ func TestFetchAPIKey_CredentialsAndTLSSet(t *testing.T) {
269284
assert.NotNil(t, clientOpts.ConnectionOptions.TLS, "TLS config must be non-nil for gRPC API key transport")
270285
}
271286

287+
// TestFetchAPIKey_CredentialClosureReadsLiveSecret verifies that fetchAPIKeyFromSecret
288+
// reads from the K8s secret at call time, picking up rotated keys without a client re-dial.
289+
// The credentials closure delegates to fetchAPIKeyFromSecret, so this covers the end-to-end path.
290+
func TestFetchAPIKey_CredentialClosureReadsLiveSecret(t *testing.T) {
291+
secret := corev1.Secret{
292+
ObjectMeta: metav1.ObjectMeta{Name: "api-key-secret", Namespace: "test-ns"},
293+
Type: corev1.SecretTypeOpaque,
294+
Data: map[string][]byte{"apikey": []byte("original-key")},
295+
}
296+
cp := newTestPoolWithFakeClient(&secret)
297+
298+
token, err := cp.fetchAPIKeyFromSecret(context.Background(), "api-key-secret", "test-ns", "apikey")
299+
require.NoError(t, err)
300+
assert.Equal(t, "original-key", token)
301+
302+
// Simulate key rotation by updating the secret in the fake client.
303+
secret.Data["apikey"] = []byte("rotated-key")
304+
require.NoError(t, cp.k8sClient.Update(context.Background(), &secret))
305+
306+
// Next call must return the rotated key without any client eviction or re-dial.
307+
token, err = cp.fetchAPIKeyFromSecret(context.Background(), "api-key-secret", "test-ns", "apikey")
308+
require.NoError(t, err)
309+
assert.Equal(t, "rotated-key", token)
310+
}
311+
272312
// ─── Tests: ParseClientSecret ─────────────────────────────────────────────────
273313

274314
// TestParseClientSecret_OpaqueSecretType verifies that an Opaque secret containing tls.crt

0 commit comments

Comments
 (0)