Skip to content

Commit 83ae4ca

Browse files
EItanyaclaude
andauthored
feat: add --default-agent-pod-labels flag for global agent pod labels (#1534)
## Summary - Adds a new `--default-agent-pod-labels` CLI flag / `DEFAULT_AGENT_POD_LABELS` env var that applies a set of labels to all agent pod templates created by kagent - Labels are specified as comma-separated `key=value` pairs (e.g. `team=platform,environment=production`) - Fully wired through Helm via `controller.agentDeployment.podLabels` map in values.yaml - Label precedence: built-in defaults → global default labels → per-agent `spec.deployment.labels` ## Test plan - [x] Unit tests for `ParseLabels` (8 cases covering valid input, edge cases, and errors) - [x] Unit test for `DEFAULT_AGENT_POD_LABELS` env var loading - [x] Existing translator golden tests pass - [x] 4 new Helm unit tests (labels set, empty, not configured, single label) - [x] Deployed to Kind cluster with `podLabels: {team: platform, environment: production}` — verified all 11 agent pod templates contain both labels - [x] `kubectl get pods -l team=platform` returns all agent pods Comment left by Claude on behalf of @EItanya 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Eitan Yarmush <eitan.yarmush@solo.io> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 27a91cd commit 83ae4ca

File tree

8 files changed

+218
-1
lines changed

8 files changed

+218
-1
lines changed

Makefile

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
# Load local overrides (gitignored) — e.g. KAGENT_HELM_EXTRA_ARGS=-f helm/kagent/values.local.yaml
22
-include .env
3-
export
43

54
# Image configuration
65
DOCKER_REGISTRY ?= localhost:5001

go/core/internal/controller/translator/agent/adk_api_translator.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,10 @@ var DefaultSkillsInitImageConfig = ImageConfig{
111111
// instead of auto-creating a per-agent ServiceAccount.
112112
var DefaultServiceAccountName string
113113

114+
// DefaultAgentPodLabels is a set of labels applied to all agent pod templates.
115+
// Per-agent labels from the Agent CRD spec take precedence over these defaults.
116+
var DefaultAgentPodLabels map[string]string
117+
114118
// TODO(ilackarms): migrate this whole package to pkg/translator
115119
type AgentOutputs = translator.AgentOutputs
116120

go/core/internal/controller/translator/agent/deployments.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ func getDefaultLabels(agentName string, incoming map[string]string) map[string]s
7171
labels.AppPartOf: labels.ManagedByKagent,
7272
labels.AppName: agentName,
7373
}
74+
// Global default labels (from --default-agent-pod-labels flag) override built-in defaults
75+
maps.Copy(defaultLabels, DefaultAgentPodLabels)
76+
// Per-agent labels override global defaults
7477
maps.Copy(defaultLabels, incoming)
7578
return defaultLabels
7679
}

go/core/pkg/app/app.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"net/http/pprof"
2626
"os"
2727
"path/filepath"
28+
"slices"
2829
"strings"
2930
"time"
3031

@@ -177,6 +178,8 @@ func (cfg *Config) SetFlags(commandLine *flag.FlagSet) {
177178
commandLine.StringVar(&agent_translator.DefaultSkillsInitImageConfig.Repository, "skills-init-image-repository", agent_translator.DefaultSkillsInitImageConfig.Repository, "The repository to use for the skills init image.")
178179

179180
commandLine.StringVar(&agent_translator.DefaultServiceAccountName, "default-service-account-name", "", "Global default ServiceAccount name for agent pods. When set, agents without an explicit serviceAccountName will use this instead of creating a per-agent ServiceAccount.")
181+
182+
commandLine.Var(&MapValue{Target: &agent_translator.DefaultAgentPodLabels}, "default-agent-pod-labels", "Comma-separated key=value pairs of labels to apply to all agent pod templates (e.g. 'team=platform,env=prod'). Per-agent labels take precedence.")
180183
}
181184

182185
// LoadFromEnv loads configuration values from environment variables.
@@ -197,6 +200,50 @@ func LoadFromEnv(fs *flag.FlagSet) error {
197200
return loadErr
198201
}
199202

