ADR 0023 — On-prem deploy: TLS issuer, app-level env, workflow-shaped chart
Status: accepted
Context
mosaic has a public on-prem deployment target (AGENTS.md → DevOps
infrastructure): a single-node k3s cluster (li7) behind the
*.eisler-systems.de wildcard, k3s Traefik on 80/443, cert-manager with a
letsencrypt-prod ClusterIssuer, and a self-hosted GitHub Actions runner on
the box (deploy-onprem.yml: render → docker build → k3s ctr images import
→ helm upgrade --install). The tessera deploy lens (ADR 0010, ingress
refined in ADR 0020) must meet that workflow where it stands. Three gaps:
- No TLS in the tessera front-end. A public ingress on a wildcard
domain needs a certificate; the on-prem cluster issues them with
cert-manager. The chart's
tlsvalues existed (added for the workflow's--setoverrides) but the tessera could not declare them — a spec that wants TLS had to rely on out-of-band helm flags. - No app-level env. The generated app is configured by env
(
MOAIC_CHAT_API_KEY,MOAIC_EMBED_BASE_URL,MOAIC_JWT_SECRET, …). Part config env bindings exist (ADR 0016), but model/API keys are not a part's concern — there was no place for app-wide bindings, so the operator had to hand-write helm--set env.*for every deploy. - Chart/workflow value mismatch. The workflow overrides
image.repository/image.tag/image.pullPolicy,ingress.enabled, anddb.password; the tessera chart renderedimageas a single string, rendered the Ingress unconditionally (a hostless spec would install an empty-host rule — catch-all on a public controller), and left secret envsecretKeyRefs non-optional (a missing AI key would wedge the whole Deployment).
Decision
Deploy spec: tls_issuer + tls_secret
deploys:
- name: li7
target: k8s
host: pages.eisler-systems.de
ingress_class: traefik
tls_issuer: letsencrypt-prod # cert-manager ClusterIssuer
# tls_secret: pages-tls # optional; defaults to <host, dots→dashes>-tls
tls_issuerenables the Ingress TLS block: thecert-manager.io/cluster-issuerannotation +spec.tls(host → secret).tls_secretoverrides the secret name; the default is the host with dots replaced by dashes plus-tls(the convention the workflow already used).- Fail closed (spec dropped, diagnostic raised):
deploy-tls-without-host— a certificate is issued for a host;deploy-tls-secret-without-issuer— a secret name without an issuer is meaningless.
App-level env: app.env
app:
env:
MOAIC_CHAT_API_KEY:
secret: true # never inlined; the operator provides the k8s Secret
MOAIC_EMBED_API_KEY:
secret: true
MOAIC_CHAT_BASE_URL: "https://openrouter.ai/api/v1" # scalar = plain value
MOAIC_JWT_SECRET: "dev-jwt-secret"
- Each entry is
var -> (value, secret, required): a scalar is a non-secret literal; a map is{value?, secret?, required?}. - A secret with a literal value is fail-closed
(
app-env-secret-literal, binding dropped) — a secret in the tessera would be inlined into the chart, which is exactly what the part-config rule already forbids (ADR 0016). - Plan order: app env first, then part config env bindings — a part binding
for the same var wins (
insertsemantics, same as today's part env). - Render: non-secret entries land in the chart's
envvalues map; secret entries land in thesecretslist and render assecretKeyRef(secret name = key = env var name, as before).
Chart: workflow-shaped values
The per-spec tessera chart (ADR 0020's deploy/<spec>/helm) now matches
what deploy-onprem.yml sets:
image: {repository, tag: latest, pullPolicy: IfNotPresent}— the runner builds the image, imports it into k3s, and overrides all three with--set(pullPolicyNever).ingress.enabled—truewhen the spec declares a host,falseotherwise; the Ingress template is guarded by it. A hostless spec installs no Ingress: on a public controller an empty host would match every request. (k3d-internal specs, e.g.data-sync, were hostless before and gain nothing from an Ingress object.)tls: {enabled, secretName, clusterIssuer}derived from the spec'stls_issuer/tls_secret(the workflow may still override via--set).- Secret env
secretKeyRefs carryoptional: trueunless the binding isrequired— the app boots without its AI key (the AI surfaces fail soft); arequiredbinding keeps the wedge (boot fails loudly instead of serving a degraded app). - In-cluster store passwords stay
requiredin the chart templates; the workflow derives a stable per-release password (sha256("$RELEASE-db")) so a redeploy does not rotate the credential under a live StatefulSet volume.
The workflow (deploy-onprem.yml) gains a repo input (default
eugeis/mosaic) — a second checkout renders app repos that live outside
the mosaic repository (e.g. eugeis/mosaic-pages) — and an
AI key secrets step: when /home/ee/.ssh/openrouter exists on the
runner it creates the MOAIC_CHAT_API_KEY / MOAIC_EMBED_API_KEY
secrets in the namespace (one key feeds both, both are OpenAI-compatible
families); without the file it warns and the deploy proceeds AI-less.
Consequences
- A public on-prem app is declarable end to end from the tessera:
host+ingress_class: traefik+tls_issuer: letsencrypt-prodrenders a Traefik Ingress with a cert-manager certificate — no out-of-band helm flags. - Model providers (chat/embed/rerank) are configured per app via
app.env+ the model registry (ADR 0017), not per deploy flag; the same tessera deploys AI-less anywhere the secrets are missing. - Re-pinned conformance goldens: every tessera chart's
values.yamlnow carries theimagemap +ingress.enabled(full-stack, data-sync). - The legacy v0 lens (
emit_helm) is untouched — it already had the workflow'simage.repository/image.tagshape.