Skip to content

feat(ai): make the AI platform reference architecture orderable - #250

Draft
grubmeshi wants to merge 60 commits into
mainfrom
feat/stackit-ai-ref-arch
Draft

feat(ai): make the AI platform reference architecture orderable#250
grubmeshi wants to merge 60 commits into
mainfrom
feat/stackit-ai-ref-arch

Conversation

@grubmeshi

@grubmeshi grubmeshi commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Turns the AI Platform reference architecture from a design document into orderable building blocks, and builds the hub prerequisites it depends on.

LiteLLM plus Langfuse install into a Kubernetes namespace landing zone, and LiteLLM is registered as a meshStack platform so application teams order governed model access as a self-service item. STACKIT is the sovereign reference implementation; the runtime and the model backend are both pluggable.

What an application team sees

One mandatory landing-zone building block, named for what it delivers rather than for the products that deliver it: AI Model Access (modules/ai/model-access). LiteLLM and Langfuse stay hidden inside it. It takes zero manual input — every input is STATIC or assigned from tenant context, because a mandatory block that stops to ask a human defeats the purpose, and API-driven tenant creation only succeeds when inputs are defaulted or static.

LiteLLM and its virtual keys are the central component that manages model access. The gateway is what hides the shape of the backends. Behind it, Azure is a single OpenAI instance the platform engineers provisioned, and STACKIT is one or more Model Serving deployments — tenants never see either. Because the gateway sits in between, the AI platform can offer several landing zones that resolve to different backend models, or to load-balanced pools across them. A shared upstream credential is not a compromise in the design; it is the point of putting a gateway there.

New modules

Module What it does
ai/model-access The one mandatory tenant-facing block. 12 buildingblock tests, 3 BBD tests.
ai/litellm The gateway, with console SSO. 14 tests.
ai/langfuse One instance per tenant, on Langfuse v4.
ai/clickhouse Shared, via the official operator.
ai/azure-openai Registers an Azure OpenAI deployment as a backend.
kubernetes/ingress HAProxy, cert-manager and a DNS-01 wildcard certificate. Consolidates six foundation copies. 4 tests.
kubernetes/platform meshStack platform registration plus its namespace landing zone. Provider-agnostic.
stackit/ske Cluster and kubeconfig, plus a backplane.
stackit/dns Zone, record sets and a provider-free zone/ submodule. 3 tests.
stackit/model-serving, stackit/postgresflex, stackit/storage-bucket Extended for this architecture.

modules/ai/litellm-team was deleted; ai/model-access replaces it.

Reference architectures

  • ai-platform is now orderable — integration file, composing building block, logo. It installs the gateway and ClickHouse once, registers LiteLLM as a custom platform of type AI-MODEL, and creates one landing zone per entry with ai/model-access in mandatory_building_block_refs.
  • stackit-kubernetes composes SKE, ingress, platform registration and the DNS record set into a cluster whose landing zone promises namespaces and a working HTTPS hostname. It creates its own backplane, so the composition yields one service account.
  • stackit-landingzone gains a kubernetes option and an ai option on top of it, both shaped like its existing network input. This makes ai-platform the first reference architecture consumed as a component by another one.

The Let's Encrypt rate limit this also fixes

stackit.run is a public suffix, so <zone>.stackit.run is the registered domain and the limit of 50 certificates per 168h applies to all of it. The hourly ske/ske-starterkit smoke test was red in about 9 of 11 runs with 429 ... too many certificates (50) already issued, because each run mints two brand-new hostnames. A stable hostname would instead hit the stricter Duplicate Certificate limit of 5 per week, because teardown deletes the certificate Secret every run. One wildcard certificate in a long-lived namespace, served through HAProxy's default TLS certificate, is what outlives the per-run lifecycle.

Things worth a reviewer's attention

  • Two provider-constraint exceptions, both deliberate and justified next to the constraint. ai/model-access pins ncecere/litellm = 2.0.1, because a virtual key is readable only at creation and a forced recreate takes the credential away from a running application. stackit/storage-bucket bounds hashicorp/aws >= 4.0, < 5.0, because AWS provider v5 always sends LocationConstraint in CreateBucket and STACKIT's StorageGRID rejects it. Neither is fixable without an upstream change.
  • LiteLLM's console SSO caps at five users. The gate counts every row in LiteLLM_UserTable and fires at login initiation, so the sixth login locks out everyone already in. Mitigated by contributing zero rows from Terraform and by declaring the three resources that grow the table forbidden. The cap is accepted, not solved.
  • One OIDC client is shared across tenants. With LANGFUSE_DEFAULT_ORG_ID set, anyone the identity provider authenticates becomes a member of whichever tenant's instance they open. The alternative turns off the auto-join that lets app teams see their own traces without a member sync. Accepted for now and documented; registering concrete clients belongs to the stackit-landingzone integration.
  • cert-manager crds.keep = false is carried forward from all five foundation copies, which keeps the migration a pure code move. With false, destroying the release makes Helm delete the CRDs and the garbage collector then removes every Certificate, Issuer, ClusterIssuer and Order on the cluster. cert_manager_crds_keep is an input for anyone who wants to close it off.

Not in this PR

  • The foundation migrations. ICF, LCF and TCF each carry their ingress, cluster and platform-registration migrations on a branch of their own, committed and planned but not applied. They will be separate PRs, cross-referenced from here.
  • No e2e test for the AI platform block. It installs Helm charts into a live cluster, the most expensive kind of e2e in this repo.
  • modules/aks/platform and an azure-kubernetes building block. Follow-ups. Three AKS platform-registration copies wait for the first of them.
  • Tenant spend visibility. Tenants get 429s at budget with no view of headroom; meshStack metering is the intended home.

Validation

tofu validate, terraform-docs and the unit tests pass; pre-commit passes under nix develop; task diagrams:check is green. The scorecard does not cover reference architectures at all, so ai-platform, stackit-kubernetes and stackit-landingzone are unscored by design.

🤖 Generated with Claude Code

Draft scaffold for discussion — README and diagrams only.

Centers on enterprise-grade sovereign model serving: OpenWebUI as the
user-facing interface, LiteLLM as the governance choke point (virtual keys,
budgets, model allow-lists), Langfuse for tracing and usage attribution, and
STACKIT AI Model Serving as the sovereign backend.

Adds a second diagram for the pluggable-model (bring your own model) variant so
the STACKIT-specific vs. generic AI platform scoping question can be discussed
against a visual.

FlowiseAI and RAGFlow from the original demo stack are intentionally left out to
keep the focus on serving, observability and governance.

Also scaffolds modules/stackit/model-serving as a minimal first cut around
stackit_modelserving_token, and extends the diagram emoji vocabulary with
user/chat/gateway/observability concepts.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Scorecard Check

Scorecard run on commit 67d164131d0409018e45ab69c206c5db04ba9e66 relative to origin/main

📊 meshstack-hub Module Scorecard

Generated: 2026-08-14 | Modules scanned: 13 | Categories: 5

📋 Per-Module Category Summary

Score per category per building block. n/a = category does not apply to this module.

