diff --git a/.github/workflows/helm-chart.yml b/.github/workflows/helm-chart.yml index e9f960d6..0ffb51c5 100644 --- a/.github/workflows/helm-chart.yml +++ b/.github/workflows/helm-chart.yml @@ -755,6 +755,53 @@ jobs: helm lint charts/foreman echo "✅ foreman lint passed" + - name: Install helm-unittest + # Helm v3.13's `helm plugin install` will not skip signature + # verification (no --verify flag in 3.x), and the runner has no + # GPG keyring to verify the upstream release signature against. + # Pull the v1.1.1 plugin tarball directly, unpack into + # $(helm env HELM_PLUGINS), and strip `platformHooks` from + # plugin.yaml — that field was added after 3.13 and v3.13's + # plugin loader rejects unknown YAML keys, so the install hard- + # fails on the next `helm plugin list`. + run: | + set -euo pipefail + HELM_PLUGINS_DIR="$(helm env HELM_PLUGINS)" + PLUGIN_DIR="${HELM_PLUGINS_DIR}/unittest" + rm -rf "${PLUGIN_DIR}" + mkdir -p "${PLUGIN_DIR}" + curl -fsSL \ + https://github.com/helm-unittest/helm-unittest/releases/download/v1.1.1/unittest-1.1.1.tgz \ + | tar -xz -C "${PLUGIN_DIR}" --strip-components=1 + chmod +x "${PLUGIN_DIR}"/untt-* + # Helm 3.13 strict-decodes plugin.yaml and rejects unknown + # keys; v1.1.1 added `platformHooks` (post-install hook map), + # which Helm 3.x's loader has never heard of. Drop the block + # with awk — it lives at the end of the file, indented under + # `platformHooks:`. The `platformCommand` map still picks the + # right binary per OS/arch, so the plugin runs without the + # post-install download hook. + awk ' + /^platformHooks:/ { skip = 1; next } + skip && /^[^ ]/ { skip = 0 } + skip { next } + { print } + ' "${PLUGIN_DIR}/plugin.yaml" > "${PLUGIN_DIR}/plugin.yaml.tmp" + mv "${PLUGIN_DIR}/plugin.yaml.tmp" "${PLUGIN_DIR}/plugin.yaml" + # Trigger Helm's plugin discovery by listing once; HELM_PLUGINS + # is read on every invocation, so this picks the plugin up. + helm plugin list > /dev/null + helm unittest --help > /dev/null + echo "✅ helm-unittest installed" + + - name: Run foreman chart unit tests + # Covers the agents: map multi-fleet (#994) shape plus the legacy + # single-agent backward-compat path. Fails CI on any drift in the + # expected resource names, suffixes, or per-agent field flow. + run: | + helm unittest charts/foreman + echo "✅ foreman chart unit tests passed" + - name: Template foreman with defaults # foreman is a sibling chart to llmkube (no Helm subchart # relationship), so no helm-repo wiring is required to render diff --git a/charts/foreman/templates/NOTES.txt b/charts/foreman/templates/NOTES.txt index be6d706a..9055626e 100644 --- a/charts/foreman/templates/NOTES.txt +++ b/charts/foreman/templates/NOTES.txt @@ -6,25 +6,41 @@ Components: {{- else }} - foreman-operator DISABLED (operator.enabled=false) {{- end }} -{{- if .Values.agent.enabled }} - - foreman-agent Deployment {{ include "foreman.fullname" . }}-agent - mode: {{ .Values.agent.mode }} - roles: {{ join "," .Values.agent.roles }} - {{- if .Values.agent.gateCache.enabled }} - gate cache PVC: {{ include "foreman.gateCache.pvcName" . }} ({{ .Values.agent.gateCache.size }}) +{{- if or .Values.agents (and .Values.agent .Values.agent.enabled) }} + - foreman-agents ({{ len (include "foreman.agents" . | fromYaml) }} fleet{{ if gt (len (include "foreman.agents" . | fromYaml)) 1 }}s{{ end }}): +{{- range $name, $cfg := include "foreman.agents" . | fromYaml }} +{{- if $cfg.enabled }} +{{- $defaults := dict }} +{{- if $.Values.agent }}{{ $defaults = deepCopy $.Values.agent }}{{ end }} +{{- $agent := mustMergeOverwrite $defaults $cfg }} +{{- $ctx := dict "Release" $.Release "Values" $.Values "Chart" $.Chart "agentName" $name "agentConfig" $agent }} + - {{ include "foreman.agent.resourceName" $ctx }} mode: {{ $agent.mode }} roles: {{ join "," $agent.roles }} + {{- if $agent.gateCache.enabled }} + gate cache PVC: {{ include "foreman.gateCache.pvcName" $ctx }} ({{ $agent.gateCache.size }}) {{- end }} +{{- end }} +{{- end }} {{- else }} - foreman-agent DISABLED (agent.enabled=false) {{- end }} Next steps: 1. Watch the operator come up: - kubectl -n {{ include "foreman.namespace" . }} logs -l app.kubernetes.io/component=operator -f -{{- if .Values.agent.enabled }} - 2. Confirm the agent registered itself as a FleetNode: - kubectl get fleetnodes + kubectl -n {{ include "foreman.namespace" . }} logs -l app.kubernetes.io/component=operator -f +{{- if or .Values.agents (and .Values.agent .Values.agent.enabled) }} + 2. Confirm the agent(s) registered themselves as FleetNodes: + kubectl get fleetnodes {{- end }} 3. Apply an Agent CR and an AgenticTask referencing it (examples - under examples/foreman/ in the repo). + under examples/foreman/ in the repo). + +{{- if .Values.agents }} + +You installed foreman in multi-fleet mode (agents: map, #994). To add +another fleet — e.g. a fork-pinned dogfood agent or a per-language +coder pool — extend the agents: map and re-run `helm upgrade`. Each +agent gets its own Deployment, ServiceAccount, ClusterRole, and gate +cache PVC, suffixed with the agent key, so they never collide. +{{- end }} -The Foreman docs live at https://llmkube.com/docs/foreman. +The Foreman docs live at https://llmkube.com/docs/foreman. \ No newline at end of file diff --git a/charts/foreman/templates/_helpers.tpl b/charts/foreman/templates/_helpers.tpl index 558f75c0..bc1944a6 100644 --- a/charts/foreman/templates/_helpers.tpl +++ b/charts/foreman/templates/_helpers.tpl @@ -86,13 +86,54 @@ ServiceAccount name for the foreman-operator. {{- end }} {{/* -ServiceAccount name for the foreman-agent. +ServiceAccount name for the foreman-agent. Expects a context dict with +"agentName" (the entry from .Values.agents, or the legacy sentinel +"_implicit_" when .Values.agent: is used unchanged) and "agentConfig" +(the per-agent values dict). When the sentinel is passed, the legacy +"-agent" name is preserved so an existing install's SA is not +renamed on upgrade; an explicit agents. entry always suffixes the +agent key so multiple agents never collide. */}} {{- define "foreman.agent.serviceAccountName" -}} -{{- if .Values.agent.serviceAccount.create }} -{{- default (printf "%s-agent" (include "foreman.fullname" .)) .Values.agent.serviceAccount.name }} +{{- if .agentConfig.serviceAccount.create }} +{{- if eq .agentName "_implicit_" }} +{{- default (printf "%s-agent" (include "foreman.fullname" .)) .agentConfig.serviceAccount.name }} {{- else }} -{{- default "default" .Values.agent.serviceAccount.name }} +{{- default (printf "%s-%s-agent" (include "foreman.fullname" .) .agentName | trunc 63 | trimSuffix "-") .agentConfig.serviceAccount.name }} +{{- end }} +{{- else }} +{{- default "default" .agentConfig.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Default resource name for one foreman-agent (Deployment / ClusterRole / +ClusterRoleBinding). Same shape as the SA default — "-agent" +for the legacy sentinel and "--agent" for an +explicit agents. entry — but with no per-agent override since the +SA already carries that escape hatch. +*/}} +{{- define "foreman.agent.resourceName" -}} +{{- if eq .agentName "_implicit_" }} +{{- printf "%s-agent" (include "foreman.fullname" .) }} +{{- else }} +{{- printf "%s-%s-agent" (include "foreman.fullname" .) .agentName | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} + +{{/* +The agents map to render. When the user opts into the multi-fleet form +(.Values.agents is set) the map is returned verbatim. Otherwise the +legacy top-level .Values.agent: block is wrapped under a sentinel key +("_implicit_") so a single template path can render both shapes and the +legacy install's resource names are preserved on upgrade (#994). Callers +must not pass "_implicit_" as an explicit agents.* key. +*/}} +{{- define "foreman.agents" -}} +{{- if .Values.agents }} +{{- .Values.agents | toYaml }} +{{- else }} +{{- dict "_implicit_" .Values.agent | toYaml }} {{- end }} {{- end }} @@ -135,10 +176,12 @@ Foreman operator image (registry/repo:tag or registry/repo@digest). {{- end }} {{/* -Foreman agent image (registry/repo:tag or registry/repo@digest). +Foreman agent image (registry/repo:tag or registry/repo@digest). Expects +the per-agent context dict with "agentConfig" (.Values.agents. or +.Values.agent) and the chart context (Release/Values/Chart) at the root. */}} {{- define "foreman.agent.image" -}} -{{- $img := .Values.agent.image -}} +{{- $img := .agentConfig.image -}} {{- $repo := $img.repository -}} {{- if $img.registry -}} {{- $repo = printf "%s/%s" $img.registry $img.repository -}} @@ -152,10 +195,18 @@ Foreman agent image (registry/repo:tag or registry/repo@digest). {{- end }} {{/* -Gate cache PVC name. +Gate cache PVC name for a single agent. Expects the per-agent context +dict; the legacy install (sentinel "_implicit_") keeps "-gate-cache" +so an upgrade does not rename an in-use PVC. Explicit agents. +entries get "--gate-cache" so multiple agents each +own a distinct PVC. Users can still override via .agentConfig.gateCache.name. */}} {{- define "foreman.gateCache.pvcName" -}} -{{- default (printf "%s-gate-cache" (include "foreman.fullname" .)) .Values.agent.gateCache.name }} +{{- $suffix := "" -}} +{{- if ne .agentName "_implicit_" -}} +{{- $suffix = printf "-%s" .agentName -}} +{{- end -}} +{{- default (printf "%s%s-gate-cache" (include "foreman.fullname" .) $suffix) .agentConfig.gateCache.name }} {{- end }} {{/* diff --git a/charts/foreman/templates/agent-deployment.yaml b/charts/foreman/templates/agent-deployment.yaml index 3633897c..a8a98b34 100644 --- a/charts/foreman/templates/agent-deployment.yaml +++ b/charts/foreman/templates/agent-deployment.yaml @@ -1,13 +1,31 @@ -{{- if .Values.agent.enabled }} +{{- range $name, $cfg := include "foreman.agents" . | fromYaml }} +--- +{{- /* Deep-merge the legacy top-level .Values.agent as the per-agent + defaults so users opting into agents: do not have to restate + replicaCount/resources/image/etc. for each entry. A nil .Values.agent + degrades to an empty dict (mustMergeOverwrite rejects nil). The merge + runs BEFORE the enabled gate so a per-agent entry that omits + `enabled` still inherits it from .Values.agent — the gate would + otherwise drop the entry silently (#994 review feedback). */}} +{{- $defaults := dict }} +{{- if $.Values.agent }} + {{- $defaults = deepCopy $.Values.agent }} +{{- end }} +{{- $agent := mustMergeOverwrite $defaults $cfg }} +{{- if $agent.enabled }} +{{- /* Per-agent context: chart root (Release/Values/Chart) + the agent's + name and its resolved (merged) config dict. Passed to helpers that read + .agentConfig / .agentName instead of the legacy .Values.agent.* */}} +{{- $ctx := dict "Release" $.Release "Values" $.Values "Chart" $.Chart "agentName" $name "agentConfig" $agent }} apiVersion: apps/v1 kind: Deployment metadata: - name: {{ include "foreman.fullname" . }}-agent - namespace: {{ include "foreman.namespace" . }} + name: {{ include "foreman.agent.resourceName" $ctx }} + namespace: {{ include "foreman.namespace" $ }} labels: - {{- include "foreman.agent.labels" . | nindent 4 }} + {{- include "foreman.agent.labels" $ | nindent 4 }} spec: - replicas: {{ .Values.agent.replicaCount }} + replicas: {{ $agent.replicaCount }} # Each foreman-agent claims one task at a time. The Recreate # strategy stops a rolling update from briefly running two agents # that race for the same Scheduled tasks. @@ -15,67 +33,70 @@ spec: type: Recreate selector: matchLabels: - {{- include "foreman.agent.labels" . | nindent 6 }} + {{- include "foreman.agent.labels" $ | nindent 6 }} template: metadata: annotations: kubectl.kubernetes.io/default-container: agent - {{- with .Values.agent.podAnnotations }} + {{- with $agent.podAnnotations }} {{- toYaml . | nindent 8 }} {{- end }} labels: - {{- include "foreman.agent.labels" . | nindent 8 }} - {{- with .Values.agent.podLabels }} + {{- include "foreman.agent.labels" $ | nindent 8 }} + {{- with $agent.podLabels }} {{- toYaml . | nindent 8 }} {{- end }} spec: - {{- with .Values.imagePullSecrets }} + {{- with $.Values.imagePullSecrets }} imagePullSecrets: {{- toYaml . | nindent 8 }} {{- end }} - serviceAccountName: {{ include "foreman.agent.serviceAccountName" . }} + serviceAccountName: {{ include "foreman.agent.serviceAccountName" $ctx }} securityContext: - {{- toYaml .Values.agent.podSecurityContext | nindent 8 }} + {{- toYaml $agent.podSecurityContext | nindent 8 }} containers: - name: agent - image: {{ include "foreman.agent.image" . }} - imagePullPolicy: {{ .Values.agent.image.pullPolicy }} + image: {{ include "foreman.agent.image" $ctx }} + imagePullPolicy: {{ $agent.image.pullPolicy }} command: - /foreman-agent args: - - --agent-mode={{ .Values.agent.mode }} - - --roles={{ join "," .Values.agent.roles }} - {{- if .Values.agent.nodeLabels }} + - --agent-mode={{ $agent.mode }} + - --roles={{ join "," $agent.roles }} + {{- if $agent.nodeLabels }} {{- $pairs := list }} - {{- range $k, $v := .Values.agent.nodeLabels }}{{ $pairs = append $pairs (printf "%s=%s" $k $v) }}{{- end }} + {{- range $k, $v := $agent.nodeLabels }}{{ $pairs = append $pairs (printf "%s=%s" $k $v) }}{{- end }} - --node-labels={{ join "," $pairs }} {{- end }} - {{- if .Values.agent.taskNamespace }} - - --task-namespace={{ .Values.agent.taskNamespace }} + {{- if $agent.taskNamespace }} + - --task-namespace={{ $agent.taskNamespace }} {{- end }} {{- /* Always set: the binary default is "foreman-system", a namespace the chart never creates, so gate Jobs would land where the cache PVC does not exist. */}} - - --foreman-namespace={{ .Values.agent.foremanNamespace | default (include "foreman.namespace" .) }} - {{- if .Values.agent.installedModels }} - - --installed-models={{ join "," .Values.agent.installedModels }} + - --foreman-namespace={{ $agent.foremanNamespace | default (include "foreman.namespace" $) }} + {{- if $agent.installedModels }} + - --installed-models={{ join "," $agent.installedModels }} {{- end }} - {{- if .Values.agent.inferenceBaseURLOverride }} - - --inference-base-url-override={{ .Values.agent.inferenceBaseURLOverride }} + {{- if $agent.inferenceBaseURLOverride }} + - --inference-base-url-override={{ $agent.inferenceBaseURLOverride }} {{- end }} - - --workspace-dir={{ .Values.agent.workspaceDir }} - {{- if .Values.agent.gitRemoteURL }} - - --git-remote-url={{ .Values.agent.gitRemoteURL }} + - --workspace-dir={{ $agent.workspaceDir }} + {{- if $agent.gitRemoteURL }} + - --git-remote-url={{ $agent.gitRemoteURL }} {{- end }} - {{- if .Values.agent.commitAuthorName }} - - --commit-author-name={{ .Values.agent.commitAuthorName }} + {{- if $agent.commitAuthorName }} + - --commit-author-name={{ $agent.commitAuthorName }} {{- end }} - {{- if .Values.agent.commitAuthorEmail }} - - --commit-author-email={{ .Values.agent.commitAuthorEmail }} + {{- if $agent.commitAuthorEmail }} + - --commit-author-email={{ $agent.commitAuthorEmail }} {{- end }} - {{- if .Values.coder.gitCredentialsSecret }} - - --coder-git-secret={{ .Values.coder.gitCredentialsSecret }} + {{- /* Per-agent override of the coder git secret (#994). Falls back to the chart-level coder.gitCredentialsSecret so existing installs are unchanged. */}} + {{- $coderSecret := dig "coderGitSecret" "" $agent | default $.Values.coder.gitCredentialsSecret }} + {{- $coderKey := dig "coderGitSecretKey" "" $agent | default $.Values.coder.gitCredentialsSecretKey }} + {{- if $coderSecret }} + - --coder-git-secret={{ $coderSecret }} {{- end }} - {{- if .Values.coder.gitCredentialsSecretKey }} - - --coder-git-secret-key={{ .Values.coder.gitCredentialsSecretKey }} + {{- if $coderKey }} + - --coder-git-secret-key={{ $coderKey }} {{- end }} env: - name: FLEET_NODE_NAME @@ -86,26 +107,26 @@ spec: valueFrom: fieldRef: fieldPath: metadata.namespace - {{- if .Values.agent.githubToken.secretName }} + {{- if $agent.githubToken.secretName }} - name: GITHUB_TOKEN valueFrom: secretKeyRef: - name: {{ .Values.agent.githubToken.secretName }} - key: {{ .Values.agent.githubToken.secretKey }} + name: {{ $agent.githubToken.secretName }} + key: {{ $agent.githubToken.secretKey }} {{- end }} - {{- with .Values.agent.env }} + {{- with $agent.env }} {{- toYaml . | nindent 12 }} {{- end }} securityContext: - {{- toYaml .Values.agent.securityContext | nindent 12 }} + {{- toYaml $agent.securityContext | nindent 12 }} resources: - {{- toYaml .Values.agent.resources | nindent 12 }} + {{- toYaml $agent.resources | nindent 12 }} volumeMounts: - name: workspace mountPath: /workspace - name: tmp mountPath: /tmp - {{- if .Values.agent.gateCache.enabled }} + {{- if $agent.gateCache.enabled }} - name: gate-cache mountPath: /cache {{- end }} @@ -114,22 +135,23 @@ spec: emptyDir: {} - name: tmp emptyDir: {} - {{- if .Values.agent.gateCache.enabled }} + {{- if $agent.gateCache.enabled }} - name: gate-cache persistentVolumeClaim: - claimName: {{ include "foreman.gateCache.pvcName" . }} + claimName: {{ include "foreman.gateCache.pvcName" $ctx }} {{- end }} - {{- with .Values.agent.nodeSelector }} + {{- with $agent.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} {{- end }} - {{- with .Values.agent.affinity }} + {{- with $agent.affinity }} affinity: {{- toYaml . | nindent 8 }} {{- end }} - {{- with .Values.agent.tolerations }} + {{- with $agent.tolerations }} tolerations: {{- toYaml . | nindent 8 }} {{- end }} terminationGracePeriodSeconds: 30 {{- end }} +{{- end }} \ No newline at end of file diff --git a/charts/foreman/templates/agent-rbac.yaml b/charts/foreman/templates/agent-rbac.yaml index 3b4819fd..9cdc3ada 100644 --- a/charts/foreman/templates/agent-rbac.yaml +++ b/charts/foreman/templates/agent-rbac.yaml @@ -1,20 +1,31 @@ -{{- if .Values.agent.enabled }} -{{- if .Values.agent.serviceAccount.create }} +{{- range $name, $cfg := include "foreman.agents" . | fromYaml }} +--- +{{- /* Merge defaults BEFORE the enabled gate so a per-agent entry that + omits `enabled` inherits it from .Values.agent instead of being + dropped silently (#994 review feedback). */}} +{{- $defaults := dict }} +{{- if $.Values.agent }} + {{- $defaults = deepCopy $.Values.agent }} +{{- end }} +{{- $agent := mustMergeOverwrite $defaults $cfg }} +{{- if $agent.enabled }} +{{- $ctx := dict "Release" $.Release "Values" $.Values "Chart" $.Chart "agentName" $name "agentConfig" $agent }} +{{- if $agent.serviceAccount.create }} apiVersion: v1 kind: ServiceAccount metadata: - name: {{ include "foreman.agent.serviceAccountName" . }} - namespace: {{ include "foreman.namespace" . }} + name: {{ include "foreman.agent.serviceAccountName" $ctx }} + namespace: {{ include "foreman.namespace" $ }} labels: - {{- include "foreman.agent.labels" . | nindent 4 }} + {{- include "foreman.agent.labels" $ | nindent 4 }} --- {{- end }} apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: - name: {{ include "foreman.fullname" . }}-agent + name: {{ include "foreman.agent.resourceName" $ctx }} labels: - {{- include "foreman.agent.labels" . | nindent 4 }} + {{- include "foreman.agent.labels" $ | nindent 4 }} rules: # FleetNode self-registration + heartbeat. - apiGroups: ["foreman.llmkube.dev"] @@ -105,15 +116,16 @@ rules: apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: - name: {{ include "foreman.fullname" . }}-agent + name: {{ include "foreman.agent.resourceName" $ctx }} labels: - {{- include "foreman.agent.labels" . | nindent 4 }} + {{- include "foreman.agent.labels" $ | nindent 4 }} roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole - name: {{ include "foreman.fullname" . }}-agent + name: {{ include "foreman.agent.resourceName" $ctx }} subjects: - kind: ServiceAccount - name: {{ include "foreman.agent.serviceAccountName" . }} - namespace: {{ include "foreman.namespace" . }} + name: {{ include "foreman.agent.serviceAccountName" $ctx }} + namespace: {{ include "foreman.namespace" $ }} {{- end }} +{{- end }} \ No newline at end of file diff --git a/charts/foreman/templates/gate-cache-pvc.yaml b/charts/foreman/templates/gate-cache-pvc.yaml index 52ad7583..1ad6e10e 100644 --- a/charts/foreman/templates/gate-cache-pvc.yaml +++ b/charts/foreman/templates/gate-cache-pvc.yaml @@ -1,22 +1,37 @@ -{{- if and .Values.agent.enabled .Values.agent.gateCache.enabled }} +{{- range $name, $cfg := include "foreman.agents" . | fromYaml }} +--- +{{- /* Merge defaults BEFORE the enabled gate so a per-agent entry that + omits `enabled` inherits it from .Values.agent instead of being + dropped silently (#994 review feedback). */}} +{{- $defaults := dict }} +{{- if $.Values.agent }} + {{- $defaults = deepCopy $.Values.agent }} +{{- end }} +{{- $agent := mustMergeOverwrite $defaults $cfg }} +{{- if $agent.enabled }} +{{- if $agent.gateCache.enabled }} +{{- $ctx := dict "Release" $.Release "Values" $.Values "Chart" $.Chart "agentName" $name "agentConfig" $agent }} apiVersion: v1 kind: PersistentVolumeClaim metadata: - name: {{ include "foreman.gateCache.pvcName" . }} - namespace: {{ include "foreman.namespace" . }} + name: {{ include "foreman.gateCache.pvcName" $ctx }} + namespace: {{ include "foreman.namespace" $ }} labels: - {{- include "foreman.agent.labels" . | nindent 4 }} - {{- with .Values.agent.gateCache.annotations }} + {{- include "foreman.agent.labels" $ | nindent 4 }} + {{- with $agent.gateCache.annotations }} annotations: {{- toYaml . | nindent 4 }} {{- end }} spec: accessModes: - {{- toYaml .Values.agent.gateCache.accessModes | nindent 4 }} + {{- toYaml $agent.gateCache.accessModes | nindent 4 }} resources: requests: - storage: {{ .Values.agent.gateCache.size }} - {{- with .Values.agent.gateCache.storageClass }} + storage: {{ $agent.gateCache.size }} + {{- with $agent.gateCache.storageClass }} storageClassName: {{ . }} {{- end }} +--- +{{- end }} {{- end }} +{{- end }} \ No newline at end of file diff --git a/charts/foreman/tests/agents_test.yaml b/charts/foreman/tests/agents_test.yaml new file mode 100644 index 00000000..82ace271 --- /dev/null +++ b/charts/foreman/tests/agents_test.yaml @@ -0,0 +1,260 @@ +suite: agents map multi-fleet (#994) +templates: + - agent-deployment.yaml + - agent-rbac.yaml + - gate-cache-pvc.yaml + - operator-deployment.yaml + - operator-rbac.yaml + - coder-rbac.yaml + - webhook.yaml + +tests: + # ---- Backward compat: legacy agent: block ---- + + - it: legacy agent renders exactly one deployment with the unsuffixed name + template: agent-deployment.yaml + set: + agent.enabled: true + asserts: + - hasDocuments: + count: 1 + - equal: + path: metadata.name + value: RELEASE-NAME-foreman-agent + - equal: + path: spec.replicas + value: 1 + + - it: legacy agent renders SA plus ClusterRole plus ClusterRoleBinding + template: agent-rbac.yaml + set: + agent.enabled: true + asserts: + - hasDocuments: + count: 3 + - equal: + path: metadata.name + value: RELEASE-NAME-foreman-agent + + - it: legacy agent renders exactly one gate-cache PVC with the unsuffixed name + template: gate-cache-pvc.yaml + set: + agent.enabled: true + agent.gateCache.enabled: true + asserts: + - hasDocuments: + count: 1 + - equal: + path: metadata.name + value: RELEASE-NAME-foreman-gate-cache + + # ---- Multi-fleet: agents: map ---- + + - it: one deployment is rendered per explicit agents entry + template: agent-deployment.yaml + values: + - ./values-multi.yaml + documentSelector: + path: metadata.name + value: RELEASE-NAME-foreman-default-agent + matchMany: false + asserts: + - equal: + path: metadata.name + value: RELEASE-NAME-foreman-default-agent + + - it: llmkube-fork agent gets its own suffixed deployment + template: agent-deployment.yaml + values: + - ./values-multi.yaml + documentSelector: + path: metadata.name + value: RELEASE-NAME-foreman-llmkube-fork-agent + matchMany: false + asserts: + - equal: + path: metadata.name + value: RELEASE-NAME-foreman-llmkube-fork-agent + + - it: SA plus ClusterRole plus ClusterRoleBinding per agent (nine docs total) + template: agent-rbac.yaml + values: + - ./values-multi.yaml + asserts: + # default + llmkube-fork + omitted-enabled each render 3 docs + # (SA + ClusterRole + ClusterRoleBinding); disabled-agent skips. + - hasDocuments: + count: 9 + + - it: gate-cache PVC per agent whose gateCache.enabled is true + template: gate-cache-pvc.yaml + values: + - ./values-multi.yaml + asserts: + # default + omitted-enabled both keep gateCache enabled + # (omitted-enabled inherits from .Values.agent); llmkube-fork + # disables it explicitly. Two PVCs expected. + - hasDocuments: + count: 2 + + - it: default agent renders its own gate-cache PVC + template: gate-cache-pvc.yaml + values: + - ./values-multi.yaml + documentSelector: + path: metadata.name + value: RELEASE-NAME-foreman-default-gate-cache + matchMany: false + asserts: + - equal: + path: metadata.name + value: RELEASE-NAME-foreman-default-gate-cache + + - it: omitted-enabled agent inherits gateCache from defaults and renders its PVC + template: gate-cache-pvc.yaml + values: + - ./values-multi.yaml + documentSelector: + path: metadata.name + value: RELEASE-NAME-foreman-omitted-enabled-gate-cache + matchMany: false + asserts: + - equal: + path: metadata.name + value: RELEASE-NAME-foreman-omitted-enabled-gate-cache + + # ---- Per-agent field propagation ---- + + - it: per-agent replicaCount plus roles flow through to args + template: agent-deployment.yaml + values: + - ./values-multi.yaml + documentSelector: + path: metadata.name + value: RELEASE-NAME-foreman-llmkube-fork-agent + matchMany: false + asserts: + - equal: + path: spec.replicas + value: 1 + - contains: + path: spec.template.spec.containers[0].args + content: --roles=coder-fork + + - it: per-agent gitRemoteURL and commit-author flow through to args + template: agent-deployment.yaml + values: + - ./values-multi.yaml + documentSelector: + path: metadata.name + value: RELEASE-NAME-foreman-llmkube-fork-agent + matchMany: false + asserts: + - contains: + path: spec.template.spec.containers[0].args + content: --git-remote-url=https://github.com/joryirving/LLMKube + - contains: + path: spec.template.spec.containers[0].args + content: --commit-author-name=Jory Dogfood + - contains: + path: spec.template.spec.containers[0].args + content: --commit-author-email=jory-dogfood@example.com + + - it: per-agent coderGitSecret overrides the chart-level coder.gitCredentialsSecret + template: agent-deployment.yaml + values: + - ./values-multi.yaml + documentSelector: + path: metadata.name + value: RELEASE-NAME-foreman-llmkube-fork-agent + matchMany: false + asserts: + - contains: + path: spec.template.spec.containers[0].args + content: --coder-git-secret=foreman-fork-credentials + - contains: + path: spec.template.spec.containers[0].args + content: --coder-git-secret-key=token + + - it: per-agent githubToken secret flows through to env + template: agent-deployment.yaml + values: + - ./values-multi.yaml + documentSelector: + path: metadata.name + value: RELEASE-NAME-foreman-llmkube-fork-agent + matchMany: false + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: GITHUB_TOKEN + valueFrom: + secretKeyRef: + name: foreman-agent-fork + key: GITHUB_TOKEN + + - it: agent without per-agent githubToken does NOT render a GITHUB_TOKEN env + template: agent-deployment.yaml + values: + - ./values-multi.yaml + documentSelector: + path: metadata.name + value: RELEASE-NAME-foreman-default-agent + matchMany: false + asserts: + - notContains: + path: spec.template.spec.containers[0].env + content: + name: GITHUB_TOKEN + + # ---- agent.enabled: false skips that agent ---- + + - it: agents entry with enabled false is omitted from the deployment list + template: agent-deployment.yaml + values: + - ./values-multi.yaml + asserts: + # only 3 enabled agents out of 4 entries (default, llmkube-fork, + # omitted-enabled; disabled-agent is skipped) + - hasDocuments: + count: 3 + + # ---- omitted enabled field inherits from .Values.agent ---- + + - it: an agent entry that omits enabled still renders resources via the legacy default + template: agent-deployment.yaml + values: + - ./values-multi.yaml + documentSelector: + path: metadata.name + value: RELEASE-NAME-foreman-omitted-enabled-agent + matchMany: false + asserts: + # The merged $agent.enabled inherits true from .Values.agent, + # so the entry renders. Its explicit replicaCount override also + # survives the merge. + - equal: + path: metadata.name + value: RELEASE-NAME-foreman-omitted-enabled-agent + - equal: + path: spec.replicas + value: 5 + + # ---- Singletons stay singleton ---- + + - it: operator Deployment renders once even with multiple agents + template: operator-deployment.yaml + values: + - ./values-multi.yaml + asserts: + - hasDocuments: + count: 1 + + - it: coder SA plus Role plus RoleBinding render once even with multiple agents + template: coder-rbac.yaml + values: + - ./values-multi.yaml + asserts: + - hasDocuments: + count: 3 \ No newline at end of file diff --git a/charts/foreman/tests/values-multi.yaml b/charts/foreman/tests/values-multi.yaml new file mode 100644 index 00000000..d3fda871 --- /dev/null +++ b/charts/foreman/tests/values-multi.yaml @@ -0,0 +1,37 @@ +# Test fixture for agents_test.yaml — exercises the multi-fleet shape +# documented in the issue #994 design. The default agent carries the +# legacy gateCache settings (and gets the chart-level coder secret); +# the llmkube-fork agent overrides gitRemoteURL, commit author, the +# coder secret, and disables gateCache so its PVC is not rendered; +# the omitted-enabled agent omits the `enabled` field entirely to +# regression-test that the gate evaluates the merged $agent.enabled +# (not the raw $cfg.enabled) — otherwise the entry would silently +# render nothing (#994 review feedback); the disabled agent is a +# smoke-test for the enabled: false path. +agents: + default: + enabled: true + replicaCount: 3 + roles: [worker, coder, verifier] + gitRemoteURL: "" + llmkube-fork: + enabled: true + replicaCount: 1 + roles: [coder-fork] + gitRemoteURL: https://github.com/joryirving/LLMKube + commitAuthorName: "Jory Dogfood" + commitAuthorEmail: "jory-dogfood@example.com" + githubToken: + secretName: foreman-agent-fork + secretKey: GITHUB_TOKEN + coderGitSecret: foreman-fork-credentials + coderGitSecretKey: token + gateCache: + enabled: false + omitted-enabled: + replicaCount: 5 + roles: [worker] + disabled-agent: + enabled: false + replicaCount: 1 + roles: [worker] \ No newline at end of file diff --git a/charts/foreman/values.yaml b/charts/foreman/values.yaml index 03f0e4fd..8ab0e092 100644 --- a/charts/foreman/values.yaml +++ b/charts/foreman/values.yaml @@ -328,6 +328,51 @@ agent: podLabels: {} env: [] + # Per-agent override of the chart-level coder.gitCredentialsSecret + # passed through as --coder-git-secret. Empty falls back to + # .Values.coder.gitCredentialsSecret so existing installs that only + # set the chart-level value are unchanged. (#994) + coderGitSecret: "" + coderGitSecretKey: "" + +# agents is the multi-fleet map (#994). Leave it UNSET for a single +# agent (the legacy `agent:` block above is used as-is and renders +# exactly the resources it always has, so an existing install is +# unchanged on upgrade). Set it to render one Deployment + +# ServiceAccount + ClusterRole/ClusterRoleBinding + gate-cache PVC PER +# entry, each suffixed with the agent key, so multiple heterogeneous +# agent fleets coexist in one release instead of needing a second +# chart install (which would collide on the chart's singletons). +# +# Real-world use cases: +# +# agents: +# default: # the primary multi-repo worker/coder pool +# replicaCount: 3 +# roles: [worker, coder, verifier] +# llmkube-fork: # a fork-pinned dogfood agent for upstream PRs +# replicaCount: 1 +# roles: [coder-fork] +# gitRemoteURL: https://github.com//LLMKube +# githubToken: { secretName: foreman-agent-fork, secretKey: GITHUB_TOKEN } +# commitAuthorName: "" +# commitAuthorEmail: "@users.noreply.github.com" +# coderGitSecret: foreman-fork-credentials # per-agent coder PAT +# gateCache: { enabled: false } # fork agents don't gate +# +# Each entry's fields DEEP-MERGE with the legacy `agent:` block above +# (which serves as the defaults) so you only have to specify what +# differs. Naming convention: per-agent resources are +# `--agent` (Deployment / SA / ClusterRole / +# ClusterRoleBinding) and `--gate-cache` (PVC), so +# agents never collide. The shared components — operator Deployment, +# webhook, CRDs, coder SA/Role/RoleBinding — remain single per release. +# +# Setting `agents:` opts you OUT of the implicit legacy agent: render +# path; if you need a "default" fleet alongside custom ones, list it +# explicitly under `agents:` with the name `default` (or any other key). +agents: {} + # coder gates the ServiceAccount + namespaced Role the Job-mode coder # pods run under (#620). A Job-mode Agent (spec.execution.mode=Job) runs # `foreman-agent run-task` in an ephemeral Job whose pod uses this SA.