Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/Skidbladnir

.nornir/tier0-common-infra-guide.md

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 in tier0-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)