Module Overall Core Structure Integration Azure Backplane STACKIT Backplane Testing
ai/azure-openai 🟢 86% 🟢 83% 🟢 100% n/a n/a 🔴 0%
ai/clickhouse 🟡 67% 🟢 86% n/a n/a n/a 🔴 0%
ai/langfuse 🟡 67% 🟢 86% n/a n/a n/a 🔴 0%
ai/litellm 🟡 67% 🟢 86% n/a n/a n/a 🔴 0%
ai/model-access 🟢 86% 🟢 83% 🟢 100% n/a n/a 🔴 0%
kubernetes/ingress 🟡 67% 🟢 86% n/a n/a n/a 🔴 0%
kubernetes/platform 🟡 67% 🟢 86% n/a n/a n/a 🔴 0%
ske/ske-starterkit 🟢 91% 🟢 100% 🟢 92% n/a n/a 🟡 67%
stackit/dns 🟢 92% 🟢 100% 🟢 100% n/a 🟢 100% 🔴 33%
stackit/model-serving 🟢 92% 🟢 100% 🟢 100% n/a 🟢 100% 🔴 33%
stackit/postgresflex 🟢 92% 🟢 100% 🟢 100% n/a 🟢 100% 🔴 33%
stackit/ske 🟡 79% 🟢 86% n/a n/a 🟢 100% 🔴 33%
stackit/storage-bucket 🟢 100% 🟢 100% 🟢 100% n/a 🟢 100% 🟢 100%

⚠️ 12 modules have failing checks — failing categories are expanded below.

Core Structure — some checks failing

Basic module file structure and documentation — applies to 13 modules

Module Score 📦 🔗 📋 📝 🖼️ 📌 🔒
ai/azure-openai 🟢 83%
ai/clickhouse 🟢 86%
ai/langfuse 🟢 86%
ai/litellm 🟢 86%
ai/model-access 🟢 83%
kubernetes/ingress 🟢 86%
kubernetes/platform 🟢 86%
ske/ske-starterkit 🟢 100%
stackit/dns 🟢 100%
stackit/model-serving 🟢 100%
stackit/postgresflex 🟢 100%
stackit/ske 🟢 86%
stackit/storage-bucket 🟢 100%

Core Structure — Summary

Emoji Criterion Coverage Status
📦 buildingblock/ directory exists 13/13 🟢 100%
🔗 meshstack_integration.tf present 7/13 🟡 54%
📋 buildingblock/APP_TEAM_README.md present (no-integration fallback) 6/6 🟢 100%
📝 buildingblock/README.md with YAML front-matter 13/13 🟢 100%
🖼️ buildingblock/logo.png included 13/13 🟢 100%
📌 buildingblock/versions.tf present 13/13 🟢 100%
🔒 Provider versions use minimum constraint (>=) 11/13 🟢 85%
Integration — some checks failing

meshstack_integration.tf conventions — applies to 7 modules

Module Score 🏷️ 🏢 📤 🔌 📎 🔀 📋 🏷️ 🧱 📖 📝 📊 🚫 🔄
ai/azure-openai 🟢 100%
ai/model-access 🟢 100%
ske/ske-starterkit 🟢 92%
stackit/dns 🟢 100%
stackit/model-serving 🟢 100%
stackit/postgresflex 🟢 100%
stackit/storage-bucket 🟢 100%

Integration — Summary

Emoji Criterion Coverage Status
🏷️ variable "hub" in integration 7/7 🟢 100%
🏢 variable "meshstack" in integration 7/7 🟢 100%
📤 building_block_definition output exposed 7/7 🟢 100%
🔌 meshcloud/meshstack in required_providers 7/7 🟢 100%
📎 backplane source uses var.hub.git_ref 7/7 🟢 100%
🔀 ref_name uses var.hub.git_ref 7/7 🟢 100%
📋 version_spec.draft uses var.hub.bbd_draft 7/7 🟢 100%
🏷️ BBD metadata.tags forwards var.meshstack.tags 7/7 🟢 100%
🧱 BBD input argument vars with optional() have explicit defaults 6/7 🟢 86%
📖 BBD readme field present 7/7 🟢 100%
📝 BBD readme starts with plain-text description (no heading) 7/7 🟢 100%
📊 BBD readme has shared responsibility table (✅/❌) 7/7 🟢 100%
🚫 No documentation_md output in backplane 7/7 🟢 100%
🔄 meshstack_platform has lifecycle ignore_changes = [availability] n/a
Azure Backplane — not applicable

Azure UAMI-based automation principal conventions — applies to 0 modules

No applicable modules.

STACKIT Backplane — ✅ all passing

STACKIT WIF-based automation principal conventions — applies to 5 modules

Module Score 🔐 🚫 📤
stackit/dns 🟢 100%
stackit/model-serving 🟢 100%
stackit/postgresflex 🟢 100%
stackit/ske 🟢 100%
stackit/storage-bucket 🟢 100%

STACKIT Backplane — Summary

Emoji Criterion Coverage Status
🔐 Uses stackit_service_account_federated_identity_provider 5/5 🟢 100%
🚫 No stackit_service_account_key resource 5/5 🟢 100%
📤 Outputs service_account_email (not key) 5/5 🟢 100%
Buildingblock provider uses use_oidc = true 5/5 🟢 100%
Testing — some checks failing

End-to-end test coverage — applies to 13 modules

Module Score ⚙️ 🧪
ai/azure-openai 🔴 0%
ai/clickhouse 🔴 0%
ai/langfuse 🔴 0%
ai/litellm 🔴 0%
ai/model-access 🔴 0%
kubernetes/ingress 🔴 0%
kubernetes/platform 🔴 0%
ske/ske-starterkit 🟡 67%
stackit/dns 🔴 33%
stackit/model-serving 🔴 33%
stackit/postgresflex 🔴 33%
stackit/ske 🔴 33%
stackit/storage-bucket 🟢 100%

Testing — Summary

Emoji Criterion Coverage Status
⚙️ backplane/ directory (optional tier) 5/6 🟢 83%
🧪 e2e/ test directory exists 2/13 🔴 15%
e2e/ contains .tftest.hcl files 2/13 🔴 15%

📈 Overall Summary

Overall Average Score: 81%

Score Distribution

  • 🟢 High maturity (≥80%): 7 modules
  • 🟡 Medium maturity (50–79%): 6 modules
  • 🔴 Low maturity (<50%): 0 modules

@aws-amplify-eu-central-1

Copy link
Copy Markdown

This pull request is automatically being deployed by Amplify Hosting (learn more).

Access this pull request here: https://pr-250.d1o16zfeoh2slu.amplifyapp.com

grubmeshi and others added 27 commits August 11, 2026 09:27
Answers the two questions raised in review: where the platform components run,
and how to let users choose STACKIT or Azure for both runtime and models.

Two orthogonal seams, so this stays one reference architecture instead of
forking per cloud:
- Runtime: components are Helm building blocks declaring supportedPlatforms
  kubernetes, installed into whichever landing zone hands out namespaces (SKE,
  AKS, any conformant cluster). Precedent: kubernetes/manifest.
- Model: one small model-access module per provider, all exposing the same
  OpenAI-compatible endpoint plus credential shape to LiteLLM.

Adds the one-click delivery model: an ai-platform block installs the stack once,
then LiteLLM is registered as a meshStack platform so model access becomes a
landing-zone-governed self-service order. This is expressible today via
spec.config.custom.platform_type_ref — modules/stackit already registers STACKIT
itself as a custom platform type with stackit/project doing the provisioning.

New diagrams: stackit-ai-oneclick (lifecycle split) and a rewritten
stackit-ai-pluggable showing both seams as parallel swap-slots.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
OpenWebUI is an application, not platform plumbing. The reference architecture
delivers a governed API — LiteLLM for policy, Langfuse for observability — and
what teams build on that endpoint is their own concern.

Concretely it was removed because:
- its own user/group model permissions are a second policy store competing with
  LiteLLM, and "who may call which model" must have exactly one home
- its built-in RAG stack duplicates the already-dropped RAG layer
- a shared UI cuts across the tenant boundary the virtual key defines

All three diagrams now show tenant applications as muted context calling the
gateway, rather than a bundled UI inside the platform. Kept as a candidate
optional catalog block a team orders with its own virtual key, which is recorded
as an open question along with the rationale.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…ture

