tatara-operator M6 (chart hardening + deploy wiring) Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking. M6 is config/manifest work, not Go-TDD: each task shows the EXACT file contents (or precise diffs) and a verification command with expected output. The FINAL deploy step (helmfile -e default diff) is GATED: it requires explicit user confirmation before anyapply. Do NOT runhelmfile applyorterraform applyas part of this plan.
Goal: Harden and finish the tatara-operator Helm chart (from the M0 skeleton), wire up cluster-agnostic deploy, and stand up the Keycloak + infra-helmfile plumbing so the operator can run in the tatara namespace. Specifically: finalize the manager Deployment (ConfigMap + Secret envFrom), ServiceAccount + RBAC (CRDs + jobs/pods/services/secrets-read/configmaps/networkpolicies), Service (REST + webhook on HTTP_ADDR) + metrics, ServiceMonitor, Ingress (host/class supplied by infra, not the chart), the CRDs; a templated egress-allowlist NetworkPolicy applied to spawned agent + ingest pods; the M1 ingest-result ConfigMap RBAC follow-up (tatara-ingest SA + Role); the tatara-operator confidential Keycloak client + tatara-operator audience mapper; and the tatara-operator release in the infra helmfile tatara bucket.
Architecture: The chart is created via helm create tatara-operator then edited (rule 5) and is cluster-agnostic (rule 14): no baked regcred, node affinity, ingress host/class, storage class, or replicated-secret names live in values.yaml. All cluster-specific customization comes from ~/Documents/infra/helmfile (per-bucket values/common.yaml + per-release values/tatara-operator/{common,default}.yaml + sops default.secrets.yaml). Config follows rule 6: each input is a camelCase scalar in values.yaml mapped through one envConfig helper to a kebab/SCREAMING_SNAKE ConfigMap key, consumed by the manager via envFrom; secrets follow the same path through a templated Secret. The four CRDs (Project/Repository/Task/Subtask) are installed by the chart under charts/tatara-operator/crds/ (helm installs crds/ before templates, no templating). The egress-allowlist NetworkPolicy is a chart-templated object whose podSelector matches the tatara.dev/managed-by: tatara-operator label that M1's ingest Job pod template and M4's agent Pod MUST carry (flagged below). RBAC is split: the manager's ClusterRole/Role (broad reconcile rights) and the narrow tatara-ingest Role (ConfigMap patch only) for the ingest Job's dedicated ServiceAccount.
Tech Stack: Helm ¾, helm lint, helm template, kubeconform (or helm template object-count assertion where kubeconform is unavailable), Terraform + mrparkers/keycloak provider (terraform fmt -check, terraform validate), helmfile + sops (helmfile -e default diff as the gated final step). All chart objects mirror ~/Documents/tatara/tatara-memory/charts/tatara-memory/ exactly (configmap envFrom, templated secret, deployment, serviceaccount, servicemonitor, networkpolicy, ingress, _helpers.tpl).
Spec: ~/Documents/tatara/docs/superpowers/specs/2026-06-06-tatara-operator-design.md (Security, observability, deploy section). Pin set (authoritative names/paths - obey exactly): ~/Documents/tatara/docs/superpowers/plans/_tatara-operator-shared-contracts.md (Deploy section).
Reference sources on disk (read these, do not guess): - Chart structure to mirror EXACTLY: ~/Documents/tatara/tatara-memory/charts/tatara-memory/ (templates/{_helpers.tpl,configmap.yaml,deployment.yaml,secret.yaml,service.yaml,serviceaccount.yaml,servicemonitor.yaml,networkpolicy.yaml,ingress.yaml}, values.yaml, Chart.yaml). - Helmfile release wiring: ~/Documents/infra/helmfile/helmfiles/tatara/helmfile.yaml.gotmpl, ~/Documents/infra/helmfile/helmfiles/tatara/values/common.yaml, ~/Documents/infra/helmfile/helmfiles/tatara/values/tatara-memory/common.yaml, ~/Documents/infra/helmfile/helmfiles/tatara/values/tatara-chat/default.yaml. - Keycloak clients to mirror: ~/Documents/infra/terraform/keycloak/tatara_clients.tf (read it; M6 adds a tatara-operator confidential service-account client + a tatara-operator audience mapper on the tatara scope). - sops config: ~/Documents/infra/helmfile/.sops.yaml (rule path_regex: default.secrets.yaml + .*.secret.*.yaml, PGP key D39E46932A270AA3BA490B9DB9FE928D3E8BCED8).
Repo dirs / paths: - Operator chart: ~/Documents/tatara/tatara-operator/charts/tatara-operator/. - Keycloak terraform: ~/Documents/infra/terraform/keycloak/. - Infra helmfile tatara bucket: ~/Documents/infra/helmfile/helmfiles/tatara/.
Preconditions (built in M0-M5, assumed present): - M0 produced a helm lint-clean chart skeleton at charts/tatara-operator/ with the four CRDs under charts/tatara-operator/crds/ (generated by make manifests via controller-gen). M6 edits that skeleton; if a skeleton object is missing, M6 creates it to match tatara-memory. - internal/config.Config env scalars (pin set): HTTP_ADDR, METRICS_ADDR, INTERNAL_ADDR, OIDC_ISSUER, OIDC_AUDIENCE (=tatara-operator), MEMORY_BASE_URL, INGESTER_IMAGE, EXTERNAL_WEBHOOK_BASE, OPERATOR_OIDC_CLIENT_ID, OPERATOR_OIDC_CLIENT_SECRET, ANTHROPIC_SECRET_NAME, CLI_OIDC_SECRET_NAME, LOG_LEVEL. Plus Namespace (default tatara). - M1 ingest Job runs under ServiceAccount tatara-ingest and patches <repo>-ingest-result ConfigMaps; M1 MEMORY flagged the SA + Role as an M6 deliverable. - M4 agent Pod + Service are spawned by the Task reconciler; M4 builds the pod via internal/agent/pod.go.
Environment naming: The infra helmfile tatara bucket uses env name default (existing files: values/tatara-memory/default.yaml, default.secrets.yaml). All M6 helmfile additions use default as the env. Substitute the real env name if the bucket is later run under a different -e.
Conventions used throughout this plan¶
- All paths absolute. Chart work happens in a worktree off
tatara-operatormain, merged back tomain; NEVER build/deploy from a worktree (rule 10). Chart-publish (OCI push) and the helmfile diff/apply run frommain. - Each chart task ends with
helm lint+ a targetedhelm templateassertion. Terraform tasks end withterraform fmt -check+terraform validate. The helmfile task ends with the gatedhelmfile -e default diff. - Mirror tatara-memory chart style verbatim: same
_helpers.tplmacro names (renamed totatara-operator.*), sameenvFromConfigMap+Secret ordering, same label includes. - Conventional commits on the M6 branch. Cross-repo: chart + RBAC + NetworkPolicy land in
tatara-operator; the Keycloak client lands ininfra/terraform; the release wiring lands ininfra/helmfile. These are three separate repos with three separate commits/PRs.
Task 1: Chart metadata, values.yaml, and _helpers.tpl¶
Files: - Modify/Create: ~/Documents/tatara/tatara-operator/charts/tatara-operator/Chart.yaml - Modify/Create: ~/Documents/tatara/tatara-operator/charts/tatara-operator/values.yaml - Modify/Create: ~/Documents/tatara/tatara-operator/charts/tatara-operator/templates/_helpers.tpl
The operator chart has NO subchart dependencies (unlike tatara-memory). It carries only its own manager workload + CRDs + RBAC + NetworkPolicy.
- Step 1: Write
Chart.yaml
Create/overwrite ~/Documents/tatara/tatara-operator/charts/tatara-operator/Chart.yaml:
apiVersion: v2
name: tatara-operator
description: Kubernetes operator orchestrating the tatara agentic-development loop (Project/Repository/Task/Subtask CRDs)
type: application
version: 0.1.0
appVersion: "0.1.0"
- Step 2: Write
values.yaml(rule 6: scalars only, no plain ENVs, no lists except cluster-supplied passthrough)
Create/overwrite ~/Documents/tatara/tatara-operator/charts/tatara-operator/values.yaml:
image:
repository: harbor.szymonrichert.pl/containers/tatara-operator
tag: ""
pullPolicy: IfNotPresent
# imagePullSecrets are cluster-specific; the deploying helmfile supplies them.
imagePullSecrets: []
replicaCount: 1
# Listener/config scalars. Each maps 1:1 to a SCREAMING_SNAKE ConfigMap key
# via the envConfig helper and is consumed by the manager through envFrom.
httpAddr: ":8080"
metricsAddr: ":9090"
internalAddr: ":8081"
oidcIssuer: "https://auth.szymonrichert.pl/realms/master"
oidcAudience: "tatara-operator"
memoryBaseUrl: "http://tatara-memory.tatara.svc:8080"
ingesterImage: "harbor.szymonrichert.pl/containers/tatara-memory-repo-ingester:latest"
externalWebhookBase: ""
operatorOidcClientId: "tatara-operator"
anthropicSecretName: "tatara-anthropic"
cliOidcSecretName: "tatara-cli-oidc"
logLevel: "info"
# Secret-backed scalars. Provided by a SOPS-encrypted values overlay at deploy
# time; the chart Secret renders empty placeholders when unset. Set
# existingSecret to point envFrom at an externally-managed Secret instead.
existingSecret: ""
operatorOidcClientSecret: ""
service:
type: ClusterIP
httpPort: 8080
metricsPort: 9090
ingress:
enabled: false
className: nginx
host: ""
path: "/"
clusterIssuer: letsencrypt-prod
tlsSecretName: ""
serviceMonitor:
enabled: true
interval: "30s"
scrapeTimeout: "10s"
# managedPodNetworkPolicy is the egress-allowlist applied to spawned agent +
# ingest pods (selected by tatara.dev/managed-by=tatara-operator). Endpoints
# are cluster service DNS / labels; SCM egress is the broad 443 rule.
managedPodNetworkPolicy:
enabled: true
# tatara-chat service the agent's tatara-cli reaches over HTTP.
chatServiceName: tatara-chat
rbac:
# create the manager ClusterRole/RoleBinding and the tatara-ingest Role/SA.
create: true
serviceAccount:
create: true
annotations: {}
name: ""
ingestServiceAccount:
# dedicated SA the M1 ingest Job runs under; Role grants ConfigMap patch only.
create: true
name: "tatara-ingest"
podSecurityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
containerSecurityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: true
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
memory: 256Mi
Note (rule 6 compliance): imagePullSecrets, capabilities.drop, and managedPodNetworkPolicy.* scalars are the only structured values. imagePullSecrets is the documented cluster-supplied passthrough (matches tatara-memory). capabilities.drop: [ALL] mirrors tatara-memory verbatim. No env-shaped lists exist; all config is scalar through envConfig.
- Step 3: Write
_helpers.tpl(mirror tatara-memory, rename to tatara-operator, add envConfig with pin-set keys)
Create/overwrite ~/Documents/tatara/tatara-operator/charts/tatara-operator/templates/_helpers.tpl:
{{/*
Expand the name of the chart.
*/}}
{{- define "tatara-operator.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "tatara-operator.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "tatara-operator.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "tatara-operator.labels" -}}
helm.sh/chart: {{ include "tatara-operator.chart" . }}
{{ include "tatara-operator.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "tatara-operator.selectorLabels" -}}
app.kubernetes.io/name: {{ include "tatara-operator.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "tatara-operator.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "tatara-operator.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
{{/*
Map camelCase values.* scalars to SCREAMING_SNAKE ConfigMap keys.
Strict: values.yaml carries only scalars; this macro is the single mapping point.
*/}}
{{- define "tatara-operator.envConfig" -}}
HTTP_ADDR: {{ .Values.httpAddr | quote }}
METRICS_ADDR: {{ .Values.metricsAddr | quote }}
INTERNAL_ADDR: {{ .Values.internalAddr | quote }}
OIDC_ISSUER: {{ .Values.oidcIssuer | quote }}
OIDC_AUDIENCE: {{ .Values.oidcAudience | quote }}
MEMORY_BASE_URL: {{ .Values.memoryBaseUrl | quote }}
INGESTER_IMAGE: {{ .Values.ingesterImage | quote }}
EXTERNAL_WEBHOOK_BASE: {{ .Values.externalWebhookBase | quote }}
OPERATOR_OIDC_CLIENT_ID: {{ .Values.operatorOidcClientId | quote }}
ANTHROPIC_SECRET_NAME: {{ .Values.anthropicSecretName | quote }}
CLI_OIDC_SECRET_NAME: {{ .Values.cliOidcSecretName | quote }}
LOG_LEVEL: {{ .Values.logLevel | quote }}
{{- end -}}
Note: OPERATOR_OIDC_CLIENT_SECRET is NOT in envConfig; it is a secret value rendered by templates/secret.yaml (Task 2) and consumed via the Secret envFrom.
- Step 4: Verify lint after metadata/values/helpers exist (templates land in later tasks)
The chart will not fully lint until the templates exist; this step just confirms the YAML parses. Run:
Expected at this point: lint may report[ERROR] for missing templates IF you run it before Tasks 2-9; that is fine. Re-run lint clean at Task 10. To validate just the values/helpers parse now: helm template t ~/Documents/tatara/tatara-operator/charts/tatara-operator --show-only templates/_helpers.tpl 2>&1 | head -1
- Step 5: Commit
Task 2: ConfigMap, Secret, ServiceAccount¶
Files: - Create: ~/Documents/tatara/tatara-operator/charts/tatara-operator/templates/configmap.yaml - Create: ~/Documents/tatara/tatara-operator/charts/tatara-operator/templates/secret.yaml - Create: ~/Documents/tatara/tatara-operator/charts/tatara-operator/templates/serviceaccount.yaml
- Step 1: ConfigMap (mirror tatara-memory)
Create ~/Documents/tatara/tatara-operator/charts/tatara-operator/templates/configmap.yaml:
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "tatara-operator.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "tatara-operator.labels" . | nindent 4 }}
data:
{{- include "tatara-operator.envConfig" . | nindent 2 }}
- Step 2: Secret (templated, mirrors tatara-memory secret.yaml; key kebab-cased)
Create ~/Documents/tatara/tatara-operator/charts/tatara-operator/templates/secret.yaml:
{{- if not .Values.existingSecret }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "tatara-operator.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "tatara-operator.labels" . | nindent 4 }}
type: Opaque
data:
# operator-oidc-client-secret is a placeholder; the real value lands from a
# SOPS-encrypted values overlay at deploy time. The manager reads it via the
# Secret envFrom as OPERATOR_OIDC_CLIENT_SECRET (key normalized below).
OPERATOR_OIDC_CLIENT_SECRET: {{ .Values.operatorOidcClientSecret | b64enc | quote }}
{{- end }}
OPERATOR_OIDC_CLIENT_SECRET so the Deployment envFrom: secretRef injects it directly (no rename needed). This matches the pin-set config name. - Step 3: ServiceAccount (manager SA; mirror tatara-memory)
Create ~/Documents/tatara/tatara-operator/charts/tatara-operator/templates/serviceaccount.yaml:
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "tatara-operator.serviceAccountName" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "tatara-operator.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
-
Step 4: Verify render
Expected: three objects render. ConfigMaphelm template t ~/Documents/tatara/tatara-operator/charts/tatara-operator \ --show-only templates/configmap.yaml --show-only templates/secret.yaml --show-only templates/serviceaccount.yamldatacontains all 12envConfigkeys (HTTP_ADDR,METRICS_ADDR,INTERNAL_ADDR,OIDC_ISSUER,OIDC_AUDIENCE,MEMORY_BASE_URL,INGESTER_IMAGE,EXTERNAL_WEBHOOK_BASE,OPERATOR_OIDC_CLIENT_ID,ANTHROPIC_SECRET_NAME,CLI_OIDC_SECRET_NAME,LOG_LEVEL). SecretdatahasOPERATOR_OIDC_CLIENT_SECRET. Assert the count:Expected:helm template t ~/Documents/tatara/tatara-operator/charts/tatara-operator --show-only templates/configmap.yaml | grep -cE '^\s+(HTTP_ADDR|METRICS_ADDR|INTERNAL_ADDR|OIDC_ISSUER|OIDC_AUDIENCE|MEMORY_BASE_URL|INGESTER_IMAGE|EXTERNAL_WEBHOOK_BASE|OPERATOR_OIDC_CLIENT_ID|ANTHROPIC_SECRET_NAME|CLI_OIDC_SECRET_NAME|LOG_LEVEL):'12. -
Step 5: Commit
Task 3: Manager Deployment (ConfigMap + Secret envFrom)¶
Files: - Create: ~/Documents/tatara/tatara-operator/charts/tatara-operator/templates/deployment.yaml
- Step 1: Write the Deployment (mirror tatara-memory; two ports http+metrics; no inline PG env)
Create ~/Documents/tatara/tatara-operator/charts/tatara-operator/templates/deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "tatara-operator.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "tatara-operator.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "tatara-operator.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "tatara-operator.selectorLabels" . | nindent 8 }}
annotations:
checksum/config: {{ include "tatara-operator.envConfig" . | sha256sum }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "tatara-operator.serviceAccountName" . }}
{{- with .Values.podSecurityContext }}
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: tatara-operator
{{- with .Values.containerSecurityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 8080
protocol: TCP
- name: metrics
containerPort: 9090
protocol: TCP
envFrom:
- configMapRef:
name: {{ include "tatara-operator.fullname" . }}
- secretRef:
name: {{ default (include "tatara-operator.fullname" .) .Values.existingSecret }}
livenessProbe:
httpGet:
path: /healthz
port: metrics
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /readyz
port: metrics
initialDelaySeconds: 5
periodSeconds: 10
resources:
{{- toYaml .Values.resources | nindent 12 }}
/healthz and /readyz live on the metrics listener (METRICS_ADDR :9090), matching the M0 manager which exposes health + /metrics together. REST + webhook share HTTP_ADDR :8080 (http port). If M0 instead put health on HTTP_ADDR, change the probe port to http; verify against cmd/manager/main.go before committing (boy-scout, rule 3). -
Step 2: Verify render
Expected: one Deployment.helm template t ~/Documents/tatara/tatara-operator/charts/tatara-operator --show-only templates/deployment.yamlenvFromhas both aconfigMapRefand asecretRefnamedt-tatara-operator. Two container portshttp(8080) andmetrics(9090). Assert envFrom wiring:Expected:helm template t ~/Documents/tatara/tatara-operator/charts/tatara-operator --show-only templates/deployment.yaml | grep -cE 'configMapRef|secretRef'2. -
Step 3: Commit
Task 4: Service + metrics, ServiceMonitor¶
Files: - Create: ~/Documents/tatara/tatara-operator/charts/tatara-operator/templates/service.yaml - Create: ~/Documents/tatara/tatara-operator/charts/tatara-operator/templates/servicemonitor.yaml
- Step 1: Service (two ports: http for REST+webhook, metrics for /metrics)
Create ~/Documents/tatara/tatara-operator/charts/tatara-operator/templates/service.yaml:
apiVersion: v1
kind: Service
metadata:
name: {{ include "tatara-operator.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "tatara-operator.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- name: http
port: {{ .Values.service.httpPort }}
targetPort: http
protocol: TCP
- name: metrics
port: {{ .Values.service.metricsPort }}
targetPort: metrics
protocol: TCP
selector:
{{- include "tatara-operator.selectorLabels" . | nindent 4 }}
- Step 2: ServiceMonitor (scrape the metrics port)
Create ~/Documents/tatara/tatara-operator/charts/tatara-operator/templates/servicemonitor.yaml:
{{- if .Values.serviceMonitor.enabled }}
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: {{ include "tatara-operator.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "tatara-operator.labels" . | nindent 4 }}
spec:
namespaceSelector:
matchNames:
- {{ .Release.Namespace }}
selector:
matchLabels:
{{- include "tatara-operator.selectorLabels" . | nindent 6 }}
endpoints:
- port: metrics
path: /metrics
interval: {{ .Values.serviceMonitor.interval }}
scrapeTimeout: {{ .Values.serviceMonitor.scrapeTimeout }}
{{- end }}
-
Step 3: Verify render
Expected: a Service withhelm template t ~/Documents/tatara/tatara-operator/charts/tatara-operator --show-only templates/service.yaml --show-only templates/servicemonitor.yamlhttp(8080) +metrics(9090) ports, and a ServiceMonitor scrapingport: metrics path: /metrics. -
Step 4: Commit
Task 5: Manager RBAC (ClusterRole + RoleBinding/ClusterRoleBinding)¶
Files: - Create: ~/Documents/tatara/tatara-operator/charts/tatara-operator/templates/rbac.yaml
The manager reconciles the four CRDs (cluster-scoped CRD read for the types; namespaced CR read/write) and spawns/reads jobs, pods, services, secrets (read), configmaps, networkpolicies in the tatara namespace. Because all CRs are namespaced and the operator runs single-namespace, use a Role + RoleBinding for the namespaced verbs and a small ClusterRole + ClusterRoleBinding only for the customresourcedefinitions read (CRDs are cluster-scoped). M0 make manifests may already emit // +kubebuilder:rbac markers; this hand-written chart RBAC is the source of truth for deploy (rule 5 chart edited). Keep it in sync with the kubebuilder markers (boy-scout: if they drift, reconcile to the broader set and note in MEMORY).
- Step 1: Write the RBAC manifest
Create ~/Documents/tatara/tatara-operator/charts/tatara-operator/templates/rbac.yaml:
{{- if .Values.rbac.create }}
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: {{ include "tatara-operator.fullname" . }}-manager
namespace: {{ .Release.Namespace }}
labels:
{{- include "tatara-operator.labels" . | nindent 4 }}
rules:
# CRDs reconciled by the manager.
- apiGroups: ["tatara.dev"]
resources: ["projects", "repositories", "tasks", "subtasks"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["tatara.dev"]
resources: ["projects/status", "repositories/status", "tasks/status", "subtasks/status"]
verbs: ["get", "update", "patch"]
# ingest Jobs.
- apiGroups: ["batch"]
resources: ["jobs"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# agent Pods + Services (M4) and ingest-result ConfigMaps (M1).
- apiGroups: [""]
resources: ["pods", "services", "configmaps"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# Project SCM Secrets (read only: token + webhookSecret).
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list", "watch"]
# the managed-pod egress NetworkPolicy may be created per-Project at runtime.
- apiGroups: ["networking.k8s.io"]
resources: ["networkpolicies"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: [""]
resources: ["events"]
verbs: ["create", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: {{ include "tatara-operator.fullname" . }}-manager
namespace: {{ .Release.Namespace }}
labels:
{{- include "tatara-operator.labels" . | nindent 4 }}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: {{ include "tatara-operator.fullname" . }}-manager
subjects:
- kind: ServiceAccount
name: {{ include "tatara-operator.serviceAccountName" . }}
namespace: {{ .Release.Namespace }}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: {{ include "tatara-operator.fullname" . }}-crd-reader
labels:
{{- include "tatara-operator.labels" . | nindent 4 }}
rules:
- apiGroups: ["apiextensions.k8s.io"]
resources: ["customresourcedefinitions"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: {{ include "tatara-operator.fullname" . }}-crd-reader
labels:
{{- include "tatara-operator.labels" . | nindent 4 }}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: {{ include "tatara-operator.fullname" . }}-crd-reader
subjects:
- kind: ServiceAccount
name: {{ include "tatara-operator.serviceAccountName" . }}
namespace: {{ .Release.Namespace }}
{{- end }}
tatara ns); the name includes the release-derived fullname so a differently-named release stays distinct. Recorded in MEMORY (Task 11). -
Step 2: Verify render
Expected:helm template t ~/Documents/tatara/tatara-operator/charts/tatara-operator --show-only templates/rbac.yaml | grep -cE '^kind: (Role|RoleBinding|ClusterRole|ClusterRoleBinding)$'4(Role, RoleBinding, ClusterRole, ClusterRoleBinding). -
Step 3: Commit
Task 6: ingest ServiceAccount + Role (M1 follow-up)¶
Files: - Create: ~/Documents/tatara/tatara-operator/charts/tatara-operator/templates/ingest-rbac.yaml
This resolves the M1-flagged follow-up: the M1 ingest Job runs under ServiceAccount tatara-ingest and patches the <repo>-ingest-result ConfigMap. M6 creates that SA + a narrow Role granting get,create,update,patch on ConfigMaps in the tatara namespace, and a RoleBinding. The M1 Job already sets ServiceAccountName: "tatara-ingest" (see M1 plan internal/ingest/job.go); no operator-code change is needed, only this chart RBAC.
- Step 1: Write the ingest RBAC manifest
Create ~/Documents/tatara/tatara-operator/charts/tatara-operator/templates/ingest-rbac.yaml:
{{- if .Values.ingestServiceAccount.create }}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ .Values.ingestServiceAccount.name }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "tatara-operator.labels" . | nindent 4 }}
app.kubernetes.io/component: ingest
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: {{ .Values.ingestServiceAccount.name }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "tatara-operator.labels" . | nindent 4 }}
app.kubernetes.io/component: ingest
rules:
# the ingest Job patches its <repo>-ingest-result ConfigMap with the HEAD SHA.
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "create", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: {{ .Values.ingestServiceAccount.name }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "tatara-operator.labels" . | nindent 4 }}
app.kubernetes.io/component: ingest
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: {{ .Values.ingestServiceAccount.name }}
subjects:
- kind: ServiceAccount
name: {{ .Values.ingestServiceAccount.name }}
namespace: {{ .Release.Namespace }}
{{- end }}
tatara-ingest (not release-prefixed) because the M1 Job hardcodes ServiceAccountName: "tatara-ingest". Keep it fixed; do NOT switch to a release-derived name without also changing internal/ingest/job.go (flagged here, not changed in M6). -
Step 2: Verify render
Expected: ServiceAccounthelm template t ~/Documents/tatara/tatara-operator/charts/tatara-operator --show-only templates/ingest-rbac.yamltatara-ingest, Roletatara-ingest(configmaps get/create/update/patch), RoleBindingtatara-ingestbinding the two. Assert verbs:Expected:helm template t ~/Documents/tatara/tatara-operator/charts/tatara-operator --show-only templates/ingest-rbac.yaml | grep -A3 'resources: \["configmaps"\]' | grep verbsverbs: ["get", "create", "update", "patch"]. -
Step 3: Commit
Task 7: Managed-pod NetworkPolicy (agent + ingest egress allowlist)¶
Files: - Create: ~/Documents/tatara/tatara-operator/charts/tatara-operator/templates/managed-pod-networkpolicy.yaml
This is the spec's "egress-allowlist NetworkPolicy on agent + ingest pods". It selects pods by the label tatara.dev/managed-by: tatara-operator. Egress is allowed to: tatara-memory (svc), the operator Service (REST/webhook + internal callback), tatara-chat (svc), SCM hosts (broad 443 - GitHub/GitLab are external, host set is open-ended so 443 to any external IP is the pragmatic allowlist, matching tatara-memory's namespaceSelector: {} 443 egress), and DNS. Ingress is denied except from the operator (operator -> wrapper on the wrapper's HTTP port).
CROSS-MILESTONE LABEL REQUIREMENT (FLAG for M1 + M4): This policy only works if spawned pods carry tatara.dev/managed-by: tatara-operator. The M1 ingest Job pod template (internal/ingest/job.go, the batchv1.Job.Spec.Template.ObjectMeta.Labels map) currently sets app.kubernetes.io/name, app.kubernetes.io/component: ingest, tatara.dev/repository; it MUST also set tatara.dev/managed-by: tatara-operator. The M4 agent Pod (internal/agent/pod.go) MUST set the same label on the Pod's ObjectMeta.Labels. These are code changes in M1/M4, NOT chart changes; M6 records the requirement in MEMORY and the M1/M4 plans should be amended if not already done. If those plans are already merged, add a tiny follow-up commit in each repo. Verify after deploy that both pod kinds carry the label (Task 12 manual check).
- Step 1: Write the NetworkPolicy template
Create ~/Documents/tatara/tatara-operator/charts/tatara-operator/templates/managed-pod-networkpolicy.yaml:
{{- if .Values.managedPodNetworkPolicy.enabled }}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ include "tatara-operator.fullname" . }}-managed-pods
namespace: {{ .Release.Namespace }}
labels:
{{- include "tatara-operator.labels" . | nindent 4 }}
spec:
podSelector:
matchLabels:
tatara.dev/managed-by: tatara-operator
policyTypes:
- Ingress
- Egress
ingress:
# operator -> wrapper only: the manager pod reaches the agent wrapper on 8080.
- from:
- podSelector:
matchLabels:
{{- include "tatara-operator.selectorLabels" . | nindent 14 }}
ports:
- port: 8080
protocol: TCP
egress:
# DNS.
- to:
- namespaceSelector: {}
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- port: 53
protocol: UDP
- port: 53
protocol: TCP
# tatara-memory service.
- to:
- podSelector:
matchLabels:
app.kubernetes.io/name: tatara-memory
ports:
- port: 8080
protocol: TCP
# tatara-chat service.
- to:
- podSelector:
matchLabels:
app.kubernetes.io/name: {{ .Values.managedPodNetworkPolicy.chatServiceName }}
ports:
- port: 8080
protocol: TCP
# the operator itself (REST API, internal turn-complete callback).
- to:
- podSelector:
matchLabels:
{{- include "tatara-operator.selectorLabels" . | nindent 14 }}
ports:
- port: 8080
protocol: TCP
- port: 8081
protocol: TCP
# SCM hosts (GitHub/GitLab) + Keycloak token endpoint: external HTTPS.
- to:
- namespaceSelector: {}
ports:
- port: 443
protocol: TCP
{{- end }}
namespaceSelector: {} 443 egress rule. Tightening to specific CIDRs is deferred to the wrapper hardening ROADMAP (gVisor/seccomp era); recorded in MEMORY. The 8081 egress to the operator is the internal turn-complete callback (INTERNAL_ADDR). -
Step 2: Verify render
Expected: one NetworkPolicyhelm template t ~/Documents/tatara/tatara-operator/charts/tatara-operator --show-only templates/managed-pod-networkpolicy.yamlt-tatara-operator-managed-podswithpodSelector.matchLabels: tatara.dev/managed-by: tatara-operator, one ingress rule (operator->8080), and egress rules for DNS, tatara-memory:8080, tatara-chat:8080, operator:8080+8081, and 443-to-any. Assert the selector:Expected: one match linehelm template t ~/Documents/tatara/tatara-operator/charts/tatara-operator --show-only templates/managed-pod-networkpolicy.yaml | grep -A2 'podSelector:' | grep 'tatara.dev/managed-by: tatara-operator'tatara.dev/managed-by: tatara-operator. -
Step 3: Commit
Task 8: Ingress (webhook + REST paths; host/class from infra, rule 14)¶
Files: - Create: ~/Documents/tatara/tatara-operator/charts/tatara-operator/templates/ingress.yaml
The Ingress exposes the webhook path (/operator/webhooks/...) and the REST paths on the http Service port. Host, className, TLS secret, and path are supplied by the infra helmfile (enabled: false in the chart default per rule 14). Mirror tatara-memory's ingress (single host, rewrite-target when path != "/"). The operator serves both webhook and REST under a common prefix; the infra value sets path to the operator's external base prefix (e.g. /operator), and the operator's internal routes (/operator/webhooks/..., REST paths) sit under it. Use a single path rule with prefix capture, matching tatara-memory.
- Step 1: Write the Ingress template
Create ~/Documents/tatara/tatara-operator/charts/tatara-operator/templates/ingress.yaml:
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "tatara-operator.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "tatara-operator.labels" . | nindent 4 }}
annotations:
cert-manager.io/cluster-issuer: {{ .Values.ingress.clusterIssuer | quote }}
{{- if ne .Values.ingress.path "/" }}
nginx.ingress.kubernetes.io/rewrite-target: /$2
{{- end }}
spec:
ingressClassName: {{ .Values.ingress.className }}
rules:
- host: {{ .Values.ingress.host | quote }}
http:
paths:
- path: {{ if ne .Values.ingress.path "/" }}{{ .Values.ingress.path }}(/|$)(.*){{ else }}/{{ end }}
pathType: {{ if ne .Values.ingress.path "/" }}ImplementationSpecific{{ else }}Prefix{{ end }}
backend:
service:
name: {{ include "tatara-operator.fullname" . }}
port:
name: http
tls:
- hosts:
- {{ .Values.ingress.host | quote }}
secretName: {{ default (printf "%s-tls" (include "tatara-operator.fullname" .)) .Values.ingress.tlsSecretName }}
{{- end }}
ingress.enabled: false, host: "", className: nginx. The real host (tatara.szymonrichert.pl), path (/operator), and class come from infra/helmfile/.../values/tatara-operator/default.yaml (Task 10). The rewrite-target strips the path prefix so the operator sees /operator/webhooks/{project} if EXTERNAL_WEBHOOK_BASE already encodes the host+prefix; align EXTERNAL_WEBHOOK_BASE with the ingress path. The operator's status.webhookURL rendering uses EXTERNAL_WEBHOOK_BASE; if the rewrite-target strips /operator, set path: / instead and route entirely inside the app. Decide based on M2's actual route prefix; default here keeps /operator external and lets the app own /operator/webhooks (so use path: / in infra values to avoid double-stripping). Confirm against internal/webhook/server.go route registration before committing infra values (Task 10). -
Step 2: Verify render with infra-style overrides
Expected: one Ingress, hosthelm template t ~/Documents/tatara/tatara-operator/charts/tatara-operator \ --set ingress.enabled=true --set ingress.host=tatara.szymonrichert.pl --set ingress.path=/ \ --show-only templates/ingress.yamltatara.szymonrichert.pl,ingressClassName: nginx, backend servicet-tatara-operatorporthttp, TLS hosttatara.szymonrichert.pl. With chart defaults (enabled:false) the template renders nothing:Expected:helm template t ~/Documents/tatara/tatara-operator/charts/tatara-operator --show-only templates/ingress.yamlError: could not find template templates/ingress.yaml in chartOR empty output (theifguards it). Both confirm rule-14 compliance (no host baked). -
Step 3: Commit
Task 9: CRDs in the chart¶
Files: - Verify/Create: ~/Documents/tatara/tatara-operator/charts/tatara-operator/crds/tatara.dev_projects.yaml - Verify/Create: ~/Documents/tatara/tatara-operator/charts/tatara-operator/crds/tatara.dev_repositories.yaml - Verify/Create: ~/Documents/tatara/tatara-operator/charts/tatara-operator/crds/tatara.dev_tasks.yaml - Verify/Create: ~/Documents/tatara/tatara-operator/charts/tatara-operator/crds/tatara.dev_subtasks.yaml
M0 generated these via make manifests (controller-gen). M6 confirms they live under the chart's crds/ directory (helm installs crds/ before templates and does not template them). If M0 emitted them to config/crd/bases/ instead, copy them into charts/tatara-operator/crds/ and wire the Makefile manifests target to output there (boy-scout, rule 3).
-
Step 1: Ensure the four CRDs exist under the chart crds/ dir
Expected:tatara.dev_projects.yaml tatara.dev_repositories.yaml tatara.dev_subtasks.yaml tatara.dev_tasks.yaml. If absent, regenerate and copy:(Only ifcd ~/Documents/tatara/tatara-operator && make manifests cp config/crd/bases/tatara.dev_*.yaml charts/tatara-operator/crds/make manifestswrites toconfig/crd/bases; if the Makefile already targets the chartcrds/, skip the copy.) -
Step 2: Verify each CRD declares the right group/kind/scope
Expected:grep -l 'group: tatara.dev' ~/Documents/tatara/tatara-operator/charts/tatara-operator/crds/*.yaml | wc -l grep -h 'scope:' ~/Documents/tatara/tatara-operator/charts/tatara-operator/crds/*.yaml | sort -u4, andscope: Namespaced(all four namespaced). -
Step 3: Commit (if any CRD was moved/regenerated)
Task 10: Full chart lint + render verification (gate before publish)¶
Files: none (verification only).
-
Step 1: helm lint clean
Expected:1 chart(s) linted, 0 chart(s) failed([INFO]lines OK; no[ERROR]). -
Step 2: Render the full chart and count expected objects
Expected (with defaults: ingress off, all else on):13- ConfigMap, Secret, ServiceAccount(manager), Deployment, Service, ServiceMonitor, Role(manager), RoleBinding(manager), ClusterRole, ClusterRoleBinding, ServiceAccount(ingest), Role(ingest), RoleBinding(ingest), NetworkPolicy. That is 14; CRDs are not counted byhelm template(they live incrds/, nottemplates/). Recount: ConfigMap(1) Secret(2) SA-mgr(3) Deployment(4) Service(5) ServiceMonitor(6) Role-mgr(7) RoleBinding-mgr(8) ClusterRole(9) ClusterRoleBinding(10) SA-ingest(11) Role-ingest(12) RoleBinding-ingest(13) NetworkPolicy(14). Expected:14. -
Step 3: kubeconform validation (schema-check rendered objects)
If kubeconform is installed:
helm template t ~/Documents/tatara/tatara-operator/charts/tatara-operator \
| kubeconform -strict -ignore-missing-schemas -summary -
Summary: N resources found ... 0 errors, 0 skipped (ServiceMonitor + NetworkPolicy schemas may be skipped via -ignore-missing-schemas; that is acceptable). If kubeconform is NOT installed, fall back to a dry-run server validate against the live cluster ONLY if a cluster is reachable and you have read access (non-mutating): helm template t ~/Documents/tatara/tatara-operator/charts/tatara-operator | kubectl apply --dry-run=server -f - 2>&1 | tail
(server dry run) with no error. Skip this fallback if no cluster is available; the helm lint + render-count gate is sufficient for the plan. - Step 4: Bump chart version if edited post-M0 and publish from main
After merging the M6 branch to tatara-operator main (rule 10: build/publish from main only), publish the OCI chart:
git -C ~/Documents/tatara/tatara-operator checkout main && git -C ~/Documents/tatara/tatara-operator pull
helm package ~/Documents/tatara/tatara-operator/charts/tatara-operator -d /tmp
helm push /tmp/tatara-operator-0.1.0.tgz oci://harbor.szymonrichert.pl/charts
Pushed: harbor.szymonrichert.pl/charts/tatara-operator:0.1.0 Digest: sha256:.... (Chart version 0.1.0 must match the helmfile pin in Task 12. The operator container image harbor.szymonrichert.pl/containers/tatara-operator:0.1.0 must be built+pushed first; flagged in ROADMAP, Task 11.) Task 11: ROADMAP + MEMORY updates (operator repo)¶
Files: - Modify: ~/Documents/tatara/tatara-operator/MEMORY.md - Modify: ~/Documents/tatara/tatara-operator/ROADMAP.md
- Step 1: Append MEMORY entries
Append to ~/Documents/tatara/tatara-operator/MEMORY.md:
- 2026-06-06 M6 chart finalized. Cluster-agnostic (rule 14): no baked
regcred/affinity/ingress-host/storage-class; all of that comes from the infra
helmfile tatara bucket. Config is scalar-via-envConfig -> ConfigMap envFrom
(rule 6); OPERATOR_OIDC_CLIENT_SECRET is the only secret env, via templated
Secret. CRDs ship under chart crds/ (helm installs them pre-templates).
- 2026-06-06 Managed-pod NetworkPolicy selects tatara.dev/managed-by=tatara-operator.
M1 ingest Job pod template and M4 agent Pod MUST set this label (code change,
not chart). Egress allowlist: DNS, tatara-memory:8080, tatara-chat:8080,
operator:8080+8081, and HTTPS-to-any (443) for SCM+Keycloak. CIDR-tightening of
the 443 rule deferred to wrapper hardening ROADMAP (brittle IP ranges).
- 2026-06-06 tatara-ingest SA + ConfigMap-patch Role added (M1 follow-up). SA
name is the fixed string "tatara-ingest" (M1 Job hardcodes it); do not
release-prefix without changing internal/ingest/job.go.
- 2026-06-06 Manager RBAC: namespaced Role (CRDs+status, jobs, pods/services/
configmaps, secrets-read, networkpolicies, events) + cluster-scoped
CRD-reader ClusterRole. Single-cluster single-release assumed; ClusterRole
name carries release fullname to avoid cross-release collision.
- 2026-06-06 Keycloak: confidential tatara-operator client (service accounts,
no browser flows) mints tokens to memory/wrapper; tatara-operator audience
mapper on the tatara scope means one tatara-cli token carries aud=tatara-memory
AND aud=tatara-operator AND aud=tatara-chat (M3 multi-audience need resolved).
- Step 2: Update ROADMAP - mark M6 done, add deploy follow-ons
Edit ~/Documents/tatara/tatara-operator/ROADMAP.md: change - [ ] M6 ... to - [x] M6 chart + deploy wiring - chart hardened, NetworkPolicy, RBAC, Keycloak client, infra helmfile release. Plan: docs/superpowers/plans/2026-06-06-tatara-operator-m6-chart-deploy.md. and append a "Deploy follow-ons (out-of-band)" section:
## Deploy follow-ons (out-of-band, before first real run)
- [ ] Replicate `ANTHROPIC_API_KEY` into the `tatara` namespace as Secret
`tatara-anthropic` (key the wrapper reads as ANTHROPIC_API_KEY). The chart
value `anthropicSecretName` points the operator at it; the Secret itself is
cluster-managed (reflector/replicated-secret or sops), NOT chart-rendered.
- [ ] Build + push the `tatara-claude-code-wrapper` image and the
`tatara-memory-repo-ingester` image to harbor; the operator references them
via Project.spec.agent.image and INGESTER_IMAGE. Operator cannot run agents
or ingest until both images are published.
- [ ] Build + push `harbor.szymonrichert.pl/containers/tatara-operator:0.1.0`
before the helmfile pins chart 0.1.0 / image tag 0.1.0.
- [ ] Create per-Project SCM Secrets out-of-band (keys: token, webhookSecret),
referenced by Project.spec.scmSecretRef. One Secret per Project; not chart-
rendered. SOPS guidance in infra helmfile values/tatara-operator/default.secrets.yaml.
- [ ] Apply the tatara-cli OIDC client-creds Secret `tatara-cli-oidc` in the
tatara namespace (the wrapper's tatara-cli mints operator/memory/chat tokens).
- Step 3: Commit
Task 12: Keycloak - tatara-operator confidential client + audience mapper¶
Files: - Modify: ~/Documents/infra/terraform/keycloak/tatara_clients.tf
Add a confidential tatara-operator client (service accounts enabled, no browser flows; mirrors tatara_memory/tatara_chat) and a tatara-operator audience mapper on the tatara scope (mirrors tatara_chat_aud). The audience mapper puts aud=tatara-operator on every token issued with the tatara scope - including tatara-cli device-flow tokens - so a single tatara-cli token carries aud for memory, chat, AND operator. This resolves the M3-flagged need for one tatara-cli token to reach both tatara-memory and tatara-operator: the audience mappers are additive on the shared scope, so no separate token exchange is needed.
- Step 1: Add the audience mapper (after the existing tatara_chat_aud mapper)
Insert into ~/Documents/infra/terraform/keycloak/tatara_clients.tf, after the tatara_chat_aud resource (around line 23):
# tatara-operator (phase 9). This mapper puts aud=tatara-operator on every
# tatara-scope token (tatara-cli device flow and confidential service accounts
# alike) so those tokens reach the operator REST API. Combined with the
# tatara-memory and tatara-chat mappers, one tatara-cli token carries all three
# audiences (resolves the M3 multi-audience need).
resource "keycloak_openid_audience_protocol_mapper" "tatara_operator_aud" {
realm_id = data.keycloak_realm.master.id
client_scope_id = keycloak_openid_client_scope.tatara.id
name = "tatara-operator-audience"
included_custom_audience = "tatara-operator"
}
- Step 2: Add the confidential client + its default scopes (after tatara_chat block, before outputs)
Insert before the output "tatara_memory_client_secret" block:
resource "keycloak_openid_client" "tatara_operator" {
realm_id = data.keycloak_realm.master.id
client_id = "tatara-operator"
name = "tatara-operator"
description = "Tatara operator (CRD reconciler + OIDC-gated REST/webhook). Confidential service-account client: mints tokens to call tatara-memory and the wrapper. Also the audience for agent tatara-cli tokens reaching the operator REST API."
access_type = "CONFIDENTIAL"
standard_flow_enabled = false
implicit_flow_enabled = false
direct_access_grants_enabled = false
service_accounts_enabled = true
valid_redirect_uris = []
access_token_lifespan = "3600"
}
resource "keycloak_openid_client_default_scopes" "tatara_operator_scopes" {
realm_id = data.keycloak_realm.master.id
client_id = keycloak_openid_client.tatara_operator.id
default_scopes = [
"profile",
"email",
keycloak_openid_client_scope.tatara.name,
]
}
- Step 3: Add the client-secret output (next to the existing outputs)
Append after output "tatara_chat_client_secret":
output "tatara_operator_client_secret" {
value = keycloak_openid_client.tatara_operator.client_secret
sensitive = true
}
-
Step 4: fmt + validate
Expected:terraform -chdir=/Users/szymonri/Documents/infra/terraform/keycloak fmt -check terraform -chdir=/Users/szymonri/Documents/infra/terraform/keycloak validatefmt -checkprints nothing and exits 0 (runterraform fmtwithout-checkfirst if it reports the file).validateprintsSuccess! The configuration is valid.Note:terraform validatemay requireterraform initfirst; if the working dir is uninitialized, runterraform -chdir=/Users/szymonri/Documents/infra/terraform/keycloak init -backend=false(no remote state needed for validate). -
Step 5: Plan (review only - do NOT apply)
Expected: plan shows3 to add(client, default-scopes, audience-mapper)0 to change, 0 to destroy. Review and STOP. Applying the Keycloak change is a separate user-gated action; the operator OIDC client secret produced byterraform output -raw tatara_operator_client_secretfeeds the sops secrets file in Task 13. -
Step 6: Commit (infra/terraform repo)
Task 13: Infra helmfile - tatara-operator release + values + sops secrets¶
Files: - Modify: ~/Documents/infra/helmfile/helmfiles/tatara/helmfile.yaml.gotmpl - Create: ~/Documents/infra/helmfile/helmfiles/tatara/values/tatara-operator/common.yaml - Create: ~/Documents/infra/helmfile/helmfiles/tatara/values/tatara-operator/default.yaml - Create (sops-encrypted): ~/Documents/infra/helmfile/helmfiles/tatara/values/tatara-operator/default.secrets.yaml
- Step 1: Add the release to the helmfile (append after the tatara-chat release)
Append to ~/Documents/infra/helmfile/helmfiles/tatara/helmfile.yaml.gotmpl:
- name: tatara-operator
chart: oci://harbor.szymonrichert.pl/charts/tatara-operator
namespace: tatara
createNamespace: true
version: 0.1.0
labels:
purpose: tatara
application: tatara-operator
<<: *default
*default anchor already wires values/common.yaml, values/default.yaml, values/tatara-operator/common.yaml, values/tatara-operator/default.yaml, and the sops values/tatara-operator/default.secrets.yaml (env name default). No per-release block changes needed beyond this. - Step 2: Write
values/tatara-operator/common.yaml(image tag pin; regcred passthrough)
Create ~/Documents/infra/helmfile/helmfiles/tatara/values/tatara-operator/common.yaml:
# Cluster-specific values the chart does not bake (rule 14). The operator chart
# has no subcharts, so the only passthrough is the image tag pin. The regcred
# pull secret reaches the manager Deployment from the bucket values/common.yaml
# (imagePullSecrets), same as the other tatara releases.
image:
tag: "0.1.0"
- Step 3: Write
values/tatara-operator/default.yaml(cluster-specific ingress + endpoints)
Create ~/Documents/infra/helmfile/helmfiles/tatara/values/tatara-operator/default.yaml:
ingress:
enabled: true
host: tatara.szymonrichert.pl
# path "/" lets the operator own /operator/webhooks and the REST routes
# internally (no rewrite double-strip). See chart ingress.yaml note.
path: /
className: nginx
# External base used to render Project.status.webhookURL. Must match the public
# host + the operator's internal webhook route prefix.
externalWebhookBase: "https://tatara.szymonrichert.pl/operator/webhooks"
# In-cluster service endpoints.
memoryBaseUrl: "http://tatara-memory.tatara.svc:8080"
oidcIssuer: "https://auth.szymonrichert.pl/realms/master"
oidcAudience: "tatara-operator"
operatorOidcClientId: "tatara-operator"
# Secret names the operator references (cluster-managed, not chart-rendered).
anthropicSecretName: "tatara-anthropic"
cliOidcSecretName: "tatara-cli-oidc"
# Ingester image the ingest Job runs (must be published to harbor first).
ingesterImage: "harbor.szymonrichert.pl/containers/tatara-memory-repo-ingester:latest"
/operator/webhooks/{project} inside the app AND the ingress path is /, then EXTERNAL_WEBHOOK_BASE correctly resolves to https://tatara.szymonrichert.pl/operator/webhooks. Confirm the route prefix against internal/webhook/server.go before deploy; adjust path/base together if it differs. - Step 4: Create the sops-encrypted secrets file
The sops rule path_regex: default.secrets.yaml (PGP D39E46932A270AA3BA490B9DB9FE928D3E8BCED8) covers this filename. Use the sops-secret-helper skill to create/populate it (never hand-edit a sops file). The plaintext shape before encryption:
# operatorOidcClientSecret: the confidential tatara-operator client secret from
# `terraform -chdir=.../keycloak output -raw tatara_operator_client_secret`.
operatorOidcClientSecret: "REPLACE_WITH_KEYCLOAK_OUTPUT"
operatorOidcClientSecret value becomes ENC[AES256_GCM,...] and a sops: metadata block appends (matching values/tatara-memory/default.secrets.yaml shape). Per-Project SCM secret guidance (documented, NOT in this sops file): each Project's scmSecretRef Secret (keys token, webhookSecret) is created out-of-band in the tatara namespace, one per Project. They are not part of the operator release values because they are Project-scoped runtime data, not chart config. Record this in the file as a top comment and in ROADMAP (Task 11 already lists it). Likewise tatara-anthropic and tatara-cli-oidc Secrets are cluster-managed (replicated/sops), not rendered here.
-
Step 5: Verify the values render through helmfile template (no apply)
Expected: the operator's object count (helmfile -e default -f ~/Documents/infra/helmfile/helmfiles/tatara/helmfile.yaml.gotmpl -l application=tatara-operator template 2>&1 | grep -cE '^kind:'14from Task 10 step 2, plus possibly the helmfile hook output noise filtered out by the grep). Iftemplatefails on a missing published chart (chart 0.1.0 not yet pushed), this step requires Task 10 Step 4 (publish) to have run; otherwise expectError: chart "oci://.../tatara-operator" version "0.1.0" not found- publish first, then re-run. -
Step 6: GATED final step - helmfile diff (REQUIRE user confirmation before any apply)
Run the diff to preview what the release would change against the live cluster:
helmfile -e default -f ~/Documents/infra/helmfile/helmfiles/tatara/helmfile.yaml.gotmpl -l application=tatara-operator diff
+ additions), no - deletions on existing tatara releases. STOP HERE. Do NOT run helmfile apply. Present the diff to the user. Applying is a destructive/prod action (CLAUDE.md "Stop and ask on: prod configs") and requires explicit user confirmation. Preconditions the user must satisfy before apply: (a) the Keycloak change (Task 12) applied so the client+secret exist; (b) the operator container image :0.1.0 and the wrapper + ingester images published; © the tatara-anthropic, tatara-cli-oidc, and per-Project SCM Secrets present in the tatara namespace. Only after the user confirms and those preconditions hold should helmfile -e default ... apply run.
- Step 7: Commit (infra/helmfile repo)
Verification summary (superpowers:verification-before-completion)¶
Before claiming M6 done, confirm:
helm lint ~/Documents/tatara/tatara-operator/charts/tatara-operator->0 chart(s) failed.helm template t .../charts/tatara-operator | grep -cE '^kind:'->14(CRDs excluded; they live incrds/).ls .../charts/tatara-operator/crds/-> 4 CRD files, allscope: Namespaced.- ConfigMap carries all 12 pin-set env keys; Secret carries
OPERATOR_OIDC_CLIENT_SECRET; DeploymentenvFromreferences both. - NetworkPolicy
podSelectoristatara.dev/managed-by: tatara-operator; egress allowlist covers DNS/memory/chat/operator/443. tatara-ingestSA + Role (configmaps get/create/update/patch) + RoleBinding render.terraform -chdir=.../keycloak fmt -checkclean;validate->Success!;plan->3 to add.- Helmfile release
tatara-operator(chart 0.1.0) present;values/tatara-operator/{common,default}.yaml+ sopsdefault.secrets.yaml(encrypted) present. helmfile -e default ... diffran and was presented to the user; NO apply executed.- MEMORY + ROADMAP updated; the cross-milestone
tatara.dev/managed-bylabel requirement flagged for M1 + M4; deploy follow-ons (ANTHROPIC_API_KEY replication, wrapper+ingester image publish, per-Project SCM secrets) listed.
Flagged cross-milestone / out-of-band items (do not silently drop)¶
- M1/M4 code change (label): spawned ingest Job pods and agent Pods MUST carry
tatara.dev/managed-by: tatara-operatoror the NetworkPolicy will not select them. Amendinternal/ingest/job.goandinternal/agent/pod.go; this is the only behavioral dependency the chart imposes on earlier milestones. - Image publishing: operator, wrapper, and ingester images must exist in harbor before apply.
- Out-of-band Secrets:
tatara-anthropic(ANTHROPIC_API_KEY),tatara-cli-oidc(client creds), and per-ProjectscmSecretRefSecrets (token + webhookSecret) are cluster-managed, not chart-rendered. - Keycloak apply gating: the
tatara-operatorclient must be applied and its secret captured into the sops file before the helmfile apply succeeds (the manager fails readiness without a valid client-credentials secret).