Skip to content

Commit 802b1e2

Browse files
committed
feat: support CODER_AGENT_TOKEN from Kubernetes secrets
This change adds support for reading CODER_AGENT_TOKEN from Kubernetes secrets via secretKeyRef, in addition to the existing inline value support. Changes: - Add resolveEnvValue helper function that resolves env var values from either direct values or secretKeyRef references - Update Pod handler to use resolveEnvValue for token resolution - Update ReplicaSet handler to use resolveEnvValue for token resolution - Add comprehensive tests for secretKeyRef functionality The implementation is fully backward compatible: - Existing inline env.Value tokens continue to work unchanged - secretKeyRef support is additive, not a breaking change - Optional secrets that don't exist are handled gracefully - Errors fetching required secrets log warnings and skip the pod Users who want to use secretKeyRef will need to ensure their service account has RBAC permissions to get secrets in the watched namespaces. Fixes #139
1 parent db7bcb7 commit 802b1e2

File tree

3 files changed

+286
-4
lines changed

3 files changed

+286
-4
lines changed

coder-logstream-kube

96.7 MB
Binary file not shown.

logger.go

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/google/uuid"
1414
appsv1 "k8s.io/api/apps/v1"
1515
corev1 "k8s.io/api/core/v1"
16+
k8serrors "k8s.io/apimachinery/pkg/api/errors"
1617
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1718
"k8s.io/client-go/informers"
1819
"k8s.io/client-go/kubernetes"
@@ -117,6 +118,39 @@ type podEventLogger struct {
117118
lq *logQueuer
118119
}
119120