The starterkit demo app already calls an AI model, but its credential is
injected statically via a foundation-local ai.tf. Records the finding that the
integration seam already exists in the right shape: forgejo-connector
additional_kubernetes_secrets carries base URL, API key and model name, so
pointing those at LiteLLM needs no interface change.

Also records the delivery idea of exposing AI as an opt-in option following the
stackit-landingzone nullable-object pattern, with the open layering question that
the STACKIT LZ hands out projects while the AI platform needs a namespace.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…-ref gap

Step 1 (platform-team install) is delivered by the reference architecture own
Terraform apply rather than a catalog building block, so the target SKE cluster
is a plain input variable and instance selection works today.

Records the underlying limitation as a meshStack feature request: BBD
supported_platforms.kind is Always meshPlatformType for now, while
landingzone.platform_ref already targets a platform by uuid. Instance-level
support would make the catalog path pluggable, which matters for the realistic
two-cluster topology (shared AI platform vs application workloads).

Also corrects the SKE Starterkit note: no layering conflict, since the SKE
cluster is itself an offering inside a STACKIT project. Notes that the cluster
building block does not exist in the hub yet.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Both pluggable seams are real, so this is one capability-scoped reference
architecture rather than a STACKIT fork and an Azure fork. Renames
stackit-ai to ai-platform, lists stackit and azure as cloudProviders, and frames
STACKIT as the sovereign reference instantiation.

Cloud-agnostic components are destined for a new modules/ai/ namespace
(ai/litellm, ai/langfuse, ai/litellm-team); each provider contributes only a
small model-access module, of which stackit/model-serving is the first.

Extends the reference-architecture folder-name convention in AGENTS.md to allow a
capability-only name for genuinely multi-cloud architectures.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Each tenant gets its own STACKIT Model Serving token in its own STACKIT project
rather than LiteLLM holding one shared platform credential. Recorded as an
opinionated call, with the shared-token variant noted as legitimate for other
platform engineering setups.

Rationale: keeps the STACKIT resource hierarchy reflecting tenant structure,
puts spend on the tenant project so it follows the existing STACKIT cost path
instead of needing AI-specific attribution, and narrows rotation blast radius.

Also records that app-team ordering stays one click either way — the added
complexity is inside the block, which must touch both STACKIT and the LiteLLM
admin API, bringing two-system consistency and rotation concerns to design for.
This validates the tenant-level shape of the scaffolded module.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
LiteLLM and Langfuse are each deployed once and are internally multi-tenant;
nothing is deployed per application team. Tenancy is a LiteLLM team plus virtual
key and a Langfuse organization/project, while the STACKIT token lives in the
tenant own project.

Shared deployment is forced for LiteLLM, since a gateway only enforces budgets
and allow-lists if all traffic passes through one instance, and chosen for
Langfuse because per-tenant installs would each carry a full data stack.

States the trade-off plainly: isolation rests on the Langfuse project boundary
rather than a Kubernetes or network boundary, and the shared instances are a
common blast radius. Also resolves the provisioning mechanism to
Mastercard/restapi, already the established pattern in this repo.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Provider configurations may reference resource attributes but fail when the value
is unknown at plan time, so the RA pre-generates the LiteLLM admin key and
derives the endpoint from a known hostname. Both are knowable up front, leaving
depends_on sufficient for a single apply.

Adds a second meshStack feature request: encrypted sensitive outputs between
building blocks would allow splitting this into two properly dependent blocks,
one deploying the gateway and one registering the platform, removing the
plan-time-unknown constraint entirely.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…ster ingress

Reframe delivery around meshStack as the Terraform runtime rather than
terragrunt: the RA is itself a WORKSPACE_LEVEL building block, and the
cluster credential arrives as a STATIC encrypted input read back through
file() - so it applies in one pass with no two-unit split.

Record that ai-platform stays generic and is reused by provider-specific
RAs via git URL, with the model seam as a map plus a separate sensitive
key map. Add modules/kubernetes/ingress as the TLS prerequisite,
documenting the four drifting copies in the foundation repos.

Correct both feature requests: WORKSPACE_LEVEL needs no
supported_platforms, and sensitive inputs are excluded only for
BUILDING_BLOCK_OUTPUT.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The ai option builds on a kubernetes option that does not exist yet:
network -> kubernetes -> ai. Record that the network -> kubernetes hop
already composes, since stackit/network outputs a non-secret network_id
that stackit_ske_cluster.network.id consumes via BUILDING_BLOCK_OUTPUT.

Separate the two controls that get conflated: access_scope = SNA
privatises only the Kubernetes API and is feature-flagged, immutable and
mutually exclusive with the ACL extension, while the GA internal-lb
annotation is what actually keeps a workload off the internet. Both are
available at the pinned provider version.

Default to expose = public with TLS so kubernetes/ingress is exercised
end to end, noting the sovereignty trade-off, with internal as opt-in.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…ckit/ske

Place the cluster block under the platform it is ordered on, mirroring
modules/azure/aks (name: AKS Cluster, supportedPlatforms: [azure]), so
modules/ske and modules/aks stay the platforms whose tenants are
namespaces.

The block is TENANT_LEVEL on STACKIT and registers its own platform and
landing zone via an ephemeral meshStack API token, which keeps the
kubeconfig inside the run - so the sensitive-output gap is avoided on
this hop rather than blocking it. Reframe what the gap actually costs:
decomposition freedom, not capability.

Also note ske-starterkit already threads platform_ref as a static input,
so instance-level targeting is an ergonomics request, not a missing
capability.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Replace metaphors and rare words with plain, literal wording: seam ->
layer, blast radius -> shared failure, choke point -> single enforcement
point, hop -> step, plumbing -> shared infrastructure, sidesteps and
routes around -> avoids, privatises -> makes private, hub-ify -> move
into the hub.

Split sentences that stacked clauses or buried a long aside, and name the
actor where a noun hid it.

Trim the diagram headers to the bare title the other .dot files use, and
drop the ones that restated their own cluster labels or still said "under
discussion". Fix the model-serving scaffold note, which pointed at a
reference architecture path that no longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…cture

Turn the design document into a published reference-architecture README:
front matter naming stackit-kubernetes as the runtime provider and the five
modules the architecture composes, overview, architecture diagram, how it
works, deployment vs tenancy, governance, getting started with a deployment
order, and the shared responsibility matrix.

Drop the design rationale, the open questions and the meshStack feature
requests; they are carried over separately. Delete the pluggable-layers
diagram, which duplicated the runtime/model table.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…lusterIssuer

Five foundation units carry copies of certmanager.tf, haproxy.tf and
cluster_issuer.tf, and the copies have drifted apart. This building block
delivers the capability they share: a service in the cluster gets a public
HTTPS URL with a valid certificate.

The ClusterIssuer and the wildcard Certificate render through a Helm chart
that lives in the module directory (chart = path.module). kubernetes_manifest
would need a plan-time CRD schema lookup, which is the only reason foundations
run addons/certmanager as a separate terragrunt unit today. Removing that
lookup lets a foundation merge the two units into one.

DNS-01 is optional and takes exactly one provider. The stackit branch installs
the stackit-cert-manager-webhook chart and issues one wildcard certificate for
the zone, which HAProxy then serves as its default TLS certificate. The
route53 branch carries the native cert-manager solver shape but is not
exercised, because AKS keeps per-hostname HTTP-01 for now.

The module is sourced, never ordered, so it ships no meshstack_integration.tf
and no backplane, and puts its user-facing readme in APP_TEAM_README.md.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Provisions a STACKIT Kubernetes Engine cluster and its kubeconfig, and nothing
else. It replaces the three identical copies of cluster.tf that LCF, ICF and TCF
each carried under platforms/ske/kubernetes/.