203+
// MapValue implements flag.Value for a map[string]string.
204+
// It parses comma-separated key=value pairs (e.g. "team=platform,env=prod").
205+
type MapValue struct {
206+
Target *map[string]string
207+
}
208+
209+
func (m *MapValue) String() string {
210+
if m.Target == nil || *m.Target == nil {
211+
return ""
212+
}
213+
keys := make([]string, 0, len(*m.Target))
214+
for k := range *m.Target {
215+
keys = append(keys, k)
216+
}
217+
slices.Sort(keys)
218+
pairs := make([]string, 0, len(keys))
219+
for _, k := range keys {
220+
pairs = append(pairs, k+"="+(*m.Target)[k])
221+
}
222+
return strings.Join(pairs, ",")
223+
}
224+
225+
func (m *MapValue) Set(raw string) error {
226+
result := make(map[string]string)
227+
for pair := range strings.SplitSeq(raw, ",") {
228+
pair = strings.TrimSpace(pair)
229+
if pair == "" {
230+
continue
231+
}
232+
k, v, ok := strings.Cut(pair, "=")
233+
if !ok {
234+
return fmt.Errorf("invalid format %q: expected key=value", pair)
235+
}
236+
k = strings.TrimSpace(k)
237+
v = strings.TrimSpace(v)
238+
if k == "" {
239+
return fmt.Errorf("invalid entry: empty key in %q", pair)
240+
}
241+
result[k] = v
242+
}
243+
*m.Target = result
244+
return nil
245+
}
246+
200247
type BootstrapConfig struct {
201248
Ctx context.Context
202249
Manager manager.Manager

go/core/pkg/app/app_test.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,112 @@ func TestDatabaseUrlFileFlag(t *testing.T) {
265265
assert.Equal(t, "/etc/credentials/db-url", cfg.Database.UrlFile)
266266
}
267267

268+
func TestMapValue(t *testing.T) {
269+
tests := []struct {
270+
name string
271+
input string
272+
want map[string]string
273+
wantErr bool
274+
}{
275+
{
276+
name: "single label",
277+
input: "team=platform",
278+
want: map[string]string{"team": "platform"},
279+
},
280+
{
281+
name: "multiple labels",
282+
input: "team=platform,env=prod",
283+
want: map[string]string{"team": "platform", "env": "prod"},
284+
},
285+
{
286+
name: "labels with spaces",
287+
input: " team = platform , env = prod ",
288+
want: map[string]string{"team": "platform", "env": "prod"},
289+
},
290+
{
291+
name: "empty string",
292+
input: "",
293+
want: map[string]string{},
294+
},
295+
{
296+
name: "trailing comma",
297+
input: "team=platform,",
298+
want: map[string]string{"team": "platform"},
299+
},
300+
{
301+
name: "empty value",
302+
input: "team=",
303+
want: map[string]string{"team": ""},
304+
},
305+
{
306+
name: "value containing equals",
307+
input: "annotation=key=value",
308+
want: map[string]string{"annotation": "key=value"},
309+
},
310+
{
311+
name: "missing equals",
312+
input: "teamplatform",
313+
wantErr: true,
314+
},
315+
{
316+
name: "empty key",
317+
input: "=value",
318+
wantErr: true,
319+
},
320+
}
321+
322+
for _, tt := range tests {
323+
t.Run(tt.name, func(t *testing.T) {
324+
var target map[string]string
325+
mv := &MapValue{Target: &target}
326+
err := mv.Set(tt.input)
327+
if (err != nil) != tt.wantErr {
328+
t.Errorf("MapValue.Set() error = %v, wantErr %v", err, tt.wantErr)
329+
return
330+
}
331+
if !tt.wantErr {
332+
assert.Equal(t, tt.want, target)
333+
}
334+
})
335+
}
336+
}
337+
338+
func TestMapValueString(t *testing.T) {
339+
var target map[string]string
340+
mv := &MapValue{Target: &target}
341+
assert.Equal(t, "", mv.String())
342+
343+
target = map[string]string{"team": "platform"}
344+
assert.Equal(t, "team=platform", mv.String())
345+
346+
target = map[string]string{"team": "platform", "env": "prod"}
347+
assert.Equal(t, "env=prod,team=platform", mv.String())
348+
}
349+
350+
func TestMapValueWithLoadFromEnv(t *testing.T) {
351+
t.Setenv("DEFAULT_AGENT_POD_LABELS", "team=platform,env=prod")
352+
353+
var target map[string]string
354+
fs := flag.NewFlagSet("test", flag.ContinueOnError)
355+
fs.Var(&MapValue{Target: &target}, "default-agent-pod-labels", "test flag")
356+
357+
err := LoadFromEnv(fs)
358+
assert.NoError(t, err)
359+
assert.Equal(t, map[string]string{"team": "platform", "env": "prod"}, target)
360+
}
361+
362+
func TestMapValueWithLoadFromEnvEqualsInValue(t *testing.T) {
363+
t.Setenv("DEFAULT_AGENT_POD_LABELS", "token=abc=def,team=platform")
364+
365+
var target map[string]string
366+
fs := flag.NewFlagSet("test", flag.ContinueOnError)
367+
fs.Var(&MapValue{Target: &target}, "default-agent-pod-labels", "test flag")
368+
369+
err := LoadFromEnv(fs)
370+
assert.NoError(t, err)
371+
assert.Equal(t, map[string]string{"token": "abc=def", "team": "platform"}, target)
372+
}
373+
268374
func TestLoadFromEnvIntegration(t *testing.T) {
269375
envVars := map[string]string{
270376
"METRICS_BIND_ADDRESS": ":9090",

helm/kagent/templates/controller-configmap.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,12 @@ data:
6060
{{- if and .Values.controller.agentDeployment .Values.controller.agentDeployment.serviceAccountName (not (eq .Values.controller.agentDeployment.serviceAccountName "")) }}
6161
DEFAULT_SERVICE_ACCOUNT_NAME: {{ .Values.controller.agentDeployment.serviceAccountName | quote }}
6262
{{- end }}
63+
{{- if and .Values.controller.agentDeployment .Values.controller.agentDeployment.podLabels }}
64+
{{- $pairs := list }}
65+
{{- range $k := keys .Values.controller.agentDeployment.podLabels | sortAlpha }}
66+
{{- $pairs = append $pairs (printf "%s=%s" $k (index $.Values.controller.agentDeployment.podLabels $k | toString)) }}
67+
{{- end }}
68+
{{- if $pairs }}
69+
DEFAULT_AGENT_POD_LABELS: {{ join "," $pairs | quote }}
70+
{{- end }}
71+
{{- end }}

helm/kagent/tests/controller-deployment_test.yaml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,47 @@ tests:
139139
- notExists:
140140
path: data.IMAGE_PULL_SECRET
141141

142+
- it: should set DEFAULT_AGENT_POD_LABELS when podLabels are configured
143+
template: controller-configmap.yaml
144+
set:
145+
controller:
146+
agentDeployment:
147+
podLabels:
148+
team: platform
149+
env: prod
150+
asserts:
151+
- equal:
152+
path: data.DEFAULT_AGENT_POD_LABELS
153+
value: "env=prod,team=platform"
154+
155+
- it: should not set DEFAULT_AGENT_POD_LABELS when podLabels are empty
156+
template: controller-configmap.yaml
157+
set:
158+
controller:
159+
agentDeployment:
160+
podLabels: {}
161+
asserts:
162+
- notExists:
163+
path: data.DEFAULT_AGENT_POD_LABELS
164+
165+
- it: should not set DEFAULT_AGENT_POD_LABELS when podLabels are not configured
166+
template: controller-configmap.yaml
167+
asserts:
168+
- notExists:
169+
path: data.DEFAULT_AGENT_POD_LABELS
170+
171+
- it: should set DEFAULT_AGENT_POD_LABELS with single label
172+
template: controller-configmap.yaml
173+
set:
174+
controller:
175+
agentDeployment:
176+
podLabels:
177+
team: platform
178+
asserts:
179+
- equal:
180+
path: data.DEFAULT_AGENT_POD_LABELS
181+
value: "team=platform"
182+
142183
- it: should configure watch namespaces
143184
template: controller-configmap.yaml
144185
set:

helm/kagent/values.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,14 @@ controller:
109109
# Precedence: agent-level serviceAccountName > this default > auto-created SA.
110110
# @default -- "" (auto-create per-agent ServiceAccount)
111111
serviceAccountName: ""
112+
# -- Default labels applied to all agent pod templates.
113+
# Per-agent labels in the Agent CRD take precedence over these defaults.
114+
# @default -- {} (no extra labels)
115+
podLabels: {}
116+
# Example:
117+
# podLabels:
118+
# team: platform
119+
# environment: production
112120
# -- The base URL of the A2A Server endpoint, as advertised to clients.
113121
# @default -- `http://<fullname>-controller.<namespace>.svc.cluster.local:<port>`
114122
a2aBaseUrl: ""

0 commit comments

Comments
 (0)