Skip to content

Commit 2e92dd4

Browse files
authored
feat: support CODER_AGENT_TOKEN from Kubernetes secrets (#144)
* 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 * chore: add secrets RBAC permission to helm chart Required to support reading CODER_AGENT_TOKEN from Kubernetes secrets via secretKeyRef. * test: add integration tests for secretKeyRef support Add integration tests that verify CODER_AGENT_TOKEN can be read from Kubernetes secrets via secretKeyRef for both Pods and ReplicaSets. * chore: add *.test to gitignore
1 parent 8ea3f21 commit 2e92dd4

File tree

5 files changed

+496
-4
lines changed

5 files changed

+496
-4
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
coder-logstream-kube
22
coder-logstream-kube-*
3+
*.test
34
build/

helm/templates/service.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
- apiGroups: [""]
33
resources: ["pods", "events"]
44
verbs: ["get", "watch", "list"]
5+
- apiGroups: [""]
6+
resources: ["secrets"]
7+
verbs: ["get"]
58
- apiGroups: ["apps"]
69
resources: ["replicasets", "events"]
710
verbs: ["get", "watch", "list"]

integration_test.go

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,3 +512,209 @@ func TestIntegration_LabelSelector(t *testing.T) {
512512
require.NotContains(t, log, "test-pod-no-label", "should not receive logs for unlabeled pod")
513513
}
514514
}
515+
516+
func TestIntegration_PodWithSecretRef(t *testing.T) {
517+
t.Parallel()
518+
519+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
520+
defer cancel()
521+
522+
client := getKubeClient(t)
523+
namespace := createTestNamespace(t, ctx, client)
524+
525+
// Create a secret containing the agent token
526+
secret := &corev1.Secret{
527+
ObjectMeta: metav1.ObjectMeta{
528+
Name: "agent-token-secret",
529+
Namespace: namespace,
530+
},
531+
Data: map[string][]byte{
532+
"token": []byte("secret-token-integration"),
533+
},
534+
}
535+
_, err := client.CoreV1().Secrets(namespace).Create(ctx, secret, metav1.CreateOptions{})
536+
require.NoError(t, err)
537+
538+
// Start fake Coder API server
539+
api := newFakeAgentAPI(t)
540+
defer api.server.Close()
541+
542+
agentURL, err := url.Parse(api.server.URL)
543+
require.NoError(t, err)
544+
545+
// Create the pod event logger
546+
reporter, err := newPodEventLogger(ctx, podEventLoggerOptions{
547+
client: client,
548+
coderURL: agentURL,
549+
namespaces: []string{namespace},
550+
logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
551+
logDebounce: 5 * time.Second,
552+
})
553+
require.NoError(t, err)
554+
defer reporter.Close()
555+
556+
// Wait for informers to sync
557+
time.Sleep(1 * time.Second)
558+
559+
// Create a pod with CODER_AGENT_TOKEN from secretKeyRef
560+
pod := &corev1.Pod{
561+
ObjectMeta: metav1.ObjectMeta{
562+
Name: "test-pod-secret",
563+
Namespace: namespace,
564+
},
565+
Spec: corev1.PodSpec{
566+
Containers: []corev1.Container{
567+
{
568+
Name: "test-container",
569+
Image: "busybox:latest",
570+
Command: []string{"sleep", "3600"},
571+
Env: []corev1.EnvVar{
572+
{
573+
Name: "CODER_AGENT_TOKEN",
574+
ValueFrom: &corev1.EnvVarSource{
575+
SecretKeyRef: &corev1.SecretKeySelector{
576+
LocalObjectReference: corev1.LocalObjectReference{
577+
Name: "agent-token-secret",
578+
},
579+
Key: "token",
580+
},
581+
},
582+
},
583+
},
584+
},
585+
},
586+
NodeSelector: map[string]string{
587+
"non-existent-label": "non-existent-value",
588+
},
589+
},
590+
}
591+
592+
_, err = client.CoreV1().Pods(namespace).Create(ctx, pod, metav1.CreateOptions{})
593+
require.NoError(t, err)
594+
595+
// Wait for log source registration
596+
waitForLogSource(t, ctx, api, 30*time.Second)
597+
598+
// Wait for the "Created pod" log
599+
logs, found := waitForLogContaining(t, ctx, api, 30*time.Second, "Created pod")
600+
require.True(t, found, "expected 'Created pod' log, got: %v", logs)
601+
602+
// Delete the pod and verify deletion event
603+
err = client.CoreV1().Pods(namespace).Delete(ctx, pod.Name, metav1.DeleteOptions{})
604+
require.NoError(t, err)
605+
606+
// Wait for the "Deleted pod" log
607+
logs, found = waitForLogContaining(t, ctx, api, 30*time.Second, "Deleted pod")
608+
require.True(t, found, "expected 'Deleted pod' log, got: %v", logs)
609+
}
610+
611+
func TestIntegration_ReplicaSetWithSecretRef(t *testing.T) {
612+
t.Parallel()
613+
614+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
615+
defer cancel()
616+
617+
client := getKubeClient(t)
618+
namespace := createTestNamespace(t, ctx, client)
619+
620+
// Create a secret containing the agent token
621+
secret := &corev1.Secret{
622+
ObjectMeta: metav1.ObjectMeta{
623+
Name: "agent-token-secret",
624+
Namespace: namespace,
625+
},
626+
Data: map[string][]byte{
627+
"token": []byte("secret-token-rs-integration"),
628+
},
629+
}
630+
_, err := client.CoreV1().Secrets(namespace).Create(ctx, secret, metav1.CreateOptions{})
631+
require.NoError(t, err)
632+
633+
// Start fake Coder API server
634+
api := newFakeAgentAPI(t)
635+
defer api.server.Close()
636+
637+
agentURL, err := url.Parse(api.server.URL)
638+
require.NoError(t, err)
639+
640+
// Create the pod event logger
641+
reporter, err := newPodEventLogger(ctx, podEventLoggerOptions{
642+
client: client,
643+
coderURL: agentURL,
644+
namespaces: []string{namespace},
645+
logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
646+
logDebounce: 5 * time.Second,
647+
})
648+
require.NoError(t, err)
649+
defer reporter.Close()
650+
651+
// Wait for informers to sync
652+
time.Sleep(1 * time.Second)
653+
654+
// Create a ReplicaSet with CODER_AGENT_TOKEN from secretKeyRef
655+
replicas := int32(1)
656+
rs := &appsv1.ReplicaSet{
657+
ObjectMeta: metav1.ObjectMeta{
658+
Name: "test-rs-secret",
659+
Namespace: namespace,
660+
},
661+
Spec: appsv1.ReplicaSetSpec{
662+
Replicas: &replicas,
663+
Selector: &metav1.LabelSelector{
664+
MatchLabels: map[string]string{
665+
"app": "test-rs-secret",
666+
},
667+
},
668+
Template: corev1.PodTemplateSpec{
669+
ObjectMeta: metav1.ObjectMeta{
670+
Labels: map[string]string{
671+
"app": "test-rs-secret",
672+
},
673+
},
674+
Spec: corev1.PodSpec{
675+
Containers: []corev1.Container{
676+
{
677+
Name: "test-container",
678+
Image: "busybox:latest",
679+
Command: []string{"sleep", "3600"},
680+
Env: []corev1.EnvVar{
681+
{
682+
Name: "CODER_AGENT_TOKEN",
683+
ValueFrom: &corev1.EnvVarSource{
684+
SecretKeyRef: &corev1.SecretKeySelector{
685+
LocalObjectReference: corev1.LocalObjectReference{
686+
Name: "agent-token-secret",
687+
},
688+
Key: "token",
689+
},
690+
},
691+
},
692+
},
693+
},
694+
},
695+
NodeSelector: map[string]string{
696+
"non-existent-label": "non-existent-value",
697+
},
698+
},
699+
},
700+
},
701+
}
702+
703+
_, err = client.AppsV1().ReplicaSets(namespace).Create(ctx, rs, metav1.CreateOptions{})
704+
require.NoError(t, err)
705+
706+
// Wait for log source registration
707+
waitForLogSource(t, ctx, api, 30*time.Second)
708+
709+
// Wait for the "Queued pod from ReplicaSet" log
710+
logs, found := waitForLogContaining(t, ctx, api, 30*time.Second, "Queued pod from ReplicaSet")
711+
require.True(t, found, "expected 'Queued pod from ReplicaSet' log, got: %v", logs)
712+
713+
// Delete the ReplicaSet
714+
err = client.AppsV1().ReplicaSets(namespace).Delete(ctx, rs.Name, metav1.DeleteOptions{})
715+
require.NoError(t, err)
716+
717+
// Wait for the "Deleted ReplicaSet" log
718+
logs, found = waitForLogContaining(t, ctx, api, 30*time.Second, "Deleted ReplicaSet")
719+
require.True(t, found, "expected 'Deleted ReplicaSet' log, got: %v", logs)
720+
}

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,

0 commit comments

Comments
 (0)