The module lives under modules/stackit/ because that directory follows the
platform the block is ordered on, not the thing it produces. modules/ske/ stays
the platform whose tenants are namespaces.

Over the foundation original it adds:
- stackit provider >= 0.88.0 instead of ~> 0.83, which is both the hub rule and
  the floor for the SNA and access_scope fields
- an optional network_id input for STACKIT Network Area placement, meant to come
  from modules/stackit/network through a BUILDING_BLOCK_OUTPUT
- an optional control_plane_access_scope, unset by default so SKE creates a
  public control plane and no private-preview field reaches accounts that are
  not enabled for it
- an optional dns_extension for the SKE managed ExternalDNS extension, off by
  default
- an 11 character validation on cluster_name, the limit TCF hit when it had to
  shorten try-meshstack to try-mesh
- node_pools and maintenance as inputs, defaulting to the foundation values

The provider_config and kubeconfig outputs keep their shape so the foundations'
terragrunt generate "provider" blocks keep working. A new non-sensitive
kube_host output carries the API server URL on its own.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…ing block

Registers a Kubernetes cluster as a meshStack platform of type kubernetes, adds
one namespace landing zone per environment, and creates the in-cluster replicator
and metering service accounts meshStack authenticates with.

Nothing in the module names a cloud, so it serves SKE today and any conformant
cluster later. It is built from the two SKE-side copies in LCF and TCF plus the
hand-rolled service account in modules/ske/ske-starterkit/e2e. Every value the
three disagreed on became a variable with the LCF value as the default: the
quota definitions, the landing zones and their quotas and tags, the platform
name, display name and description, the location, the namespace name pattern and
the metering toggle.

The module hand-rolls the service accounts instead of sourcing
terraform-kubernetes-meshplatform v0.2.0, which keeps the hub self-contained. The
resource names match that module, so an existing deployment can be moved over
without renaming.

The AKS copies cannot share this module. meshStack models them with
spec.config.aks, an Entra service principal read from data.meshstack_integrations
and six extra required fields, so AKS needs its own registration module.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…ing block

Adds the meshstack_integration.tf that was missing, so the module can be
imported into a meshStack instance. The building block definition is
TENANT_LEVEL and mints one token per tenant rather than sharing a single
token across the platform. Its readme is inline, as the convention requires
for modules that carry an integration file.

Adds buildingblock/logo.png as a copy of modules/stackit/logo.png, which is
what stackit/project and stackit/ske already do for building blocks without
a service-specific logo.

Exposes an api_base output that carries the '/v1' suffix, because a caller
that drops the suffix gets a "Not Found" error from the OpenAI-compatible
endpoint. Together with the token, that output is everything LiteLLM needs
to register STACKIT as a model backend: LiteLLM sends api_key upstream as a
bearer token in the Authorization header. A summary output renders both for
the application team.

Fixes the ttl_duration default. STACKIT validates it with Go's duration
parser, which knows no day unit, so the previous '90d' was rejected at
apply time; 90 days is now written as '2160h'.

Lowers required_version to >= 1.11.0 to match the terraform_version the
building block definition runs and every other STACKIT building block. The
module uses no feature newer than that.

Declares requiresBackplane: false: the token is created in a STACKIT
project that already exists and the service account arrives as an input, so
the module provisions nothing cloud-side of its own.

This pull request adds no e2e tests, so the two e2e scorecard checks stay
open by decision. Scorecard: 40% -> 90%.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…ding block

The architecture no longer expects an SKE cluster to exist. It now carries a
meshstack_integration.tf and a buildingblock/ that composes three hub modules
into one TENANT_LEVEL building block ordered against the STACKIT Project
platform, so the cluster lands in the meshTenant's own STACKIT project:

- modules/stackit/ske creates the cluster and its kubeconfig,
- modules/kubernetes/platform registers it as a meshStack platform of type
  kubernetes with its namespace landing zones,
- modules/kubernetes/ingress installs cert-manager, HAProxy and the Let's
  Encrypt ClusterIssuer.

All three are sourced by git URL and pinned with ?ref=${var.hub.git_ref}.

The tenant-facing input is one STRING, `expose`, taking public, internal or
none. Everything else is a landing-zone concern and arrives as a STATIC input
or stays at the module default.

dns.tf holds the whole delegated-subzone design in one place, including the
unverified assumption that NS delegation bypasses the one-label rule under
stackit.run, and the modules/stackit/dns module the zone and the NS record
still need. No cloud provider resource is declared here.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Reference architectures must not declare stackit_postgresflex_instance
inline, so this adds a hub module that owns the resource. The module is
sourceable as a plain Terraform module by a composition and orderable as a
building block through meshstack_integration.tf.

The buildingblock creates the instance, one database and one owning user,
and returns host, port, database name, username, the generated password and
a connection string that LiteLLM and Langfuse take as DATABASE_URL.

STACKIT encodes CPU, RAM and node count in one flavor ID, so the module
takes flavor_cpu, flavor_ram and replicas separately and resolves the
matching flavor through the stackit_postgresflex_flavors data source. The
deprecated flavor block stays unused.

The ACL defaults to the STACKIT service ranges 193.148.160.0/19,
45.129.40.0/21 and 45.135.244.0/22. An SKE cluster reaches the instance
through a NAT router, so the team adds the cluster egress IP as a /32 entry;
allow_stackit_public_ip_ranges pulls the live list instead. 0.0.0.0/0 is
rejected.

Default PostgreSQL version is 17 and anything below 16 is rejected, because
version 14 reaches end of life on 12 November 2026.

The backplane grants postgres-flex.admin at organization scope over a WIF
service account, the same shape modules/stackit/network/backplane uses for a
TENANT_LEVEL building block.

Designed against stackitcloud/stackit v0.110.0.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…ssions

Langfuse configures an S3 client from a bare endpoint URL plus a bucket
name, which neither bucket_url_path_style nor bucket_url_virtual_hosted_style
provides. The new endpoint output returns the host on its own. A local now
holds the endpoint so the path-style URL and the summary derive from one
place; the region stays hardcoded to eu01, the same value provider.tf uses.

The bucket policy also gains s3:AbortMultipartUpload,
s3:ListMultipartUploadParts and s3:ListBucketMultipartUploads. Creating,
uploading and completing parts is covered by s3:PutObject, but cancelling an
upload and listing leftover parts are separate actions. The AWS SDK upload
helpers call AbortMultipartUpload when an upload fails part-way, so without
these the abort returns 403 and the orphaned parts stay in the bucket and
keep costing storage.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The website generator reads a README.md with front matter and a logo from every
directory under modules/, so the ai modules need both before they can appear in
the catalog. The logo is original line art in the style of modules/ske/logo.svg,
because AI is a capability grouping and has no vendor mark of its own.

The category is devops, which is the closest of the five values the website
accepts. Adding an ai category would mean changing the website, which is out of
scope for this pull request.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The platform team installs the gateway into a Kubernetes namespace and registers
the OpenAI-compatible endpoints it fronts. The ai-platform reference architecture
sources the module, so it follows the sourced-not-ordered pattern: a buildingblock
tier with an APP_TEAM_README.md, no meshstack_integration.tf and no backplane.

The chart litellm-helm is published to GHCR as an OCI artifact only and is pinned
to 1.96.2, the tag on the registry. Chart.yaml on the repository's main branch
carries a different version, because the release pipeline overwrites the field
while publishing.

