Mosaic one model, many lenses

ADR 0010: One app, many targets — deploy specs are the master

  • Status: accepted
  • Date: 2026-09-28
  • Supersedes: nothing (complements ADR 0007, which covers the mosaic-spec deploy block; this ADR covers the tessera deploy { name … } specs)

Context

A product is not deployed to one place. The same mosaic app ships to our customers' own Kubernetes (bare k8s), to managed clusters on the clouds their workloads already live on (aws-eks, aws-ecs, gcp-gke, azure-aks, alibaba-ack), to plain docker and local for dev. And the datastores are the same story in miniature: sometimes a managed service (RDS, Cloud SQL, ApsaraDB RDS, Azure Database for PostgreSQL), sometimes a self-hosted engine in the cluster (postgres, mysql, redis, clickhouse, qdrant, weaviate, opensearch, milvus).

The old shape — one deploy block, one target, terraform that also rendered the k8s workload inline — could not express "same app, prod on GCP with Cloud SQL, staging on our own k3s with an in-cluster postgres".

Decision

An app declares any number of deployment specs:

deploys:
  - name: prod-gcp          # -> deploy/prod-gcp/
    target: gcp-gke         # local | docker | k8s | aws-eks | aws-ecs |
    region: europe-west1    #   gcp-gke | azure-aks | alibaba-ack (closed vocab)
    replicas: 2
    db:
      name: shop
      engine: postgres      # postgres|mysql|mariadb|redis|dynamodb|clickhouse
      managed: true         # default: cloud targets managed, own-infra not
    vector:
      name: shop-vectors
      engine: qdrant        # opensearch|qdrant|weaviate|milvus|pinecone
  - name: staging-k8s
    target: k8s             # own infra: no terraform, chart only
    db: { name: shop, engine: postgres }   # in-cluster via the chart

Rules, all fail-closed in the plan:

  • name is required and unique (it is the deploy/<name>/ directory).

  • target is a closed vocabulary; unknown targets are an error.

  • Each spec resolves a cloud (aws|gcp|azure|alibaba|none) and a per-cloud default region.

  • Stores are closed vocabularies too. managed defaults to true on cloud targets and false on own-infra targets. dynamodb and pinecone exist only as managed services; self-hosted stores require a k8s target.

  • A managed store must have a stable first-party Terraform resource with a connection URL on that cloud (deploy-managed-unavailable, fail-closed) — otherwise the chart would reference a DSN secret Terraform cannot create:

    cloudmanaged db enginesmanaged vector engines
    awspostgres, mysql, mariadb, redis, clickhouse, dynamodb¹opensearch
    gcppostgres, mysql, mariadb, redis, dynamodb¹— (use managed: false)
    azurepostgres, mysql, redisall (map to AI Search)
    alibabapostgres, mysql, mariadb, redis, clickhouse, dynamodb¹— (use managed: false)

    ¹ managed document store, no DSN: the app is wired through the provider SDK, and the chart installs nothing for it (enabled: false in values).

    The matrix lives in the plan (managed_store_available) and must stay in sync with tf_db_url / tf_vector_url in mosaic-render.

Each spec derives its artifacts; the app, the DSL, and the codegen are identical across specs:

  • local → deploy/<name>/run.sh; docker → deploy/<name>/Dockerfile.
  • cloud targets → deploy/<name>/infra/ Terraform: the managed cluster (EKS/GKE/AKS/ACK module), the image registry, the managed store resources (RDS/DynamoDB/ElastiCache/OpenSearch, Cloud SQL/Memorystore, Azure PostgreSQL/MySQL/Redis/AI Search, ApsaraDB RDS/Redis/ClickHouse), and — for k8s targets — the namespace + DSN secrets. Terraform never renders the workload.
  • k8s targets → deploy/<name>/Dockerfile + deploy/<name>/helm/ chart: the image is the bare binary (the chart passes serve --bind as container args); the chart is the app Deployment/Service plus the stores. mode: cluster renders the store in-cluster (postgres/mysql/ mariadb/redis/clickhouse statefulsets or deployments; qdrant/weaviate/ opensearch/milvus workloads) and materializes the DSN into a chart-owned secret; mode: external reads the DSN secret Terraform created (<app>-db / <app>-vector) or that the operator provides.

The app reads exactly two store env vars in every mode: MOSAIC_DB_URL and MOSAIC_VECTOR_URL.

Consequences

  • "Deploy to another cloud" is a spec, not a fork: same image, same DSL, same generated app; only deploy/<name>/ differs.
  • The chart is the single k8s footprint for any cluster, managed or not — what ee-helm does for the enterprise engine, generalized over engines.
  • Cloud testing can be per-cloud: render the spec, terraform apply (or reuse the managed resources), helm install with the chart values, and the app is exercised against the real managed services.
  • Terraform output is codegen, reviewed like any generated file; per-cloud drift (which engines exist where) is a renderer concern, invisible to the DSL.
  • aws-ecs has no cluster: terraform owns the whole footprint (Fargate service + ALB), store DSNs wired into the task definition.
  • In-cluster store passwords live in the chart values (test/dev-grade); production footprints should prefer managed: true or mode: external so no credential is in the values file.

Proof

  • cargo test -p mosaic-core → tessera_plan::deploy_spec_tests::* (defaults, managed inference, cloud-only engines, managed-availability guard, name rules, alibaba/azure targets).
  • cargo test -p mosaic-render → app::terraform_generation::* (per-spec infra + helm for aws-eks and gcp-gke).
  • examples/full-stack/tessera.yaml carries three specs (prod-aws, prod-gcp, staging-k8s); cargo run -p mosaic-cli -- conformance pins the rendered deploy/<name>/ trees in the golden.
  • scripts/e2e/ — the k8s e2e harness + peer fixtures; .github/workflows/e2e-*.yml run it on a GH-hosted k3d cluster, on the self-hosted on-prem runner, and (secrets-gated) against the clouds.