Mosaic one model, many lenses

ADR 0020 — Deploy ingress (host / ingress_class / proxy_buffering) + app content_root

Ports two ee-pages/EE deployment improvements into the tessera deploy and web lenses.

Context

Ingress. ee-pages' assistant streams SSE per token, but the deployed ingress-nginx coalesces the whole response into one burst before the client sees it. EE fixed this with a per-environment deploy-DSL field proxy_buffering: "on" | "off" (commit f93a04c96): it renders the nginx.ingress.kubernetes.io/proxy-buffering annotation into the environment's values, and the chart's ingress template picks it up. Unset keeps the nginx default (buffering on, still honoring a response's X-Accel-Buffering: no). The parser rejects any value other than on / off.

Mosaic's tessera deploy specs (ADR 0010) render a per-spec Helm chart, but that chart has no ingress at all — no host, no class, no annotations. (The legacy v0 deploy lens predates tessera and already has host / ingress_class / proxy-body-size; this ADR does not touch it.)

content_root. A portal/content app whose main page is app content cannot own the deployment root under EE: with a home path set, the generated Leptos router emits a GET / redirect (and a page claiming / is routed at /), which collides with any block that mounts its own GET / (commit b2abba3e6, "content_root — let the deployment root belong to app content"). EE added an app-spec field content_root: bool (default false, byte-identical behavior when omitted): when true, the Leptos factory emits no root route at all — the root is free for the content route, and the UI keeps all its other routes and becomes a hidden entry point.

Mosaic's web lens is the equivalent surface: the generated Leptos router unconditionally claims the root (<Route path=() view=pages::Index/>), and the axum fallback serves the shell for unknown paths. A content app (e.g. mosaic-pages' doc site, P8) that wants to serve its content at / has no way to give the root up.

Decision

1. Tessera deploy specs gain three optional ingress fields (on each deploy { name: … } spec, so every environment can differ — the spec name is the environment, per ADR 0010):

app:
  deploys:
    - name: prod
      target: gcp-gke
      host: pages.example.com
      ingress_class: nginx
      proxy_buffering: off   # on | off (streaming / SSE surfaces)
  • host — the Ingress rule host. Unset → the rule is hostless (ClusterIP / port-forward usage).
  • ingress_class — spec.ingressClassName. Unset → cluster default.
  • proxy_buffering — renders the nginx.ingress.kubernetes.io/proxy-buffering annotation. Fail closed: any value other than on / off is a bad-deploy-proxy-buffering error and the spec is skipped (same hard-error discipline as the store fields).

2. The per-spec Helm chart gains an ingress template. deploy/<name>/helm/templates/ingress.yaml renders an Ingress from values.yaml:

ingress:
  host: pages.example.com      # "" when unset
  className: nginx             # absent when unset
  proxyBuffering: off          # "" when unset

The annotation renders only when proxyBuffering is set (the nginx controller key; other controllers ignore it). The chart otherwise stays minimal — TLS is out of scope (cert-manager is cluster policy, like in EE).

3. App-level content_root: bool (default false) on app::

app:
  name: pages
  content_root: true

When true, the web lens omits the root route from the generated Leptos router (path=()). Everything else is unchanged: the other pages keep their routes (the UI remains reachable, e.g. /commands, /aggregates, /docs), and the root belongs to whatever mounts it. This is the web-lens port of EE's suppression; the static site lens (zola) is unaffected (it is a separate surface with its own /), and the app lens is pure API (no root route).

The decision is stamped once in the plan (AppPlan.content_root); the render just reads it.

Consequences

  • DeploySpecPlan grows host, ingress_class, proxy_buffering; the values.yaml and the new ingress template change only for specs that set them (hostless ingress renders - with no host, exactly as the legacy v0 lens does).
  • Conformance goldens for any example with a tessera deploy spec are re-pinned (the chart gains ingress: values + the template).
  • content_root is a render-time fact: no data-model change, no migration. Apps that never set it render byte-identical output.
  • Out of scope (deliberately): TLS/cert-manager wiring, path prefixes, the EE auto-deploy trigger / mono-SHA tagging (GitLab-mono specific), and ui_prefix (unmerged upstream).