Per-Project External Exposure (memory + chat) - Design¶
Date: 2026-06-12 Status: design, pending user review
Goal¶
When a Project CR is reconciled, the tatara-operator provisions and exposes that project's per-project memory (already provisioned) and chat (newly provisioned) via a per-project Ingress on the shared host, behind the apps' own OIDC auth, so the tatara MCP tools (which hit the memory's /code/*, /memories, /queries routes) are reachable from outside the cluster, project-scoped by path.
Today mem-<project> is a ClusterIP-only Service; nothing externally exposes it, so tatara mcp on a developer machine cannot reach the code graph. This closes that gap and makes per-project chat a first-class provisioned component.
Decisions locked (from brainstorming)¶
- Topology: per-project PATH on the shared host (not per-project host) -
tatara.szymonrichert.pl/api/v1/memory/<project>and.../api/v1/chat/<project>. Reuses the one host TLS cert; matches the existing operator(/) + chat (/api/v1/chat) path pattern withrewrite-target: /$2. - Chat: the operator PROVISIONS a per-project
chat-<project>(Deployment + Service + ConfigMap, stateless, pointed atmem-<project>+ the operator), mirroring the memory stack. The single standalonetatara-chatrelease is retired once per-project chat is live. - Trigger: automatic - every project gets the ingress (+ chat) when the operator has
ingressHostconfigured. NoProjectspec field. - MCP wiring: tatara-cli gains a
--project/-pflag +TATARA_PROJECTenv (aws-cli--profilestyle); the cli composes the per-project memory URL. - Build: one spec, two phases. Phase 1 = memory ingress + cli
--project(immediate MCP value). Phase 2 = per-project chat provisioning + its ingress path.
Architecture¶
Operator config (new, cluster-supplied per rule 14)¶
internal/config/config.go Config gains:
| Field | env | infra value | purpose |
|---|---|---|---|
IngressHost | INGRESS_HOST | tatara.szymonrichert.pl | shared host for per-project paths |
IngressClassName | INGRESS_CLASS_NAME | nginx | ingressClassName |
MemoryPathPrefix | MEMORY_PATH_PREFIX | /api/v1/memory | base path; project appended |
ChatPathPrefix | CHAT_PATH_PREFIX | /api/v1/chat | base path; project appended |
ChatImage | CHAT_IMAGE | harbor.szymonrichert.pl/containers/tatara-chat:<tag> | per-project chat image |
The operator chart (charts/tatara-operator) exposes these as camelCase values -> ConfigMap keys (rule 6); the infra helmfile (helmfiles/tatara/values/tatara-operator/default.yaml) supplies them. Per-project ingress provisioning is gated on IngressHost being non-empty (so a cluster that doesn't want external exposure leaves it blank and gets no ingress).
Per-project Ingress (new)¶
The Project reconciler builds ONE networking.k8s.io/v1 Ingress per project, owner-ref'd to the Project (cascade delete), named <project> in the operator namespace:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: <project>
namespace: <operator-ns>
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /$2
ownerReferences: [<Project>]
spec:
ingressClassName: <IngressClassName>
rules:
- host: <IngressHost>
http:
paths:
- path: <MemoryPathPrefix>/<project>(/|$)(.*)
pathType: ImplementationSpecific
backend: { service: { name: mem-<project>, port: { number: 8080 } } }
- path: <ChatPathPrefix>/<project>(/|$)(.*) # phase 2
pathType: ImplementationSpecific
backend: { service: { name: chat-<project>, port: { number: <chatPort> } } }
No TLS block + no cert-manager annotation on this Ingress. The host's cert is provided by the existing operator Ingress (which owns the host's TLS); nginx merges all ingresses for the host and serves every path under that one cert. This avoids per-project certs and cert-manager contention. No nginx auth annotations - auth is app-level (both memory and chat run OIDC middleware that validates the Bearer token; the cli/tatara login token carries the tatara-memory audience).
Phase 1 ships the Ingress with only the memory path; phase 2 adds the chat path.
Per-project chat provisioning (new, phase 2)¶
Mirrors the memory provisioner but stateless. New builders (e.g. internal/chat/chat_builders.go): ChatConfigMap, ChatDeployment, ChatService for chat-<project>, using ChatImage, owner-ref'd, with config pointing at mem-<project> (memory endpoint) and the operator. Applied in the Project reconcile alongside the memory stack; health-gated into a new status.chat.phase. The chat container port (default 8080) is read from the chat image's known port; exposed by ChatService and referenced by the ingress.
Project CRD / Status¶
No new Spec field (automatic). ProjectStatus gains: - memory.externalEndpoint - e.g. https://tatara.szymonrichert.pl/api/v1/memory/<project>. - chat (*ChatStatus{ Phase, Endpoint, ExternalEndpoint }) - in-cluster + external chat URLs (phase 2).
These let a user read the exact MCP URL from kubectl get project <p> -o yaml.
RBAC + manager wiring¶
- Operator Role gains
networking.k8s.io/ingresses(get/list/watch/create/update/ patch/delete). (apps/deployments + core/services already granted for memory; chat reuses them.) SetupWithManageraddsOwns(&networkingv1.Ingress{})so the controller watches- reconciles owned ingresses.
tatara-cli --project / -p¶
Add a persistent flag --project/-p + TATARA_PROJECT env (precedence: flag > env > none). When set, the cli composes the per-project memory base URL: <memory-base>/<project> (e.g. baked base https://tatara.szymonrichert.pl/api/v1/memory + tatara -> .../api/v1/memory/tatara). The operator base URL is unchanged (single, not per-project). .mcp.json then needs only -p tatara (or the env):
The existing code_* MCP tools already take a project arg / TATARA_PROJECT; this flag wires the same value into the URL composition so the request actually lands on the right per-project backend.
Infra¶
helmfiles/tatara/values/tatara-operator/default.yaml adds ingressHost, ingressClassName, memoryPathPrefix, chatPathPrefix, chatImage. The standalone tatara-chat release is removed from helmfiles/tatara/helmfile.yaml.gotmpl in phase 2 (retired in favor of per-project chat). The tatara-chat image is built by its new CI pipeline (:<git-describe>); pin chatImage to that tag.
Phasing¶
- Phase 1 (memory ingress + cli): operator Config fields + the Ingress builder (memory path only) + RBAC +
Owns+status.memory.externalEndpoint; operator chart values + RBAC; infra values; tatara-cli--project/-p. Deliverable: thetatara mcptools reach the code graph externally. Ship operator + cli images via their CI (no local buildx), bump infra pins, deploy. - Phase 2 (chat provisioning): chat builders + provisioning in the reconcile +
status.chat+ the chat ingress path; retire the standalonetatara-chatrelease; pinchatImage.
Error handling¶
IngressHostblank -> skip ingress provisioning entirely (no-op; in-cluster only). Logged once.- Ingress apply uses server-side apply / create-or-update like the memory stack, owner-ref'd; a failed apply requeues (same pattern as
applyMemoryStack). - Chat health failure ->
status.chat.phase=Provisioning, requeue; does not block memory readiness.
Testing¶
- Operator: envtest unit tests for the Ingress builder (paths, rewrite, backends, owner-ref, gated on IngressHost) and the chat builders; reconcile test asserting the Ingress + chat objects are created and status endpoints populated.
- tatara-cli: unit test for URL composition (
-p/env ->/api/v1/memory/<project>), precedence, and the no-project case. - End-to-end (real cluster, after deploy):
kubectl get ingress <project>shows both paths;tatara -p tatara raw GET /code/entities?repo=tatara-operator&q=Reconcilereturns graph data throughtatara.szymonrichert.pl; thetataraMCP server in.mcp.jsonexposes workingcode_*tools.
Out of scope¶
- Per-project DNS/hosts (path-based chosen).
- nginx-level auth (app-level OIDC is the auth).
- tatara-tasks / tatara-gitlab-bridge (not deployed).
- Multi-cluster / multiple ingress controllers.