Skip to content
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ jobs:
- name: Set up Helm
uses: azure/setup-helm@v4.2.0
with:
version: v4.1.0
version: v4.1.1

- name: Install unittest plugin
run: |
Expand Down
7 changes: 7 additions & 0 deletions helm/kagent-tools/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ Allows overriding it for multi-namespace deployments in combined charts.
{{- default .Release.Namespace .Values.namespaceOverride | trunc 63 | trimSuffix "-" -}}
{{- end }}

{{/*
Service account name: default when useDefaultServiceAccount is true, otherwise the chart fullname.
*/}}
{{- define "kagent.serviceAccountName" -}}
{{- if .Values.useDefaultServiceAccount }}default{{- else }}{{ include "kagent.fullname" . }}{{- end }}
{{- end }}

{{/*
Watch namespaces - transforms list of namespaces cached by the controller into comma-separated string
Removes duplicates
Expand Down
4 changes: 3 additions & 1 deletion helm/kagent-tools/templates/clusterrole.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{{- if not .Values.useDefaultServiceAccount }}
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
Expand Down Expand Up @@ -26,4 +27,5 @@ rules:
verbs:
- get
- list
- watch
- watch
{{- end }}
4 changes: 3 additions & 1 deletion helm/kagent-tools/templates/clusterrolebinding.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{{- if not .Values.useDefaultServiceAccount }}
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
Expand Down Expand Up @@ -41,4 +42,5 @@ roleRef:
subjects:
- kind: ServiceAccount
name: {{ include "kagent.fullname" . }}
namespace: {{ include "kagent.namespace" . }}
namespace: {{ include "kagent.namespace" . }}
{{- end }}
4 changes: 3 additions & 1 deletion helm/kagent-tools/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ spec:

securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
serviceAccountName: {{ include "kagent.fullname" . }}
serviceAccountName: {{ include "kagent.serviceAccountName" . }}
containers:
- name: tools
command:
Expand Down Expand Up @@ -91,6 +91,8 @@ spec:
value: {{ .Values.otel.tracing.exporter.otlp.timeout | quote }}
- name: OTEL_EXPORTER_OTLP_TRACES_INSECURE
value: {{ .Values.otel.tracing.exporter.otlp.insecure | quote }}
- name: TOKEN_PASSTHROUGH
value: {{ (index .Values.tools "k8s" | default dict).tokenPassthrough | default false | quote }}
{{- with .Values.tools.env }}
{{- toYaml . | nindent 12 }}
{{- end }}
Expand Down
4 changes: 3 additions & 1 deletion helm/kagent-tools/templates/serviceaccount.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
{{- if not .Values.useDefaultServiceAccount }}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "kagent.fullname" . }}
namespace: {{ include "kagent.namespace" . }}
labels:
{{- include "kagent.labels" . | nindent 4 }}
{{- include "kagent.labels" . | nindent 4 }}
{{- end }}
35 changes: 34 additions & 1 deletion helm/kagent-tools/tests/deployment_test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,46 @@ tests:
path: spec.template.spec.containers[0].resources.limits.memory
value: 512Mi

- it: should have correct service account name
- it: should use default service account when useDefaultServiceAccount is true
template: deployment.yaml
set:
useDefaultServiceAccount: true
asserts:
- equal:
path: spec.template.spec.serviceAccountName
value: default

- it: should use dedicated service account when useDefaultServiceAccount is false
template: deployment.yaml
set:
useDefaultServiceAccount: false
asserts:
- equal:
path: spec.template.spec.serviceAccountName
value: RELEASE-NAME

- it: should set token passthrough env when tools.k8s.tokenPassthrough is true
template: deployment.yaml
set:
tools.k8s.tokenPassthrough: true
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: TOKEN_PASSTHROUGH
value: "true"

- it: should set token passthrough env when tools.k8s.tokenPassthrough is false
template: deployment.yaml
set:
tools.k8s.tokenPassthrough: false
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: TOKEN_PASSTHROUGH
value: "false"

- it: should have correct container port
template: deployment.yaml
asserts:
Expand Down
8 changes: 8 additions & 0 deletions helm/kagent-tools/values.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# Default values for kagent
replicaCount: 1

# When true: pods use the default service account and no ClusterRole/ClusterRoleBinding are created.
# When false: a dedicated ServiceAccount and RBAC are created.
useDefaultServiceAccount: false

global:
tag: ""

Expand All @@ -27,6 +31,10 @@ tools:
limits:
cpu: 1000m
memory: 512Mi
k8s:
# When true: a Bearer token in the Authorization header on each request is passed to kubectl; fails if missing
# When false: kubectl uses in-cluster ServiceAccount.
tokenPassthrough: false
prometheus:
url: "prometheus.kagent.svc.cluster.local:9090"
username: ""
Expand Down
7 changes: 4 additions & 3 deletions internal/cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ type DefaultShellExecutor struct{}
func (e *DefaultShellExecutor) Exec(ctx context.Context, command string, args ...string) ([]byte, error) {
log := logger.WithContext(ctx)
startTime := time.Now()
redactedArgs := logger.RedactArgsForLog(args)

log.Info("executing command",
"command", command,
"args", args,
"args", redactedArgs,
)

cmd := exec.CommandContext(ctx, command, args...)
Expand All @@ -34,15 +35,15 @@ func (e *DefaultShellExecutor) Exec(ctx context.Context, command string, args ..
if err != nil {
log.Error("command execution failed",
"command", command,
"args", args,
"args", redactedArgs,
"error", err,
"output", string(output),
"duration", duration.Seconds(),
)
} else {
log.Info("command execution successful",
"command", command,
"args", args,
"args", redactedArgs,
"duration", duration.Seconds(),
)
}
Expand Down
32 changes: 24 additions & 8 deletions internal/commands/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type CommandBuilder struct {
namespace string
context string
kubeconfig string
token string
output string
labels map[string]string
annotations map[string]string
Expand Down Expand Up @@ -120,6 +121,14 @@ func (cb *CommandBuilder) WithKubeconfig(kubeconfig string) *CommandBuilder {
return cb
}

// WithToken sets the authentication token for kubectl commands
func (cb *CommandBuilder) WithToken(token string) *CommandBuilder {
if token != "" {
cb.token = token
}
return cb
}

// WithOutput sets the output format
func (cb *CommandBuilder) WithOutput(output string) *CommandBuilder {
validOutputs := []string{"json", "yaml", "wide", "name", "custom-columns", "custom-columns-file", "go-template", "go-template-file", "jsonpath", "jsonpath-file"}
Expand Down Expand Up @@ -240,6 +249,11 @@ func (cb *CommandBuilder) Build() (string, []string, error) {
args = append(args, "--kubeconfig", cb.kubeconfig)
}

// Add token if specified
if cb.token != "" {
args = append(args, "--token", cb.token)
}

// Add output format
if cb.output != "" {
args = append(args, "--output", cb.output)
Expand Down Expand Up @@ -293,7 +307,7 @@ func (cb *CommandBuilder) Execute(ctx context.Context) (string, error) {
log := logger.WithContext(ctx)
_, span := telemetry.StartSpan(ctx, "commands.execute",
attribute.String("command", cb.command),
attribute.StringSlice("args", cb.args),
attribute.StringSlice("args", logger.RedactArgsForLog(cb.args)),
attribute.Bool("cached", cb.cached),
)
defer span.End()
Expand All @@ -308,14 +322,15 @@ func (cb *CommandBuilder) Execute(ctx context.Context) (string, error) {
return "", err
}

redactedArgs := logger.RedactArgsForLog(args)
span.SetAttributes(
attribute.String("built_command", command),
attribute.StringSlice("built_args", args),
attribute.StringSlice("built_args", redactedArgs),
)

log.Debug("executing command",
"command", command,
"args", args,
"args", redactedArgs,
"cached", cb.cached,
)

Expand Down Expand Up @@ -343,9 +358,10 @@ func (cb *CommandBuilder) Execute(ctx context.Context) (string, error) {

func (cb *CommandBuilder) executeWithCache(ctx context.Context, command string, args []string) (string, error) {
log := logger.WithContext(ctx)
redactedArgs := logger.RedactArgsForLog(args)
_, span := telemetry.StartSpan(ctx, "commands.executeWithCache",
attribute.String("command", command),
attribute.StringSlice("args", args),
attribute.StringSlice("args", redactedArgs),
attribute.Bool("cached", true),
)
defer span.End()
Expand All @@ -357,7 +373,7 @@ func (cb *CommandBuilder) executeWithCache(ctx context.Context, command string,

log.Info("executing cached command",
"command", command,
"args", args,
"args", redactedArgs,
"cache_key", cacheKey,
"cache_ttl", cb.cacheTTL.String(),
)
Expand All @@ -374,7 +390,7 @@ func (cb *CommandBuilder) executeWithCache(ctx context.Context, command string,
telemetry.AddEvent(span, "cache.miss.executing_command")
log.Debug("cache miss, executing command",
"command", command,
"args", args,
"args", redactedArgs,
)
return cb.executeCommand(ctx, command, args)
})
Expand All @@ -383,7 +399,7 @@ func (cb *CommandBuilder) executeWithCache(ctx context.Context, command string,
telemetry.RecordError(span, err, "Cached command execution failed")
log.Error("cached command execution failed",
"command", command,
"args", args,
"args", redactedArgs,
"cache_key", cacheKey,
"error", err,
)
Expand All @@ -393,7 +409,7 @@ func (cb *CommandBuilder) executeWithCache(ctx context.Context, command string,
telemetry.RecordSuccess(span, "Cached command executed successfully")
log.Info("cached command execution successful",
"command", command,
"args", args,
"args", redactedArgs,
"cache_key", cacheKey,
"result_length", len(result),
)
Expand Down
24 changes: 21 additions & 3 deletions internal/logger/logger.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,27 +59,45 @@ func WithContext(ctx context.Context) *slog.Logger {
return logger
}

// RedactArgsForLog returns a copy of args with sensitive values redacted for logging.
// Any value immediately following "--token" is replaced with "<REDACTED>" so tokens are not logged.
func RedactArgsForLog(args []string) []string {
if len(args) == 0 {
return nil
}
out := make([]string, len(args))
copy(out, args)
for i := 0; i < len(out)-1; i++ {
if out[i] == "--token" {
out[i+1] = "<REDACTED>"
i++ // skip the redacted value
}
}
return out
}

func LogExecCommand(ctx context.Context, logger *slog.Logger, command string, args []string, caller string) {
logger.Info("executing command",
"command", command,
"args", args,
"args", RedactArgsForLog(args),
"caller", caller,
)
}

func LogExecCommandResult(ctx context.Context, logger *slog.Logger, command string, args []string, output string, err error, duration float64, caller string) {
redacted := RedactArgsForLog(args)
if err != nil {
logger.Error("command execution failed",
"command", command,
"args", args,
"args", redacted,
"error", err.Error(),
"duration_seconds", duration,
"caller", caller,
)
} else {
logger.Info("command execution successful",
"command", command,
"args", args,
"args", redacted,
"output", output,
"duration_seconds", duration,
"caller", caller,
Expand Down
37 changes: 37 additions & 0 deletions internal/logger/logger_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,43 @@ import (
"go.opentelemetry.io/otel/trace/noop"
)

func TestRedactArgsForLog(t *testing.T) {
t.Run("redacts token value", func(t *testing.T) {
args := []string{"get", "pods", "--token", "secret-token-123", "-n", "default"}
redacted := RedactArgsForLog(args)
require.Len(t, redacted, 6)
assert.Equal(t, "get", redacted[0])
assert.Equal(t, "pods", redacted[1])
assert.Equal(t, "--token", redacted[2])
assert.Equal(t, "<REDACTED>", redacted[3])
assert.Equal(t, "-n", redacted[4])
assert.Equal(t, "default", redacted[5])
})
t.Run("empty args returns nil", func(t *testing.T) {
assert.Nil(t, RedactArgsForLog(nil))
assert.Nil(t, RedactArgsForLog([]string{}))
})
t.Run("args without token unchanged", func(t *testing.T) {
args := []string{"get", "pods", "-n", "default"}
redacted := RedactArgsForLog(args)
assert.Equal(t, args, redacted)
})
t.Run("--token at end with no value", func(t *testing.T) {
args := []string{"get", "pods", "--token"}
redacted := RedactArgsForLog(args)
assert.Equal(t, args, redacted)
})
t.Run("logged output does not contain token", func(t *testing.T) {
var buf bytes.Buffer
log := slog.New(slog.NewTextHandler(&buf, nil))
args := []string{"get", "pods", "--token", "secret-token-123"}
log.Info("executing command", "command", "kubectl", "args", RedactArgsForLog(args))
output := buf.String()
assert.Contains(t, output, "<REDACTED>")
assert.NotContains(t, output, "secret-token-123")
})
}

func TestLogExecCommand(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buf, nil))
Expand Down
Loading