121+
// resolveEnvValue resolves the value of an environment variable, supporting both
122+
// direct values and secretKeyRef references. Returns empty string if the value
123+
// cannot be resolved (e.g., optional secret not found).
124+
func (p *podEventLogger) resolveEnvValue(ctx context.Context, namespace string, env corev1.EnvVar) (string, error) {
125+
// Direct value takes precedence (existing behavior)
126+
if env.Value != "" {
127+
return env.Value, nil
128+
}
129+
130+
// Check for secretKeyRef
131+
if env.ValueFrom != nil && env.ValueFrom.SecretKeyRef != nil {
132+
ref := env.ValueFrom.SecretKeyRef
133+
secret, err := p.client.CoreV1().Secrets(namespace).Get(ctx, ref.Name, v1.GetOptions{})
134+
if err != nil {
135+
// Handle optional secrets gracefully - only ignore NotFound errors
136+
if ref.Optional != nil && *ref.Optional && k8serrors.IsNotFound(err) {
137+
return "", nil
138+
}
139+
return "", fmt.Errorf("get secret %s: %w", ref.Name, err)
140+
}
141+
value, ok := secret.Data[ref.Key]
142+
if !ok {
143+
if ref.Optional != nil && *ref.Optional {
144+
return "", nil
145+
}
146+
return "", fmt.Errorf("secret %s has no key %s", ref.Name, ref.Key)
147+
}
148+
return string(value), nil
149+
}
150+
151+
return "", nil
152+
}
153+
120154
// initNamespace starts the informer factory and registers event handlers for a given namespace.
121155
// If provided namespace is empty, it will start the informer factory and register event handlers for all namespaces.
122156
func (p *podEventLogger) initNamespace(namespace string) error {
@@ -157,15 +191,28 @@ func (p *podEventLogger) initNamespace(namespace string) error {
157191
if env.Name != "CODER_AGENT_TOKEN" {
158192
continue
159193
}
194+
195+
token, err := p.resolveEnvValue(p.ctx, pod.Namespace, env)
196+
if err != nil {
197+
p.logger.Warn(p.ctx, "failed to resolve CODER_AGENT_TOKEN",
198+
slog.F("pod", pod.Name),
199+
slog.F("namespace", pod.Namespace),
200+
slog.Error(err))
201+
continue
202+
}
203+
if token == "" {
204+
continue
205+
}
206+
160207
registered = true
161-
p.tc.setPodToken(pod.Name, env.Value)
208+
p.tc.setPodToken(pod.Name, token)
162209

163210
// We don't want to add logs to workspaces that are already started!
164211
if !pod.CreationTimestamp.After(startTime) {
165212
continue
166213
}
167214

168-
p.sendLog(pod.Name, env.Value, agentsdk.Log{
215+
p.sendLog(pod.Name, token, agentsdk.Log{
169216
CreatedAt: time.Now(),
170217
Output: fmt.Sprintf("🐳 %s: %s", newColor(color.Bold).Sprint("Created pod"), pod.Name),
171218
Level: codersdk.LogLevelInfo,
@@ -218,10 +265,23 @@ func (p *podEventLogger) initNamespace(namespace string) error {
218265
if env.Name != "CODER_AGENT_TOKEN" {
219266
continue
220267
}
268+
269+
token, err := p.resolveEnvValue(p.ctx, replicaSet.Namespace, env)
270+
if err != nil {
271+
p.logger.Warn(p.ctx, "failed to resolve CODER_AGENT_TOKEN",
272+
slog.F("replicaset", replicaSet.Name),
273+
slog.F("namespace", replicaSet.Namespace),
274+
slog.Error(err))
275+
continue
276+
}
277+
if token == "" {
278+
continue
279+
}
280+
221281
registered = true
222-
p.tc.setReplicaSetToken(replicaSet.Name, env.Value)
282+
p.tc.setReplicaSetToken(replicaSet.Name, token)
223283

224-
p.sendLog(replicaSet.Name, env.Value, agentsdk.Log{
284+
p.sendLog(replicaSet.Name, token, agentsdk.Log{
225285
CreatedAt: time.Now(),
226286
Output: fmt.Sprintf("🐳 %s: %s", newColor(color.Bold).Sprint("Queued pod from ReplicaSet"), replicaSet.Name),
227287
Level: codersdk.LogLevelInfo,

logger_test.go

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,228 @@ func TestPodEvents(t *testing.T) {
221221
require.NoError(t, err)
222222
}
223223

224+
func TestPodEventsWithSecretRef(t *testing.T) {
225+
t.Parallel()
226+
227+
api := newFakeAgentAPI(t)
228+
229+
ctx := testutil.Context(t, testutil.WaitShort)
230+
agentURL, err := url.Parse(api.server.URL)
231+
require.NoError(t, err)
232+
namespace := "test-namespace"
233+
234+
// Create the secret first
235+
secret := &corev1.Secret{
236+
ObjectMeta: v1.ObjectMeta{
237+
Name: "agent-token-secret",
238+
Namespace: namespace,
239+
},
240+
Data: map[string][]byte{
241+
"token": []byte("secret-token-value"),
242+
},
243+
}
244+
client := fake.NewSimpleClientset(secret)
245+
246+
cMock := quartz.NewMock(t)
247+
reporter, err := newPodEventLogger(ctx, podEventLoggerOptions{
248+
client: client,
249+
coderURL: agentURL,
250+
namespaces: []string{namespace},
251+
logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
252+
logDebounce: 5 * time.Second,
253+
clock: cMock,
254+
})
255+
require.NoError(t, err)
256+
257+
// Create pod with secretKeyRef for CODER_AGENT_TOKEN
258+
pod := &corev1.Pod{
259+
ObjectMeta: v1.ObjectMeta{
260+
Name: "test-pod-secret",
261+
Namespace: namespace,
262+
CreationTimestamp: v1.Time{
263+
Time: time.Now().Add(time.Hour),
264+
},
265+
},
266+
Spec: corev1.PodSpec{
267+
Containers: []corev1.Container{
268+
{
269+
Env: []corev1.EnvVar{
270+
{
271+
Name: "CODER_AGENT_TOKEN",
272+
ValueFrom: &corev1.EnvVarSource{
273+
SecretKeyRef: &corev1.SecretKeySelector{
274+
LocalObjectReference: corev1.LocalObjectReference{
275+
Name: "agent-token-secret",
276+
},
277+
Key: "token",
278+
},
279+
},
280+
},
281+
},
282+
},
283+
},
284+
},
285+
}
286+
_, err = client.CoreV1().Pods(namespace).Create(ctx, pod, v1.CreateOptions{})
287+
require.NoError(t, err)
288+
289+
source := testutil.RequireRecvCtx(ctx, t, api.logSource)
290+
require.Equal(t, sourceUUID, source.ID)
291+
require.Equal(t, "Kubernetes", source.DisplayName)
292+
require.Equal(t, "/icon/k8s.png", source.Icon)
293+
294+
logs := testutil.RequireRecvCtx(ctx, t, api.logs)
295+
require.Len(t, logs, 1)
296+
require.Contains(t, logs[0].Output, "Created pod")
297+
298+
err = reporter.Close()
299+
require.NoError(t, err)
300+
}
301+
302+
func TestReplicaSetEventsWithSecretRef(t *testing.T) {
303+
t.Parallel()
304+
305+
api := newFakeAgentAPI(t)
306+
307+
ctx := testutil.Context(t, testutil.WaitShort)
308+
agentURL, err := url.Parse(api.server.URL)
309+
require.NoError(t, err)
310+
namespace := "test-namespace"
311+
312+
// Create the secret first
313+
secret := &corev1.Secret{
314+
ObjectMeta: v1.ObjectMeta{
315+
Name: "agent-token-secret",
316+
Namespace: namespace,
317+
},
318+
Data: map[string][]byte{
319+
"token": []byte("secret-token-value"),
320+
},
321+
}
322+
client := fake.NewSimpleClientset(secret)
323+
324+
cMock := quartz.NewMock(t)
325+
reporter, err := newPodEventLogger(ctx, podEventLoggerOptions{
326+
client: client,
327+
coderURL: agentURL,
328+
namespaces: []string{namespace},
329+
logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
330+
logDebounce: 5 * time.Second,
331+
clock: cMock,
332+
})
333+
require.NoError(t, err)
334+
335+
rs := &appsv1.ReplicaSet{
336+
ObjectMeta: v1.ObjectMeta{
337+
Name: "test-rs-secret",
338+
Namespace: namespace,
339+
CreationTimestamp: v1.Time{
340+
Time: time.Now().Add(time.Hour),
341+
},
342+
},
343+
Spec: appsv1.ReplicaSetSpec{
344+
Template: corev1.PodTemplateSpec{
345+
ObjectMeta: v1.ObjectMeta{
346+
Name: "test-pod",
347+
},
348+
Spec: corev1.PodSpec{
349+
Containers: []corev1.Container{{
350+
Env: []corev1.EnvVar{
351+
{
352+
Name: "CODER_AGENT_TOKEN",
353+
ValueFrom: &corev1.EnvVarSource{
354+
SecretKeyRef: &corev1.SecretKeySelector{
355+
LocalObjectReference: corev1.LocalObjectReference{
356+
Name: "agent-token-secret",
357+
},
358+
Key: "token",
359+
},
360+
},
361+
},
362+
},
363+
}},
364+
},
365+
},
366+
},
367+
}
368+
_, err = client.AppsV1().ReplicaSets(namespace).Create(ctx, rs, v1.CreateOptions{})
369+
require.NoError(t, err)
370+
371+
source := testutil.RequireRecvCtx(ctx, t, api.logSource)
372+
require.Equal(t, sourceUUID, source.ID)
373+
require.Equal(t, "Kubernetes", source.DisplayName)
374+
require.Equal(t, "/icon/k8s.png", source.Icon)
375+
376+
logs := testutil.RequireRecvCtx(ctx, t, api.logs)
377+
require.Len(t, logs, 1)
378+
require.Contains(t, logs[0].Output, "Queued pod from ReplicaSet")
379+
380+
err = reporter.Close()
381+
require.NoError(t, err)
382+
}
383+
384+
func TestPodEventsWithOptionalMissingSecret(t *testing.T) {
385+
t.Parallel()
386+
387+
ctx := testutil.Context(t, testutil.WaitShort)
388+
namespace := "test-namespace"
389+
390+
// No secret created - but it's marked as optional
391+
client := fake.NewSimpleClientset()
392+
393+
cMock := quartz.NewMock(t)
394+
reporter, err := newPodEventLogger(ctx, podEventLoggerOptions{
395+
client: client,
396+
coderURL: &url.URL{Scheme: "http", Host: "localhost"},
397+
namespaces: []string{namespace},
398+
logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
399+
logDebounce: 5 * time.Second,
400+
clock: cMock,
401+
})
402+
require.NoError(t, err)
403+
404+
optional := true
405+
pod := &corev1.Pod{
406+
ObjectMeta: v1.ObjectMeta{
407+
Name: "test-pod-optional",
408+
Namespace: namespace,
409+
CreationTimestamp: v1.Time{
410+
Time: time.Now().Add(time.Hour),
411+
},
412+
},
413+
Spec: corev1.PodSpec{
414+
Containers: []corev1.Container{
415+
{
416+
Env: []corev1.EnvVar{
417+
{
418+
Name: "CODER_AGENT_TOKEN",
419+
ValueFrom: &corev1.EnvVarSource{
420+
SecretKeyRef: &corev1.SecretKeySelector{
421+
LocalObjectReference: corev1.LocalObjectReference{
422+
Name: "missing-secret",
423+
},
424+
Key: "token",
425+
Optional: &optional,
426+
},
427+
},
428+
},
429+
},
430+
},
431+
},
432+
},
433+
}
434+
_, err = client.CoreV1().Pods(namespace).Create(ctx, pod, v1.CreateOptions{})
435+
require.NoError(t, err)
436+
437+
// Should not register the pod since the optional secret is missing
438+
// Give it a moment to process
439+
time.Sleep(100 * time.Millisecond)
440+
require.True(t, reporter.tc.isEmpty(), "pod should not be registered when optional secret is missing")
441+
442+
err = reporter.Close()
443+
require.NoError(t, err)
444+
}
445+
224446
func Test_newPodEventLogger_multipleNamespaces(t *testing.T) {
225447
t.Parallel()
226448

0 commit comments

Comments
 (0)