Installing the Operator¶
By the end of this page the tatara-operator release is running in your cluster, the six tatara.dev CRDs are installed, and every future change to the platform goes through a pull request with a rendered diff attached to it.
The operator deploys through the tatara-helmfile GitOps repository and nowhere else. There is no supported path using helm install directly or kubectl apply from local files. Every deploy is a PR, reviewed as a rendered diff, and applied by an in-cluster Actions Runner Controller (ARC) pipeline.
GitOps only - no manual deploys
helm upgrade, kubectl set image, kubectl patch, and kubectl edit are never used to ship the operator or any tatara component. Any live patch you apply during incident response has to be re-asserted through the helmfile immediately afterwards, so the live state matches the repo.
Before you start¶
Check that all four of these exist:
- An in-cluster ARC runner set labelled
arc-runner-tatara-helmfile. The runner set, thetatara-helmfile-deployerServiceAccount, and its cluster-admin ClusterRoleBinding are provisioned separately in your cluster bootstrap helmfile, not intatara-helmfileitself. - A Harbor OCI registry, or a compatible substitute, reachable from the cluster and from the ARC runner for chart pulls.
- A SOPS PGP key. The encrypted files under
values/tatara-operator/are decrypted at deploy time. - An OIDC provider with the required client registrations. See OIDC clients below.
1. Repo layout¶
Fork tatara-helmfile into your organization and treat it as a private, team-restricted repo. The runner ServiceAccount is cluster-admin scoped, so anything merged here can modify any cluster resource.
tatara-helmfile/
helmfile.yaml.gotmpl # single 'default' env
.hook.sh # prepare/presync/postsync hook: applies raw/*.pre.yaml,
# sops-decrypts *.pre.secrets.yaml, runs hooks/*.pre.sh
values/
common.yaml # bucket-wide: imagePullSecrets: regcred
memory-stack.yaml # shared memory sizing for EVERY project release
tatara-operator/
common.yaml # image.tag pin
default.yaml # ingress, webhook, OIDC, memory images, S3, scheduling
default.secrets.yaml # sops-encrypted: operator OIDC secret + SCM PAT
hooks/
*.pre.sh # presync scripts (CRD helm-ownership adoption)
raw/
*.pre.yaml # plain manifests applied pre-sync (OBC, PDB)
*.pre.secrets.yaml # sops-encrypted Secrets applied pre-sync
project-tatara/
common.yaml # Project + Repository CR values (tatara-project chart)
project-infrastructure/
common.yaml # second Project's CR values
project-mtg/
common.yaml # third Project's CR values
.github/workflows/
diff.yaml # PR trigger: helmfile diff, posted as a sticky comment
apply.yaml # push to main trigger: helmfile apply
lint.yaml # pre-commit / lint checks
The reference bucket declares these releases:
| Release | Chart | Namespace |
|---|---|---|
tatara-operator | oci://<registry>/charts/tatara-operator | tatara |
project-tatara | oci://<registry>/charts/tatara-project | tatara |
project-infrastructure | oci://<registry>/charts/tatara-project | tatara |
project-mtg | oci://<registry>/charts/tatara-project | tatara |
One release per Project, all rendering the same tatara-project chart. Adding a fourth Project means adding a release block and a values/project-<name>/ directory, nothing more.
The retired chat release
The bucket also carries a chat release pinned at installed: false. That component was retired in the cutover to the task-centric platform, and Task.status.notes carries what it used to. The block stays in the file so helmfile keeps tracking the uninstall rather than forgetting the release exists.
Every project-* release declares needs: tatara/tatara-operator, which forces the operator - and therefore its CRDs - to apply before any Project or Repository CR is rendered.
Each project-* release also loads values/memory-stack.yaml before its own values. That file is the one place per-Project Postgres and Neo4j sizing is declared, so a sizing fix cannot land on one Project and silently miss the others. A Project can still override a single field afterwards, with a stated reason.
2. OIDC clients and SCM secret¶
The operator needs several credential groups before it can reconcile anything. They are Kubernetes Secrets, referenced by name in the chart values.
OIDC clients¶
The full platform uses four Keycloak clients; see Identity & OIDC for the authoritative inventory. Two of them are rendered by the operator chart:
| Client | Flow | Purpose |
|---|---|---|
tatara-operator | Client credentials | The operator authenticates outbound calls to the SCM and to the wrapper REST API |
tatara-cli | Device authorization (public) | The CLI OIDC token wrapper pods forward to the operator's MCP server |
The chart renders the CLI credentials into a Secret named by cliOidcSecretName (keys client-id, client-secret), and the operator client secret into a separate Secret (key OPERATOR_OIDC_CLIENT_SECRET). Supply both through default.secrets.yaml. The remaining two clients belong to their own component charts.
SCM secret¶
This Secret holds the bot identity token and the webhook HMAC secret. The chart renders it when scmToken, scmWebhookSecret, and scmSecretName are all set in the SOPS values file.
Create a fine-grained PAT for the bot account with:
- Repository permissions:
Contents: Read and write,Issues: Read and write,Pull requests: Read and write,Metadata: Read - Organization permissions:
Members: Read, for org membership checks
# values/tatara-operator/default.secrets.yaml (sops-encrypt before commit)
scmSecretName: "tatara-scm"
scmToken: "<github-fine-grained-pat>"
scmWebhookSecret: "<random-32-byte-hex>"
Then configure a matching webhook on each enrolled repository:
- Payload URL:
https://<your-domain>/operator/webhooks/<project-name> - Content type:
application/json - Secret: the same value as
scmWebhookSecret - Events:
Issues,Issue comments,Pull requests,Pull request reviews
Create a PAT for the bot account with scopes api, read_repository, write_repository.
# values/tatara-operator/default.secrets.yaml (sops-encrypt before commit)
scmSecretName: "tatara-scm"
scmToken: "<gitlab-personal-access-token>"
scmWebhookSecret: "<random-32-byte-hex>"
Then configure a matching webhook on each enrolled project:
- URL:
https://<your-domain>/operator/webhooks/<project-name> - Secret token: the same value as
scmWebhookSecret - Triggers:
Issues events,Comments,Merge request events
The rendered Secret carries keys token and webhookSecret. The operator finds it through the SCM_SECRET_NAME ConfigMap key. Read the exact per-project URL back from Project.status.webhookURL after the Project applies rather than assembling it by hand.
Anthropic and OpenAI secrets¶
Two more Secrets have to exist before the operator starts agent pods:
# tatara-anthropic: oauth-token key.
# Rendered by the chart when anthropicOauthToken + anthropicSecretName are set.
anthropicSecretName: "tatara-anthropic"
anthropicOauthToken: "<anthropic-oauth-token>"
# lightrag-openai: LLM_BINDING_API_KEY key.
# Read by each Project's lightrag Deployment. Needed only while memory is enabled.
openaiSecretName: "lightrag-openai"
openaiApiKey: "<openai-api-key>"
Optional: callback HMAC secret¶
If you want the operator to verify HMAC-SHA256 signatures on internal turn-complete callbacks from wrapper pods - worth doing alongside a NetworkPolicy, not instead of one:
The rendered Secret carries key callback-hmac-secret.
3. Operator release values¶
Edit values/tatara-operator/default.yaml. Every scalar maps 1:1 to a SCREAMING_SNAKE ConfigMap key the manager consumes through envFrom. No inline Pod-spec env values are used anywhere.
Ingress and URLs¶
# The operator's own Ingress: public webhook + API endpoint.
ingress:
enabled: true
host: tatara.example.com
path: /
className: nginx
# externalWebhookBase is stamped into Project.status.webhookURL.
# It has to match the public hostname and the operator's webhook route prefix.
externalWebhookBase: "https://tatara.example.com/operator/webhooks"
# callbackUrl is the in-cluster Service wrapper pods POST turn results to.
# Use the internal Service DNS (tatara-operator-internal, port 8082).
callbackUrl: "http://tatara-operator-internal.tatara.svc:8082"
callbackUrl has to be reachable by agent pods
Wrapper pods validate the callback URL scheme on startup and reject an https-only configuration when the internal Service serves plain HTTP. Set callbackUrl to the internal Service DNS, not the public ingress hostname.
OIDC¶
oidcIssuer: "https://auth.example.com/realms/tatara"
oidcAudience: "tatara-operator"
operatorOidcClientId: "tatara-operator"
# Secret references, matching the values in default.secrets.yaml
scmSecretName: "tatara-scm"
anthropicSecretName: "tatara-anthropic"
cliOidcSecretName: "tatara-cli-oidc"
openaiSecretName: "lightrag-openai"
Image pins¶
tatara-built images are pinned by semver (vX.Y.Z); third-party images pin their own upstream tags or digests. The operator stamps these into the native objects it provisions per Project. Under semver push-CD (section 6) a pipeline-opened PR advances them; you do not hand-edit them in the normal flow.
# Operator manager image tag (values/tatara-operator/common.yaml)
image:
tag: "v2.9.0" # semver; the pipeline propagates this on release
# Per-Project memory stack images (values/tatara-operator/default.yaml)
memoryImage: "harbor.example.com/containers/tatara-memory:v0.4.6"
lightragImage: "harbor.example.com/proxy-ghcr/hkuds/lightrag@sha256:<digest>"
neo4jImage: "neo4j:2026.04.0"
grafanaMcpImage: "grafana/mcp-grafana:0.17.0"
ingesterImage: "harbor.example.com/containers/tatara-memory-repo-ingester:v0.2.11"
# Pull secret for every operator-spawned workload (neo4j, lightrag, memory, cnpg).
imagePullSecret: "regcred"
Agent scheduling and security¶
The wrapper image declares USER agent, a non-numeric name for uid 10001. The kubelet cannot verify a non-numeric USER is non-root, so without an explicit agentRunAsUser a runAsNonRoot: true hard-fails with CreateContainerConfigError. Set both:
The persistent agent workspace needs two more values, and neither is a preference:
# Operator-wide switch for the per-Task workspace PVC and the per-Project build
# cache. Chart default false, so the CRD and controller can land before any PVC
# is created. ANDed with each Project's own spec.workspace.enabled.
agentWorkspacePvcEnabled: true
# The wrapper IMAGE's gid, not a cluster fact. A freshly provisioned CephFS
# subvolume is root:root mode 0755 and the agent runs as uid 10001, so without
# this the volume mounts unwritable and the first clone dies on permission denied.
agentFsGroup: "10001"
# The CephFS CSI driver reports fsGroupPolicy: File. With Kubernetes' default
# ("Always") the kubelet recursively chowns the whole volume on every mount,
# which on a Go build cache costs more than the cache saves.
agentFsGroupChangePolicy: "OnRootMismatch"
Agent pod resource bounds:
Per-Project memory ingress¶
The operator creates an Ingress for each Project's memory stack at reconcile time. Supply the cluster-specific IngressClass and rewrite annotation:
S3 conversation persistence: removed, do not configure it¶
There is no S3 conversation persistence to turn on. It was removed on 2026-07-04, together with the cross-pod conversation restore it existed to serve, and tatara-claude-code-wrapper carries a regression test that fails the build if the symbols come back. Nothing carries a Claude session across a pod boundary now; what carries forward is Task.status.notes and the turn-0 context bundle, which every pod gets in full.
The operator chart therefore has no s3Endpoint, s3Bucket, s3Region, s3KeyPrefix, s3ForcePathStyle or s3ConversationRetentionHours value. Setting any of them in helmfile is inert: Helm accepts an undeclared value without complaint and no template reads it.
values/tatara-operator/default.yaml still sets five of them
They are left over from the removed feature and are read by nothing. The comment above them describes the wiring as forward-compatible with a build that would consume them; no such build exists on main. Treat the block as dead configuration to be deleted in tatara-helmfile, not as a feature to enable here.
s3SecretName is the one survivor and IS still a live chart value: when set, the operator Deployment picks up AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY from that Secret via a conditional envFrom. The client that used those credentials has been deleted along with the rest, so it currently mounts credentials nothing consumes.
Memory backups (optional)¶
memoryBackup turns on continuous WAL archiving plus a daily base backup to an object store, for every per-Project CNPG cluster. It is off by default and every field is empty, which keeps the chart cluster-agnostic.
memoryBackup:
enabled: true
endpointUrl: "http://rook-ceph-rgw-ceph-objectstore.rook-ceph.svc"
bucket: "tatara-pg-backup"
credentialsSecretName: "tatara-pg-backup"
retentionPolicy: "7d"
scheduleCron: "0 0 2 * * *" # cnpg schedule: SIX fields, not five
A partial backup config fails closed, and that is on purpose
enabled: true alone does nothing: bucket and credentialsSecretName are both required. A partial config produces no archiving at all, with a warning in the operator log, because a broken archive command makes PostgreSQL retain every WAL segment until the volume fills. Note also that scheduleCron is a CNPG six-field schedule (seconds first), not the five-field Kubernetes form.
4. Release ordering¶
Helmfile applies releases in file order, subject to needs::
tatara-operator (installs CRDs via templates/crds.yaml)
project-tatara (needs: tatara/tatara-operator)
project-infrastructure (needs: tatara/tatara-operator)
project-mtg (needs: tatara/tatara-operator)
The project-* releases render Project and Repository custom resources through the tatara-project chart. Because they declare needs:, helmfile blocks their apply until the operator release - and therefore every tatara.dev CRD - is confirmed healthy. Never apply a project-* release to a cluster where the CRDs are absent.
CRD management and helm ownership
CRDs are bundled in templates/crds.yaml and applied on every helm upgrade (installCRDs: true by default). A CRD that already exists on the cluster without helm ownership metadata fails the upgrade with invalid ownership metadata and blocks the whole release.
The reference bucket handles this with an idempotent presync hook, values/tatara-operator/hooks/crd-adopt.tatara-operator.pre.sh, which stamps ownership onto any of the six CRDs that exists and is not already owned. On a fresh cluster it is a no-op. To do it by hand:
for crd in projects.tatara.dev repositories.tatara.dev tasks.tatara.dev \
issues.tatara.dev mergerequests.tatara.dev queuedevents.tatara.dev; do
kubectl label crd "$crd" app.kubernetes.io/managed-by=Helm --overwrite
kubectl annotate crd "$crd" \
meta.helm.sh/release-name=tatara-operator \
meta.helm.sh/release-namespace=tatara --overwrite
done
The CRDs carry helm.sh/resource-policy: keep
The chart annotates every CRD it renders with helm.sh/resource-policy: keep. A helm uninstall, or a helmfile apply that prunes the release, will not remove them, and a helm rollback will not revert them. Removing a CRD is an explicit kubectl delete crd <name>, and it cascades to every CR of that kind.
5. The deploy flow¶
Every change to the cluster - image bumps, config changes, enrollment CR updates - takes the same path:
sequenceDiagram
participant Dev as Developer
participant PR as Pull Request
participant ARC as ARC Runner
participant K8s as Kubernetes
Dev->>PR: Open PR (bump image.tag and/or chart version)
PR->>ARC: Trigger diff workflow
ARC->>ARC: helmfile -e default diff --detailed-exitcode --suppress-secrets
ARC->>PR: Post sticky diff comment (ANSI-stripped, truncated at 60 KB)
alt Exit code 1 or other error
ARC->>PR: Block merge (chart-not-found / render / sops decrypt failure)
else Exit code 0 or 2
ARC->>PR: Check passes (no change, or a reviewable diff)
end
Dev->>PR: Review rendered diff, approve, merge to main
PR->>ARC: Trigger apply workflow (concurrency-guarded, cancel-in-progress: false)
ARC->>K8s: helmfile -e default apply --suppress-secrets
alt Apply succeeds
K8s-->>ARC: All releases healthy
ARC->>K8s: Apply pre-sync raw manifests (OBC, PDB, SCM/Grafana Secrets)
else Apply fails
K8s-->>ARC: --rollback-on-failure triggers helm rollback
end Diff workflow¶
diff.yaml runs on every PR targeting main. It:
- Installs tooling with
mise install: helm, helmfile, kubectl, sops, and the helm-secrets and helm-diff plugins. - Imports the GPG private key from
GPG_PRIVATE_RSA_B64to decrypt SOPS files. - Logs in to Harbor OCI using
HARBOR_ROBOT_KUBERNETES_USERNAMEandHARBOR_ROBOT_KUBERNETES_PASSWORD. - Runs
helmfile -e default diff --detailed-exitcode --suppress-secrets. - Posts or updates a sticky PR comment, even when the diff errors, so the reviewer sees the failure reason rather than a silent red check.
- Blocks the merge on any exit code other than
0(no change) or2(diff present). Exit code1means a chart-not-found, render, or sops decrypt failure.
Apply workflow¶
apply.yaml runs on every push to main:
- Concurrency group
tatara-helmfile-apply,cancel-in-progress: false. Overlapping pushes queue; they never cancel a running apply. - Timeout 900 seconds per release (
helmDefaults.timeout), which covers image pulls and ServiceMonitor/CRD settling. - Rollback through
--rollback-on-failureinhelmDefaults.syncArgs. A failed apply rolls the release back to its previous revision. - Server-side apply. Helm 4 applies server-side by default;
--force-conflictslets the GitOps deploy reclaim fields an emergencykubectloperation previously took ownership of. - Pre-sync raw manifests. After the apply, the workflow re-applies the plain manifests in
values/tatara-operator/raw/withkubectl apply, sops-decrypting the*.secrets.yamlones first. These are the conversation-bucket ObjectBucketClaim, the operator PodDisruptionBudget, and the SCM and Grafana Secrets - not Project or Repository CRs. Applying them explicitly keeps them idempotent on every run, even when Helm decides the operator release is unchanged and skips the presync hook. Project and Repository CRs come from thetatara-projectchart through theproject-*releases (sections 1 and 4).
6. Release versioning (semver push-CD)¶
Deploys are semver and pipeline-driven. You almost never hand-edit a pin.
How a release ships¶
Every merged PR declares its significance: a human sets a semver:<level> label (major, minor, or patch) on the PR, or the implementer declares it on the accepted outcome that closed the Task. A reviewer may escalate that level and never lower it. On merge to the component's main, the release pipeline:
- Cuts the tag. Computes the next
vX.Y.Zfrom the merged PR'ssemver:*label. - Publishes artifacts. Builds and pushes the image at
:vX.Y.Z. The required-checks pipeline already pushed a:<shortSHA>traceability tag for the same commit, and Harbor's containers project has tag immutability, so re-pushing it here would fail. It then packages both charts (tatara-operatorandtatara-project) at the bareX.Y.ZwithappVersioncarryingvX.Y.Z, and pulls each one back to prove neither is missing. That check exists because a partial publish wedges every later apply. - Propagates the pins. Opens a bot PR against
tatara-helmfilerewriting every pin atomically in one commit: the chart-version pins fortatara-operatorand for eachproject-*release take the bareX.Y.Z, and the operatorimage.tagtakesvX.Y.Z. - Applies and closes. The component PR itself was merged by the operator, from the reviewer's accepted verdict - no agent tool exposes merge. The
tatara-helmfilepin PR is an ordinary PR: nothing arms it to merge itself, so it waits for a human or your own branch-protection rule, like any other change to that bucket. Once it lands, the apply workflow rolls the pins out (section 5), and on a successful apply the operator closes the originating issue.
# What the pipeline writes into tatara-helmfile. Do not hand-edit in normal flow.
# values/tatara-operator/common.yaml
image:
tag: "v2.9.0" # image at :vX.Y.Z
# helmfile.yaml.gotmpl
- name: tatara-operator
version: 2.9.0 # chart at bare X.Y.Z
- name: project-tatara
version: 2.9.0
- name: project-infrastructure
version: 2.9.0
- name: project-mtg
version: 2.9.0
Do not hand-edit deploy pins, and never re-run a green release job
In the normal flow the pipeline owns the pins. Tag mode is not idempotent: re-running a green release job tries to re-cut a tag that already exists. Roll forward with a new PR instead.
Break-glass: manual pin bump¶
Bump the pins by hand only when the pipeline is unavailable, for instance while recovering from a stuck release. The invariant is that the chart versions and the image tag move together.
Bump every pin in the same PR
Bumping only the chart versions leaves the old image running. Bumping only the image tag against stale charts applies manifests rendered from an older chart, which may lack fields or ConfigMap keys the new image expects. Change the tatara-operator chart version, every project-* chart version, and image.tag in one PR, to versions actually published in your registry.
Harbor's retention policy collects old chart tags, so pinning backward to a collected X.Y.Z fails the apply with FetchReference ... not found. Roll forward to a published version rather than back. Find published versions through helm search repo or the Harbor UI.
Local validation¶
mise install # helm, helmfile, kubectl, sops, plugins
helm registry login <your-registry> # OCI chart pull
helmfile -e default diff --suppress-secrets # validate against your current kube-context
Next steps¶
The operator is running but has nothing to work on. Go to Your First Project to apply your first Project CR.