Postgres is mandatory, because virtual keys, teams, budgets and spend tracking all
live there. The connection arrives as inputs, so the module works with any Postgres,
and the chart's bundled Bitnami subchart stays off: those images no longer receive
updates. The Prisma migration Job runs as a Helm pre-install hook so the pods never
start against a database without the schema.

Redis is optional and off by default. It is the coordination store for cross-pod
rate limits, spend tracking and the pod lock manager, so a single replica needs none
and several replicas with enforced budgets need it.

Two traps in the model registration are encoded and validated: the openai/ prefix on
the model name selects the OpenAI-compatible driver, and an api_base without the /v1
suffix answers Not Found on every call.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Creates one LiteLLM team per tenant with a budget, and one virtual key scoped
to that team. The building block definition is TENANT_LEVEL and marked
use_in_landing_zones_only, so an AI landing zone lists it in
spec.mandatory_building_block_refs and meshStack provisions the team when the
tenant is created. Every input is either STATIC or filled from the meshStack
tenant context, so tenant creation needs no form.

The team_id output is assigned as PLATFORM_TENANT_ID, which lets building
blocks ordered later bind their resources to the tenant's team.

The ncecere/litellm provider is pinned to exactly 2.0.1, a deliberate
exception to the hub rule that provider constraints use '>='. versions.tf
carries the reason.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…LM backend

Adds the Azure entry in the pluggable model layer of the ai-platform
architecture, next to stackit/model-serving. The module creates a single
litellm_model resource and no Azure resources, so it declares
requiresBackplane: false and needs no Azure identity.

The building block definition mirrors stackit/model-serving: TENANT_LEVEL and
orderable, with team_id filled from PLATFORM_TENANT_ID. LiteLLM then offers
the model to the ordering tenant's team alone and attributes its spend to that
team.

Azure OpenAI addresses a deployment rather than a model. The provider joins
custom_llm_provider and base_model into 'azure/<base_model>', so
azure_deployment_name carries the Azure deployment name.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
… helm providers

The module configured both providers itself from cluster_endpoint,
cluster_ca_certificate and token. That shape blocked the composition in
reference-architectures/stackit-kubernetes in two ways.

It could not authenticate to a freshly created SKE cluster.
stackit_ske_kubeconfig issues a client certificate and a client key, never a
bearer token, and no step in a composition can mint a cluster-admin token
before the cluster exists.

It could not be made optional. A module that carries its own provider
configuration is a legacy module, and OpenTofu rejects count, for_each and
depends_on on calls to it. The restriction is transitive, so a local wrapper
module does not lift it, and providers = {} fails with "Cannot override
provider configuration". The composition needs count to skip ingress when
expose is "none".

provider.tf is gone and the three credential variables with it. The caller now
configures the kubernetes and the helm provider and passes both down through
the providers argument. Terragrunt units that generate a provider block are
unaffected, because their generated configuration already served the module.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…ster

This reference architecture serves demonstrations, so every workload it
deploys is now sized as small as it still runs, and every value is a variable
a production consumer can raise.

The cert-manager chart sets no resources at all, so its controller, cainjector,
webhook and startupapicheck ran unbounded. They now request 10m CPU and 32Mi to
64Mi of memory. The memory request sits above the 32Mi the chart documents as
its example, because a container that gets too little memory is OOMKilled
rather than slowed down.

