tier0 common-infra — guide
Class:
guide· audience: a platform engineer bringing up a fresh cluster's shared services before any app. The what we ship is intier0-common-infra-design.md.
The idea
Before a single lake app (holger, tunnr, …) can run, the cluster needs its shared substrate: an ingress controller, a TLS issuer, an artifact registry, and a secret store. Skidbladnir describes these four as ordinary ComponentSpecs (the same type your apps use) and orders their bring-up with the same wave engine that orders app installs. One vocabulary, top to bottom.
Bring the substrate up, in order
use skidbladnir::product::{common_infra, common_infra_waves};
use skidbladnir::service::{renderer_for, Backend};
// 1. The dependency-ordered waves (pure — no cluster needed yet).
let waves = common_infra_waves()?; // [[cert-manager, ingress-nginx], [holger-registry, openbao]]
// 2. Render each component to k8s manifests (pure DATA).
for name in waves.iter().flatten() {
let spec = common_infra().into_iter().find(|c| &c.name == name).unwrap();
let rendered = renderer_for(Backend::Kubernetes)
.unwrap()
.render(&spec.to_service_spec(&layout, Backend::Kubernetes));
// rendered.files = the StatefulSet/Deployment + Service + PVC YAML to apply.
// rendered.enable/start = the kubectl-free verbs (the `kubernetes` feature applies them).
}
Apply wave by wave: everything in waves[0] in parallel, wait for readiness, then waves[1]. holger-registry and openbao won't come up until ingress-nginx/cert-manager are ready — the wave order guarantees it.
Populate the OpenBao structure (once it's up)
The openbao component is only the pod; its structure (auth method + per-env policy/role) is a separate, idempotent step. Build the plan offline, apply it live:
use skidbladnir::openbao::{OpenBao, PopulatePlan}; // feature = "openbao"
// Pure DATA — assert it in a test before you ever touch a live vault.
let plan = PopulatePlan::for_platform(
"https://kubernetes.default.svc",
&[("prod", "holger-sa", "holger"), ("staging", "holger-sa", "holger-staging")],
);
// Live executor (idempotent; needs a reachable OpenBao + an admin token).
let bao = OpenBao::from_env()?; // VAULT_ADDR/BAO_ADDR + VAULT_TOKEN/BAO_TOKEN
bao.apply(&plan)?;
Each env gets a read policy scoped to secret/data/<env>/* and a k8s-auth role binding its ServiceAccount — nothing more. provision_env(env, sa, ns) is the single-env shortcut (same per-env structure).
Render the full ingress-nginx object set (production-shaped)
The generic renderer gives one Deployment + Service — fine for an app, but the ingress controller needs its whole graph (RBAC, an IngressClass, a LoadBalancer). infra::ingress_nginx_objects() renders the complete set as data:
use skidbladnir::infra::ingress_nginx_objects;
for obj in ingress_nginx_objects() {
// obj.path = "ingress-nginx/ingress-nginx-<kind>.yaml", obj.contents = the manifest YAML.
// 18 objects: the core 9 (ServiceAccount, ConfigMap, ClusterRole/Binding, Role/Binding,
// Deployment (runs AS the SA), LoadBalancer Service, IngressClass) + the admission
// webhook 9 (its SA + RBAC, the admission Service, the ValidatingWebhookConfiguration,
// and the two kube-webhook-certgen Jobs that self-provision + inject the serving cert).
apply(&obj.contents); // your kubectl-free applier (the `kubernetes` feature)
}
The RBAC is rendered coherent — both bindings target the ServiceAccount the Deployment runs as — so the grants reach the pod. The admission webhook self-provisions its serving cert via the two certgen Jobs (a create + a patch) instead of depending on cert-manager, so ingress-nginx stays a wave-0 root. Identity values (name/namespace/image/ports) come from ComponentSpec::ingress_nginx(), so there is one source of truth.
Render the full cert-manager object set (three components)
cert-manager is three controllers (controller + webhook + cainjector), so its set is deeper — infra::cert_manager_objects() renders all 28 objects:
use skidbladnir::infra::cert_manager_objects;
for obj in cert_manager_objects() {
// Per component: ServiceAccount + ClusterRole/Binding + Role/Binding + Deployment (runs AS
// its SA). Plus the controller/webhook Services, the Validating + Mutating webhook configs,
// and the six CRD registrations (Certificate/Issuer/ClusterIssuer/Order/Challenge/…).
apply(&obj.contents);
}
The webhook/cainjector images are the controller image with the component name swapped, so the version pin from ComponentSpec::cert_manager() is the single source. CRD schemas are deferred: each CRD is registered (group/scope/names) with an x-kubernetes-preserve-unknown-fields: true schema and a # SCHEMA DEFERRED marker — apply the registrations now, author the strict OpenAPI validation in a later slice.
What's deferred (the live back half)
- Live cluster apply — driving the rendered manifests over the k8s API (server-side apply / scale / rollout-wait) is the
kubernetesfeature (k8s_apply.rs); the renderer half above needs no cluster. - Live
OpenBao::apply— theopenbaofeature's HTTP calls need a reachable OpenBao; the plan itself is pure and tested offline.