Skip to content

Commit 8a6095d

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 8a6095d

File tree

2 files changed

+285
-4
lines changed

2 files changed

+285
-4
lines changed

logger.go

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,39 @@ type podEventLogger struct {
117117
lq *logQueuer
118118
}
119119

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

163209
// We don't want to add logs to workspaces that are already started!
164210
if !pod.CreationTimestamp.After(startTime) {
165211
continue
166212
}
167213

168-
p.sendLog(pod.Name, env.Value, agentsdk.Log{
214+
p.sendLog(pod.Name, token, agentsdk.Log{
169215
CreatedAt: time.Now(),
170216
Output: fmt.Sprintf("🐳 %s: %s", newColor(color.Bold).Sprint("Created pod"), pod.Name),
171217
Level: codersdk.LogLevelInfo,
@@ -218,10 +264,23 @@ func (p *podEventLogger) initNamespace(namespace string) error {
218264
if env.Name != "CODER_AGENT_TOKEN" {
219265
continue
220266
}
267+
268+
token, err := p.resolveEnvValue(p.ctx, replicaSet.Namespace, env)
269+
if err != nil {
270+
p.logger.Warn(p.ctx, "failed to resolve CODER_AGENT_TOKEN",
271+
slog.F("replicaset", replicaSet.Name),
272+
slog.F("namespace", replicaSet.Namespace),
273+
slog.Error(err))
274+
continue
275+
}
276+
if token == "" {
277+
continue
278+
}
279+
221280
registered = true
222-
p.tc.setReplicaSetToken(replicaSet.Name, env.Value)
281+
p.tc.setReplicaSetToken(replicaSet.Name, token)
223282

224-
p.sendLog(replicaSet.Name, env.Value, agentsdk.Log{
283+
p.sendLog(replicaSet.Name, token, agentsdk.Log{
225284
CreatedAt: time.Now(),
226285
Output: fmt.Sprintf("🐳 %s: %s", newColor(color.Bold).Sprint("Queued pod from ReplicaSet"), replicaSet.Name),
227286
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)