The HAProxy chart requests 250m CPU and 400Mi of memory for the controller and
for its CRD Job, and sets no limit on either. The controller now requests 100m
CPU and 256Mi, the Job 50m and 64Mi. The memory limit of the controller stays
at 768Mi: the pod runs HAProxy next to the Go controller and the entrypoint
hands HAProxy two thirds of the cgroup limit, and users of this chart version
report a reload loop below a 500Mi limit (haproxytech/kubernetes-ingress#799).

haproxy_replica_count drops from the chart default of 2 to 1. One replica gives
no redundancy, which the variable description and both readmes state.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Creates one DNS zone in a STACKIT project, the record sets inside it, and a
service account key that cert-manager and ExternalDNS use to manage records at
runtime.

A proof-of-concept against the live API disproved the delegated-subzone-per-tenant
design for free STACKIT subdomains. Creating a two-label zone under stackit.run is
rejected with "subdomain should only have one level", byte-for-byte identically
with an NS delegation in place and in a second project. The primary path is
therefore a single zone whose names below it are record sets. Delegation stays as
an optional input for customer-owned domains, where a control test showed the
cross-project machinery does exist.

The zone_name validation rejects the deep-name shape at plan time, the delegation
input refuses free STACKIT suffixes, and preconditions enforce trailing dots on
nameservers and refuse to write an NS record for a zone the module is not
creating, because an orphaned delegation resolves to SERVFAIL with no error at
apply time.

The backplane grants dns.admin and iam.member-admin at organization scope. STACKIT
predefined role for creating service accounts could not be established, so it is
documented in prose and passed through additional_organization_roles rather than
guessed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
grubmeshi and others added 29 commits August 13, 2026 00:18
A free STACKIT subdomain admits exactly one label, so a subzone per cluster is
impossible and every cluster has to share one platform-owned zone. The module
gains `create_zone = false`, which skips the zone and writes its record sets
into a zone that already exists, identified either by `zone_id` or by a lookup
on `zone_name` through the `stackit_dns_zone` data source.

The new `wildcard` input writes the record that sends every application
hostname to the ingress controller. Its `label` is optional: unset puts the
wildcard at the zone apex, which reproduces the record the three SKE
foundations have today down to the comment, and a label gives
`*.<label>.<zone_name>` so several clusters can share the zone. Only one
cluster per zone can hold the apex, and the module cannot detect the collision
because a second cluster runs from its own state and STACKIT offers no data
source that lists a zone's record sets. The README documents that constraint
and the migration, including the `moved` blocks a foundation needs.

`zone_name = null` switches the module off, because Terraform refuses `count`
on a module that carries its own provider configuration.

The label is a boundary the code draws. The DNS credential stays zone-wide,
which the README now says plainly.
`dns.admin` does not exist at organization scope, so the backplane would have
failed on the role assignment. The live authorization API returns 76 roles for
an organization and no dns role among them, while a folder returns 184 roles
including `dns.admin`, and a project returns 182 including it:

  stackit curl https://authorization.api.stackit.cloud/v2/organization/<id>/roles
  stackit curl https://authorization.api.stackit.cloud/v2/folder/<id>/roles
  stackit curl https://authorization.api.stackit.cloud/v2/project/<id>/roles

Each call returned 200. `iaas.network.admin`, `organization.admin`,
`iam.member-admin` and `resource-manager.admin` are present in the organization
response, so the absence is real and not a filtered response.

Folder scope keeps the property the building block needs: it is `TENANT_LEVEL`,
so the platform team grants the role before it knows which projects tenants
order into, and a folder covers every project below it.
`modules/stackit/model-serving/backplane` made the same move for
`model-serving.editor`, and this mirrors it.

`iam.member-admin` stays at organization scope, where it does exist. The role
list also settles the module's open question about creating service accounts:
`iam.service-account-creator`, `iam.service-account-key-admin` and
`iam.service-account-admin` exist at all three scopes, so the README now names
them instead of saying the role could not be established.
… DNS zone

The delegated subzone the architecture described cannot exist. STACKIT rejects
a two-label zone under a free subdomain in every project, even with a correct
NS delegation in place, so `cluster1.likvid.stackit.run` is not a zone. A
record set with that depth inside the existing zone is allowed.

The composition now takes the platform team's shared zone and the project that
owns it, derives the cluster's label from `cluster_name` and creates the record
set `*.<cluster_name>` in that zone through `modules/stackit/dns`. The wildcard
certificate covers `*.<cluster_name>.<zone>` alone, while the DNS-01 solver
still authorises against the whole zone, so a cluster no longer requests a
certificate for the entire domain. `dns_cluster_label_enabled = false` puts the
cluster at the zone apex instead, which is the flat shape existing SKE
foundations have and which only one cluster per zone can hold.

The SKE managed ExternalDNS extension is switched off, because it reaches only
zones in the cluster's own project and the shared zone lives in the platform
team's project. The wildcard record makes it unnecessary.

The DNS credential remains zone-wide. The code keeps each cluster inside its
label, the permission system does not, and dns.tf and the README both say so.
The DNS inputs move next to the design they drive.

`ske.admin` is not offered at organization scope either, so the identity
prerequisite now names folder scope.
…ver zone

The dns01 object had no certificate_domain attribute, so a caller that passed
one got it silently discarded and the wildcard certificate covered the whole
zone. A composition that gives every cluster its own label inside one shared
zone therefore issued *.<zone> on every cluster.

dns01.certificate_domain now declares that domain and defaults to
dns01.zone_name, which keeps the rendering of every existing caller unchanged.
The ClusterIssuer keeps the zone in the dnsZones selector of the solver,
because cert-manager matches every name below the zone and still answers for
the narrower certificate. A validation rejects a domain outside the zone, and
the new wildcard_certificate_domain output reports the domain that was used.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…esign

The building block definition was still written for a delegated subzone per
cluster, so an ordered cluster could not find the zone at all: the zone lookup
fell back to the tenant's own project, while the shared zone lives in the
platform team's project. The definition now passes dns_zone_project_id, and
dns_cluster_label_enabled lets the platform team put one cluster at the zone
apex.

The service account needs ske.admin on the folder the tenant projects live in.
STACKIT does not offer that role at organization scope, which the readme of the
architecture already records.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…it tests

Both providers are mocked, so the suite needs no cluster, no DNS zone and no
ACME account. It pins the fallback that keeps existing callers rendering
*.<zone_name>, the narrowing to a domain inside the zone, the zone that stays
in the dnsZones selector of the solver, and the rejection of a domain outside
the zone.

The rejection run also guards the error message of that validation. var.dns01
is sensitive, so the message must interpolate nothing, and a message that does
fails the run with 'Error message refers to sensitive values'.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…-free submodule

The AI platform runs one Langfuse instance per tenant and gives each tenant
its own bucket, so its composition needs to call this module once per tenant
with for_each. It could not. buildingblock/ carries its own stackit and aws
provider configuration, which makes it a legacy module, and OpenTofu rejects
count, for_each and depends_on on every call to such a module. The same shape
also blocks mock_provider, so a caller cannot plan against it in a test.

The bucket, the credentials group, the credential and the bucket policy move
to buildingblock/bucket, which declares no provider configuration and
inherits the caller's. buildingblock/ keeps provider.tf and calls the
submodule once, so the path meshStack runs when an application team orders
the building block is unchanged, down to its outputs.

The building block stays WORKSPACE_LEVEL. TENANT_LEVEL would put the bucket
in the tenant's own STACKIT project, where the backplane grants no
object-storage role and no admin credentials group exists to apply the bucket
policy. The aws provider that applies that policy is configured from static
backplane credentials, so it cannot depend on a group created in the same
run. That is a backplane redesign, and the AI platform does not need it: the
composition sources buildingblock/bucket and creates every tenant bucket in
the platform's own project.

Two outputs are new. region reports eu01, the region the endpoint already
hardcodes, so a caller configuring an S3 client does not hardcode it a second
time. credentials_group_urn reports the group the bucket policy grants access
to. A mocked plan test covers the ordered path's outputs.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The smoke test hand-rolled a service account, a secret and a cluster role in a local submodule,
which is the same configuration `modules/kubernetes/platform` now provides. Sourcing the module
means the test exercises the code the foundations run instead of a private copy of it.

The module configures its own `kubernetes` provider, so the four cluster credentials are passed
as inputs. That leaves the `provider "kubernetes"` block at the root of the e2e without a single
resource behind it, so the file goes away.

Metering is on, which the local submodule stubbed out with a dummy token. It costs one more
service account in the throwaway namespace and covers the path both SKE foundations use.
…le move

Moving the four resources into buildingblock/bucket changed their address in
state. A building block ordered before the move would plan a destroy and a
create on the next run, which takes every object in the bucket with it. The
moved blocks map each old address onto the new one.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
… already exists

The AI platform runs one Langfuse instance per tenant against a shared set of
stateful backends. A managed PostgreSQL Flex instance per tenant is expensive
and mostly idle, so the platform needs one instance carrying one database and
one owner user per tenant instead.

existing_instance_id selects the mode. Unset, the module behaves as before and
creates the instance, the database and the user as one unit. Set, it skips the
instance and creates only the database and the owner user inside the instance
named, reading that instance to fill host, port and the details the summary
shows. Exactly one of existing_instance_id and instance_name must be set, and
an empty string is rejected rather than read as database-only mode, because a
caller that builds the value with try or lookup easily produces one.

The instance resource gains a count, so a moved block maps the old unindexed
address onto index zero and keeps instances created earlier.

direct_connection_string is new. Prisma reads DIRECT_URL for migrations and
DATABASE_URL for everything else, so an operator can put a connection pooler
in front of the instance and keep migrations on a direct connection. This
module places no pooler in front of the instance, so the value equals
connection_string today and the separate output gives a caller that later adds
one a stable name.

The user needs only the login role. prisma migrate deploy, which is what the
Langfuse container runs, applies the migration files as they are and opens no
shadow database, so CREATEDB is unnecessary. The default keeps createdb so
existing users keep the roles they have; the README says to drop it.

The README now carries STACKIT's documented max_connections per flavour, the
15 connections STACKIT reserves, the fact that the limit follows RAM and not
CPU and cannot be tuned, and the arithmetic for how many tenants one instance
carries. It also warns that Prisma sizes its pool from the node's physical
core count when connection_limit is absent, which makes an unpinned deployment
unbounded.

A tftest with a mocked provider plans both modes with real values and checks
every output, plus the three ways the mode inputs can be wrong.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Installs the official ClickHouse Kubernetes operator and one
operator-managed ClickHouseCluster and KeeperCluster, following the
v4-installation example in langfuse-k8s. The Langfuse chart bundles a
ClickHouse older than the 25.12 floor Langfuse v4 requires, so the
server runs outside the chart.

The module is deployed once per Kubernetes cluster and shared by every
tenant. Every replica count, volume size and resource figure is a
variable, defaulted for a demonstration, with the production target
named in its description. The memory floor is validated: ClickHouse does
not start reliably below 1Gi.

The custom resources run through a Helm chart in the module directory,
because a kubernetes_manifest resource looks the CRD schema up at plan
time and the CRDs do not exist yet during the first plan. A post-install
hook Job waits until ClickHouse answers a query, which Helm cannot do
for a custom resource on its own.

The module declares no provider blocks, so a caller can instantiate it
with count.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Installs one Langfuse v4 instance per tenant against a shared Postgres,
ClickHouse, Valkey and object storage. Every value that identifies a
tenant is an explicit input, because the module is instantiated once per
tenant and the caller derives the names.

Pins chart 1.5.41 and overrides the image tag to a concrete v4 release,
because the chart appVersion is still v3 and a floating tag moves under
a running Deployment.

Handles four traps that are each a silent failure otherwise:

- REDIS_KEY_PREFIX is not exposed by the chart, so it is injected
  through additionalEnv. Both the database index and the key prefix are
  set: BullMQ queue names are hardcoded, so two tenants on one index
  without a prefix consume each other's ingestion jobs.
- Langfuse never reads DATABASE_PORT, so the port is folded into the
  host the chart is given.
- signUpDisabled stays false. AUTH_DISABLE_SIGNUP is checked inside the
  NextAuth adapter createUser, which is the SSO first-login path, so
  turning it on locks every SSO user out. disableUsernamePassword is the
  control that belongs to the operator.
- LANGFUSE_OTEL_HOST, not LANGFUSE_HOST, is what LiteLLM reads on every
  version; with neither set it exports to the public cloud endpoint.

Bootstraps the organisation, the project and a predefined API keypair
through LANGFUSE_INIT_*, which carries no entitlement, and joins every
user who logs in to that organisation through LANGFUSE_DEFAULT_ORG_ID,
which carries no entitlement either. That removes member sync entirely.

Self-hosted OIDC is free and takes the identity provider as a nullable
input. Every credential travels through a secretKeyRef, so the Helm
values map holds no secret and stays readable in a plan.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…scope

The role does not exist at organization scope: the authorization API offers
76 roles there and 184 at folder scope. Three sibling modules made the same
mistake and would have failed at apply.

ske.editor is one permission short, lacking ske.cluster.delete, which the
building block needs when a tenant deletes it. So ske.admin is the narrowest
predefined role that fits.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
An application team now orders its own SKE cluster through the STACKIT
Kubernetes Cluster building block, so the cluster is no longer something the
platform team runs and hands over. In the stackit-kubernetes diagram the cluster
node carries the tenant color and the label "ordered by an application team",
and a node for the building block definition that creates it is new, next to the
Kubernetes Platform the building block registers once per ordered cluster.

The ingress path is drawn as well. HAProxy runs one ingress controller with one
load balancer per cluster, cert-manager obtains a wildcard certificate from a
Let's Encrypt ClusterIssuer, and the platform team owns one shared DNS zone that
holds one wildcard record per cluster. The zone solves the DNS-01 challenge and
the record resolves to the load balancer.

In the ai-platform diagram the "SKE Platform" node was stale. The AI platform
runs in a namespace it receives from stackit-kubernetes, and what it registers
in meshStack is a LiteLLM platform type, so the node is renamed and LiteLLM is
drawn as registered on it. The landing zone node now names what it carries, the
allowed models and the budget, and the model-serving building block node becomes
ai/litellm-team, which is the building block an application team orders. The
one-click diagram picks up the same building block name and stops naming SKE and
AKS in the landing zone node, because any conformant cluster works.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Prisma sizes its connection pool as `physical cores × 2 + 1` when the connection
URL carries no `connection_limit`, and it counts the physical cores of the node
instead of the pod's CPU limit. prisma-engines#4341 records that as an oversight
and was closed as `not_planned`, so no fix is coming. On 16-core nodes a single
pod therefore takes 33 connections, and 40 unpinned pods ask for about 1320.
STACKIT PostgreSQL Flex fixes `max_connections` per flavour, reserves 15 of them
and exposes no parameter group, so that ceiling cannot be raised. Unpinned, the
platform runs out of connections at five tenants, and the number it asks for
changes on its own as soon as a pod is rescheduled onto a node with more cores.

ai/langfuse gains postgres_connection_limit, default 5, which the module appends
to the connection URL next to postgres_args, and
postgres_direct_url_connection_limit, default 2, which it appends to the
migration URL. The migration URL is a full URL the caller hands in, so it never
passes through postgres_args and needs a limit of its own. Two is enough,
because the web entrypoint runs `prisma db execute` and then
`prisma migrate deploy` one after the other and each opens a single connection.
Both inputs reject a `connection_limit` the caller already wrote into the URL,
since two occurrences in one query string leave the effective pool size to the
parser.

ai/litellm gains postgres_connection_limit, default 10, which is LiteLLM's own
default, so pinning the value changes nothing at runtime and only bounds what
the URL asks for. The module writes it in two places. proxy_cli.py overwrites
`connection_limit` on DATABASE_URL at startup from
`general_settings.database_connection_pool_limit`, so the parameter on the URL
alone does not bind the running proxy and both have to be set. The URL parameter
still matters, because the Prisma migration Job reads DATABASE_URL as it stands.

Each module gets a tftest that plans against a mocked provider and asserts the
parameter where it lands rather than where it is set: on the rendered chart
values for both modules, in the proxy config for LiteLLM, and in the secret that
carries DIRECT_URL for Langfuse.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The AI platform runs one Langfuse instance per tenant against one shared
PostgreSQL Flex instance, so its composition has to create one database per
tenant and needs to call this module with for_each. It could not.
buildingblock/ carries its own provider.tf, which makes it a legacy module, and
OpenTofu rejects count, for_each and depends_on on every call to such a module.

The instance, the database, the owner user, the flavor lookup, the public IP
range lookup and the SUMMARY template move to buildingblock/database, which
declares no provider configuration and inherits the caller's. buildingblock/
keeps provider.tf, calls the submodule once and passes every output through, so
the path meshStack runs when an application team orders the building block is
unchanged, down to its outputs. Both entry points take the same inputs, apart
from service_account_email, which only the root needs for its provider.

Three moved blocks in the root carry the old addresses into the submodule and
keep every instance created before the move. The instance moves as a whole
resource rather than per key, which covers both shapes its address has had: no
key at all before database-only mode existed, and [0] since the resource gained
a count. A state that still holds the unkeyed shape arrives in the submodule
unkeyed, and the moved block that already lives there adds the index.

This is breaking because every resource address inside the module changed.
A caller with an existing state is covered by the moved blocks, which ship in
the root, but anything outside the module that names an address has to be
updated: a targeted apply, an import block or a script that edits state.

The tftest gains a run that plans the submodule on its own, with the provider
configured by the test rather than by a provider block of the module, and checks
that the submodule assembles the same connection string and reports the same
instance as the root.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Building block outputs cannot be sensitive. Provider schema 0.24.4 gives
version_spec.outputs exactly four attributes and no sensitive block, while
version_spec.inputs has one, and output assignment_type is limited to NONE,
PLATFORM_TENANT_ID, SIGN_IN_URL, RESOURCE_URL and SUMMARY.

The key was published twice: as its own STRING output, and interpolated into
the SUMMARY template. Both are gone. The summary now says where the platform
writes the key instead of showing it.

buildingblock/outputs.tf keeps it as a sensitive Terraform output, which is a
different mechanism: it stays in state for a composing module and never
reaches meshPanel.
The console SSO gate counts every row in LiteLLM_UserTable and fires at login
initiation, so the sixth login locks out everyone already in. Two changes keep
the table empty and one keeps it from filling by accident.

disable_auto_add_proxy_admin_to_teams defaults to true, so creating a team
writes no user row at all.

GENERIC_USER_ID_ATTRIBUTE is pinned to sub. The proxy defaults it to
preferred_username, which the documentation does not say, and which is
reassignable at most providers: a reassignment writes a second row for the same
person and silently spends another of the five seats.

The caller supplies one issuer and the module reads the discovery document,
because Keycloak, Entra and Okta put the endpoints at unrelated paths. Setting
all three endpoint overrides skips the request entirely, for a provider the
Terraform runner cannot reach.

The recovery runbook uses POST /user/delete with the master key rather than SQL.
The master key keeps working while the console is locked, and raw SQL either
fails on an ON DELETE RESTRICT foreign key or orphans a user's virtual keys.
The procedure for unlocking a console that has hit the five-user limit was
written from source reading, not from a test, and it is out of scope for this
pull request. Two side effects of the API call it recommended are unverified,
and one of them would destroy every tenant virtual key if it turned out to be
true.

A short Follow-up section keeps the risk visible and names exactly what has to
be established before a procedure can be written.
…g block

An application team orders one mandatory block named for the capability, not for
the products: it mints the LiteLLM team and virtual key, deploys that project
own Langfuse, finds the sibling Kubernetes tenant of the same meshProject, and
writes the key into that namespace as a Secret.

One root module with five provider configurations, not a composition. Building
block outputs cannot be sensitive, so the key has to be created and delivered
inside a single run. There is no virtual_key output any more, and a mutation test
confirmed Terraform refuses outright when the key is put into the summary.

The sibling namespace comes from spec.platform_tenant_id, never from
status.tenant_name, which the provider documents as an opaque string. Three
preconditions cover no match, several matches and an unreplicated tenant.

Per-tenant names carry an eight-character hash of the workspace and project
identifiers, because the 63-character limits force truncation and two long
identifier pairs sharing a prefix would otherwise collide.
Langfuse needs its Postgres database, its ClickHouse database and its bucket to
exist before it starts, and tenants arrive dynamically, so nothing applied once
at workspace level can pre-create them. This block creates all three at the
moment the tenant appears.

Postgres and the bucket come from the provider-free submodules that were split
out for exactly this, so they inherit the callers providers.

ClickHouse gets a small Helm chart with two hook Jobs instead of a provider. The
decisive reason is reachability: the shared cluster answers on an in-cluster name
that the meshStack runner cannot resolve, so the DDL has to execute inside the
cluster whatever drives it. A bare Job cannot drop the database on destroy,
because Terraform destroys resources and cannot run a Job on the way out, so the
drop is a pre-delete hook and the tenant database goes when the tenant goes.

The statements converge rather than drift: IF NOT EXISTS, ALTER USER so the
password follows state, and GRANT WITH REPLACE OPTION so the grant list is
replaced instead of only growing.

delete is now in the documented ClusterRole. The DELETE deletion mode would
otherwise fail with a 403 on the Secret.
The GRANT example put ON CLUSTER at the end of the statement. ClickHouse's
documented grammar puts it directly after GRANT, before the privilege list, so
the statement as written is a parse error for anyone who pastes it into a shell.

The clause's position is statement-dependent: GRANT and REVOKE take it right
after the keyword, while CREATE USER and CREATE DATABASE take it after the
object name. The README now says so, because it shows both placements.

The module executes no DDL itself, so this was a documentation defect only.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The website's category union had no ai member, which is the only reason
modules/ai/README.md declared category: devops. The union is closed and
platform.json is imported as a typed module, so the compiler is what proves the
change is complete.

Adds ai to the three places the union is declared, gives it an emoji and a label
in getCategoryLabel, and switches the AI provider README to it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Grant at project scope wherever the target project is known. Folder scope is the
fallback for a project that cannot be named at grant time, not the default.

modules/stackit/ske/backplane gains additional_project_roles and its own project
input, so one backplane can carry roles beyond ske.admin and a composition ends
up with a single service account instead of one per role. ske.admin itself stays
at folder scope: an ordered cluster lands in the ordering tenant's own STACKIT
project, which the platform team cannot name when it deploys the backplane.

modules/stackit/dns/backplane gains zone_project_ids and grants dns.admin at
project scope for each. The folder grant stays as an optional fallback rather
than being replaced outright, because this module's own building block
definition is TENANT_LEVEL and takes its project from PLATFORM_TENANT_ID, so
the zone project genuinely is unknowable in that path. A moved block covers the
folder assignment becoming count-gated.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…e_email

The composition now sources modules/stackit/ske/backplane and takes the service
account email from it, instead of asking an operator for one. It passes
dns.admin on the zone-owning project through the new additional_project_roles,
so the DNS record set keeps working after the move — without that grant, going
through the backplane would break DNS silently.

acme_email is gone from both tiers and from the building block definition. Let's
Encrypt accepts an account with no contact address and cert-manager reports
renewal failures in the cluster, so the address is only a backstop. The ingress
module keeps its own input required, because the contact address is not one
value across the estate. Exposing it again is noted as a later feature.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The kubernetes and helm providers took a service account token only, but
modules/stackit/ske hands out a client-certificate kubeconfig, so the AI
platform reference architecture could not have driven the gateway at all. The
token becomes optional, client_certificate and client_key are accepted
alongside it, and two validations require exactly one of the two credentials.
This mirrors modules/ai/model-access, whose provider already takes both.

Adds a service_port output so a composition can point an Ingress backend at the
gateway without hardcoding 4000.

The README also loses its reference to the deleted ai/litellm-team module,
because terraform-docs regenerates that file here and the two edits cannot be
separated.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The architecture was documentation only. It now has a meshstack_integration.tf,
a composing buildingblock/ and a logo, so a platform team can order it.

The composition installs the gateway and ClickHouse once, registers LiteLLM as a
custom meshStack platform, and creates one landing zone per entry with
ai/model-access in mandatory_building_block_refs. Every building block
definition input is STATIC, because a mandatory block that stops to ask a human
defeats the purpose and API-driven tenant creation only succeeds when inputs are
defaulted or static. ai/langfuse is deliberately absent: it is per tenant, and
ai/model-access creates it.

The platform type is named for the capability rather than for the product that
delivers it, so it is AI-MODEL and not LITELLM — matching the tenant-facing
block being called AI Model Access. A dash and not an underscore, because
meshStack validates the name against ^[A-Z0-9]+(-[A-Z0-9]+)*$. The old
ai/model-access default of LiteLLM was mixed case and could never have named a
real platform type.

The README and both diagrams are corrected in the same change. They still named
the deleted ai/litellm-team module and still called Langfuse one shared
instance, which the per-tenant design replaced. The last dangling references in
ai/azure-openai go with them.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The building block declares its own provider with use_oidc hardcoded, so a
caller holding a service account key cannot drive it, and no caller can use
count, for_each or depends_on on it. buildingblock/zone/ now holds all seven
resources and the root is a thin wrapper with moved blocks, the same split
storage-bucket and postgresflex already made.

The submodule keeps the credential together with the zone it writes into,
because both precedents do the same: bucket/ mints its own object storage
credential and database/ its own user.

Fixes a bug that would have failed at plan time. STACKIT caps a service account
name at 20 characters, and the old default derived it as mesh-dns-<zone with
dots replaced>, which is 27 for any zone under a free STACKIT subdomain. The
name now uses a truncated first label plus four hex digits of the zone hash, and
the input carries a length validation.

Adds the module's first unit tests, three of them.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Both are shaped like the existing nullable network variable, as CODE inputs on
the existing block rather than a new wrapper architecture.

The kubernetes option creates the shared DNS zone once in the foundation
project, through the new provider-free zone submodule, and passes the zone
name, its project and the DNS credential into the cluster building block
definition as STATIC inputs. The ordered cluster never creates a zone. This is
the same division of labour the network option already uses.

The ai option sources the AI platform architecture and orders its definition
once, which makes ai-platform the first reference architecture consumed as a
component by another one. It derives the apps domain and the application
platform identifier from what the landing zone already knows, so it genuinely
sits on top of the kubernetes option; a validation makes ai without kubernetes
an error.

The identity provider stays generic. Nothing in these repos names a concrete
one, so the oidc fields are passed through untouched and the accepted risk is
restated where an operator fills them in: every tenant's Langfuse trusts the
same client, so anyone the provider authenticates can reach any tenant's
tracing instance.

The DNS credential is zone-wide, so a cluster could write outside its own
label. What keeps clusters inside their label is this code, not the credential.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@grubmeshi grubmeshi changed the title feat(ref-arch): draft STACKIT AI reference architecture feat(ai): make the AI platform reference architecture orderable Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant