feat(ai): make the AI platform reference architecture orderable - #250
feat(ai): make the AI platform reference architecture orderable#250grubmeshi wants to merge 60 commits into
Conversation
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]>
Scorecard Check
📊 meshstack-hub Module Scorecard
📋 Per-Module Category SummaryScore per category per building block.
Core Structure — some checks failingBasic module file structure and documentation — applies to 13 modules
Core Structure — Summary
Integration — some checks failingmeshstack_integration.tf conventions — applies to 7 modules
Integration — Summary
Azure Backplane — not applicableAzure UAMI-based automation principal conventions — applies to 0 modules No applicable modules. STACKIT Backplane — ✅ all passingSTACKIT WIF-based automation principal conventions — applies to 5 modules
STACKIT Backplane — Summary
Testing — some checks failingEnd-to-end test coverage — applies to 13 modules
Testing — Summary
📈 Overall SummaryOverall Average Score: 81%Score Distribution
|
|
This pull request is automatically being deployed by Amplify Hosting (learn more). |
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]>
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]>
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 isSTATICor 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
ai/model-accessai/litellmai/langfuseai/clickhouseai/azure-openaikubernetes/ingresskubernetes/platformstackit/skestackit/dnszone/submodule. 3 tests.stackit/model-serving,stackit/postgresflex,stackit/storage-bucketmodules/ai/litellm-teamwas deleted;ai/model-accessreplaces it.Reference architectures
ai-platformis now orderable — integration file, composing building block, logo. It installs the gateway and ClickHouse once, registers LiteLLM as a custom platform of typeAI-MODEL, and creates one landing zone per entry withai/model-accessinmandatory_building_block_refs.stackit-kubernetescomposes 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-landingzonegains akubernetesoption and anaioption on top of it, both shaped like its existingnetworkinput. This makesai-platformthe first reference architecture consumed as a component by another one.The Let's Encrypt rate limit this also fixes
stackit.runis a public suffix, so<zone>.stackit.runis the registered domain and the limit of 50 certificates per 168h applies to all of it. The hourlyske/ske-starterkitsmoke test was red in about 9 of 11 runs with429 ... 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
ai/model-accesspinsncecere/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-bucketboundshashicorp/aws >= 4.0, < 5.0, because AWS provider v5 always sendsLocationConstraintinCreateBucketand STACKIT's StorageGRID rejects it. Neither is fixable without an upstream change.LiteLLM_UserTableand 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.LANGFUSE_DEFAULT_ORG_IDset, 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 thestackit-landingzoneintegration.crds.keep = falseis carried forward from all five foundation copies, which keeps the migration a pure code move. Withfalse, destroying the release makes Helm delete the CRDs and the garbage collector then removes everyCertificate,Issuer,ClusterIssuerandOrderon the cluster.cert_manager_crds_keepis an input for anyone who wants to close it off.Not in this PR
modules/aks/platformand anazure-kubernetesbuilding block. Follow-ups. Three AKS platform-registration copies wait for the first of them.Validation
tofu validate,terraform-docsand the unit tests pass; pre-commit passes undernix develop;task diagrams:checkis green. The scorecard does not cover reference architectures at all, soai-platform,stackit-kubernetesandstackit-landingzoneare unscored by design.🤖 Generated with Claude Code