Docs · Apron
Apron
Ground infrastructure a cluster needs before a Hangar can run on it.
Apron deploys Hangar onto your Kubernetes cluster. It renders the cluster’s own GitOps repo from a small
cluster.yaml, then onboards the cluster: two Argo CD instances (argocd for the platform, argocd-apps for apps),
the root app-of-apps, and stops for the few secrets a person must plant. Onboarding an existing cluster is the
primary path. kiac and kind are conveniences that create a local cluster first and then run the same onboarding.
Interim engine. Rendering is Apron’s own token templates today. Porting them to the kubara catalog is next (the 2026-10-16 review decision: kubara as the only renderer, Apron’s installs driven from its output), so don’t build on the template format.
The one-name rule
A cluster has one name, chosen by you, and everything else derives from it. Apron’s code, templates and docs contain
no cluster names. For --name hangar-dev:
| What | Value |
|---|---|
| kubectl context | hangar-dev (Apron renames kiac-hangar-dev / kind-hangar-dev to it) |
fleet record (clusters.yaml) |
name: hangar-dev |
| Infisical projects | <app>-hangar-dev, glidepath-hangar-dev |
| ServiceAccount issuer | https://<issuerBase>/oidc/hangar-dev (issuerBase default hangarplatform.dev) |
| hostnames | argocd.hangar-dev.local, … (--domain overrides <name>.local) |
Names must be DNS-1123 labels. There is no default owner either: --scm-owner is required, and --registry-owner
defaults to it.
Profiles and runtimes (independent)
- A profile says what the cluster runs.
profiles/dev.yamlis the lower zone: the CI/CD control plane (Glidepath, Bootstrap-tier Crossplane, the full service catalog), workloads, the fleet’s Infisical server, full observability, Autopilot and AI triage.profiles/prod.yamlis the upper zone: workloads and platform services, an Infisical consumer, the Attached-tier catalog plus Dex, Prometheus and tracing, andtowerAccess. Tower itself (Backstage) is an Airframe app (app-backstage-<env>), not a cluster component:towerAccessholds only its cluster-scoped grants, and65-backstagestays in the templates off by default, for reference. - Observability reaches apps through
cluster-defaults.yaml’sobservabilityblock: every environment’s NetworkPolicy admits Prometheus, and withobservability.tracing(both profiles; implied byobservability.full) the OpenTelemetry Operator auto-instruments Node.js, Java and Python apps (picked from the app’s Airframe kind) into the gateway collector. The collector drops probe and/metricsspans, derives span metrics and the service graph (traces_service_graph_*, Tower’s Airspace edges) for Prometheus, and sends traces to Tempo (MinIO-backed with the full stack, local disk without) unlessobservability.tempo: false(the prod profile: no trace store). - A runtime says how the cluster comes to exist:
existing(any cluster, Traefik as a LoadBalancer),kiac(single-node VM, Cilium, Traefik on NodePorts 30080/30443; never--gateway) orkind(Calico v3.29.1, host 80/443 mapped to Traefik’s NodePorts). Defaults live inruntimes/*.yaml. Every runtime gets the same Traefik Gateway (hangarinhangar-gateway), installed by Apron. - Any value can be overridden in
cluster.yamlor with--set key=valueat init:--set runtime.kiac.cpus=8,--set components.aiTriage=false. versions.yamlpins Airframe, Glidepath and the Argo CD chart in one place. The name-free Airframe/Glidepath/Tower changes are on theirno-hardcoded-cluster-namesbranches: cut tags from them and set those tags inversions.yamlbefore the rebuild uses them.
Autopilot (components.autopilot, control-plane clusters only)
55-autopilot/ holds what Autopilot’s agent runs need on the cluster. apron check refuses it off the control-plane
cluster and without the full catalog.
| Path | What | Guardrail |
|---|---|---|
namespaces/ |
autopilot-system (Clearance, the model proxy) and autopilot-runs (AgentRun XRs), pod security restricted |
never labelled hangar.io/ephemeral-env |
agent-sandbox/ |
the kubernetes-sigs/agent-sandbox controller, chart pinned by commit and image by digest (v1.0.8) | a run’s Sandbox shutdownTime ends its pod with no Clearance or Crossplane involved |
clearance/ |
the Autopilot gateway (Deployment clearance), its read API (autopilot-api), autopilot-pg, their NetworkPolicies and routes (autopilot.<domain>: /mcp, the inbox, person controls, Run, and a public GET /healthz) |
ingress default-deny but for run namespaces and the cluster Gateway; with observability.full both send a copy of their log lines to the OTel collector (OTLP/HTTP, on to Loki as service_name autopilot-gateway / autopilot-api) |
sandbox-runtime/ |
with autopilot.hardenedRuntime: gvisor only: RuntimeClass gvisor (handler runsc), scheduled onto nodes labelled and tainted hangar.io/runtime=gvisor (hangar-infra’s eks-cluster gvisor = true node group) |
the same key gives function-agentrun HANGAR_RUNTIME_CLASSES; without it a sandbox: hardened run is Rejected, never downgraded. apron check refuses it on kind and kiac, which have no gVisor |
agentrun-grants/ |
provider-kubernetes’s ClusterRole for a run’s objects, the ValidatingAdmissionPolicy that confines it, a second policy on who may create such Objects, Clearance’s Role | the grant only ships with the policy: run namespaces (agent-r-*) only, no pod-security label changes, no PR-sweep label, serviceAccountName: run only (decision D15); and a provider-kubernetes Object with a run-kind manifest comes only from Crossplane core for an AgentRun, never hand-made |
It also gives provider-kubernetes a stable service account (10-crds-operators/crossplane/provider-kubernetes-runtime.yaml)
and installs function-agentrun with the cluster’s name and its runtime classes (function-agentrun-runtime.yaml). Runs land only once the
cluster’s registry record has airframe.autopilotReady, set after autopilot’s tools/netpol_canary.py passes.
tests/policy/test_agentrun_grants.py proves the policy against a real kube-apiserver (envtest binaries in
KUBEBUILDER_ASSETS; tests/run.sh skips it without them).
Clearance’s Argo CD gate (clearance.gate, Draft)
clearance.gate.enabled: true puts Clearance’s Config Management Plugin in front of every protected tenant
Application (clearance docs/gate.md, “The Argo CD plugin”). Two things render:
- argocd-apps’ repo-server gets the
clearance-cmpsidecar (versions.clearance.cmpImage, by digest), the only container that mounts the clearance token (a projected service-account token, audienceclearance). - the tenant-onboarding ApplicationSet gives an environment the gate covers one source, the plugin
(
plugin.name: clearance) on the app’s gitops repo, in place of the Helm source and its$appsrcref. The plugin fetches the same chart at the same pinned tag and renders it with the same value files and platform values. An environment is covered when<cluster>/<env>matchesclearance.protectedEnvironments(every environment when that is empty); an environment on a registry (OCI) chart keeps the Helm source.
cluster.yaml key |
|
|---|---|
clearance.gate.enabled |
default off: nothing renders, an enabled cluster needs the next two pins |
versions.clearance.cmpImage, waitImage |
the clearance-cmp and clearance-wait images, by digest |
clearance.gate.url |
the core as the sidecar and the PreSync wait Job reach it; default http://clearance-core.<namespace>.svc:8080 |
clearance.gate.hostAlias |
ip=host[,host] for the repo-server, the agent and the wait Job’s pod when the core is on another cluster |
clearance.gate.apps |
limits the plugin to these apps, to turn the gate on one app at a time; empty: every app in a protected environment |
clearance.gate.agent |
renders the Argo CD agent (56-clearance-gate/agent/): apiURL (clearance-api), projects, protectedNamespaces; needs versions.clearance.argocdAgentImage and argocdAgentChart |
With the gate on and Flight admission on, the exact versions.clearance.waitImage is added to Flight’s allowed images: the
wait Job runs in the tenant’s namespace and is not a Flight image.
On the cluster that runs the core (hangar-dev), clearance.foreignServiceAccounts, clearance.extraProviders,
clearance.verifiers and clearance.feedReaders admit the protected cluster’s agent, repo-server and wait Job;
feedReaders also opens the agent’s /v1/records and /v1/breakglass routes.
Clearance’s evidence ledger and its witness (clearance.ledger, clearance.witness, off by default)
Clearance C-3 (clearance docs/core.md, “The evidence ledger (C-3)”) keeps a transparency log of the evidence chain’s
record hashes, checkpointed and countersigned by a witness Clearance does not operate. Apron wires both ends; nothing
renders unless a cluster turns it on, and a cluster that does not renders byte-for-byte what it did before.
The log, on the cluster that runs the core (hangar-dev, components.clearance). clearance.ledger.enabled: true
passes the chart’s ledger.* values to the clearance Application (the chart gives the core a ReadWriteOnce volume and
a Recreate rollout), renders 55-clearance/clearance-config/ledger-key.yaml (an ExternalSecret: Infisical secret
clearance.ledger.keySecret, default hangar-ledger-<cluster>-key, into Secret clearance-ledger-key, key
ledger-key), the witness policy built from clearance.ledger.witnesses, and the core’s egress to the witnesses.
cluster.yaml key |
|
|---|---|
clearance.ledger.enabled |
default off |
clearance.ledger.witnesses |
required: [{name, vkey, url}]. vkey is the witness’s public cosignature key (clearance ledger keygen --witness prints it: type byte 0x04; apron check refuses the log’s own key form). url is the witness’s base (http(s)://host[:port]); the log appends /add-checkpoint. Policy line: witness <name> <vkey> <url> |
clearance.ledger.quorum |
with several witnesses: all (default), any or a number; one witness is quorum <name> |
clearance.ledger.hostAlias |
ip=host[,host] for the core’s pod when a witness is on another cluster (like clearance.gate.hostAlias); also narrows the core’s egress to that /32 and the witness’s port |
clearance.ledger.keySecret |
the Infisical secret name of the log’s signing key; default hangar-ledger-<cluster>-key |
clearance.ledger.retiredKeys, checkpointInterval, stallAfter, tier3Wait, storage.size / storage.storageClass |
the chart’s ledger.retiredKeys, checkpointInterval, stallAfter, tier3Wait (default true) and persistence (default 1Gi) |
The Kubernetes audit collector (clearance.observe.enabled). 57-clearance-observe/ installs the collector into
clearance-system, on any cluster. Set clearance.observe.protectedNamespaces, serviceIP (the pinned ClusterIP its
certificate names), kubeletNodes (node names whose kubelets’ pod-token requests are routine; a node not listed shows as
out-of-band) and argocdNamespace (default argocd on the control plane, else argocd-apps). The core registers it through clearance.extraProviders and,
for another cluster’s collector, foreignServiceAccounts. The TLS Secret and the API server flags are node work:
docs/observe-audit.md.
The AWS collector (clearance.observeAWS.enabled). 58-clearance-observe-aws/ installs Clearance’s AWS collector
into clearance-system (fixed: the AWS roles trust clearance-system:clearance-observe-aws). Set
clearance.observeAWS.account, queueURL, roleARN, applyRoles (required), and optionally region, environment,
trailRoleARN, trail.{name,bucket,prefix,region,regions}, dataBuckets, rules, ssoAdminRoles, governedAccounts.
The core and clearance-api admit its pod. Registering it is yours, in cluster.yaml: a clearance.extraProviders entry
named observe-aws (principal clearance-system/clearance-observe-aws; kinds observation, assessment, resolution,
observation.health, observation.verification, deploy.exception) and clearance.extraAuditors: [clearance-system/clearance-observe-aws] (principal globs appended verbatim after the user: auditors).
The witness, on a protected cluster (hangar-prod, zone upper). The clearance-witness chart is not published, only
its image, so 56-clearance-witness/ renders the same objects as plain manifests: namespace clearance-witness
(Pod Security restricted), one replica with Recreate, a state claim that is never pruned, a ConfigMap of the logs’
public keys, an ExternalSecret (Infisical clearance.witness.keySecret, default hangar-witness-<cluster>-key, into
Secret clearance-witness-key, key witness-key), a Service, a NetworkPolicy (ingress from the cluster Gateway on 8080
only, no egress: the witness calls nothing) and a route witness.<domain> that passes only POST /add-checkpoint and
GET /healthz. Needs versions.clearance.witnessImage (by digest; pinned in versions.yaml), which Flight admission
also allows, exactly, while the witness is on.
cluster.yaml key |
|
|---|---|
clearance.witness.enabled |
default off; refused on a lower-zone cluster or one that also keeps the log |
clearance.witness.name |
required: the name the log’s operator lists the witness under |
clearance.witness.logsVkeys |
required: the logs’ public verifier keys (type 0x01), one per log (a rotation lists the old and new key) |
clearance.witness.host |
default witness.<domain> |
clearance.witness.keySecret, storage.size / storage.storageClass |
Infisical secret name; state claim (default 64Mi) |
How the core reaches the witness: hangar-prod exposes witness.hangar-prod.local on its gateway (host port 80);
hangar-dev’s core cannot resolve that, so clearance.ledger.hostAlias: <hangar-prod LAN address>=witness.hangar-prod.local
gives the core pod a hosts entry, and the witness url is http://witness.hangar-prod.local. Private keys are never in
this repository or in a rendered file: clearance ledger keygen writes them to a file the owner puts in Infisical.
Check names (pipelinesAsCode.applicationName)
Pipelines-as-Code names every check run <application-name> / <gate>-. Apron sets the name from cluster.yaml
pipelinesAsCode.applicationName (default Glidepath, so checks read Glidepath / sast-) in two places that must
match: PaC’s pipelines-as-code ConfigMap (50-glidepath/tekton-operator/pipelines-as-code-vendored.yaml) and the
glidepath-catalog’s pipelinesAsCode.applicationName, which the gate tasks find checks by. The PaC GitHub App can have
any name; Backstage’s GLIDEPATH_GATE_COMMENT_APPS must list its slug. See Glidepath’s
github-app.md, including how to rename it.
Flight admission (admission.flight, prod profile)
On an upper cluster, Apron renders Glidepath’s Flight admission (ADR-0029,
registries.md) into
30-policy/kyverno-policies/flight-images.yaml. apron render refuses enabled without registries.flight.namespace
and promotion.promoter.repo in cluster.yaml.
| Key | Renders | Turn it on when |
|---|---|---|
enabled |
flight-only-images (Flight prefix or allowedPrefixes) and flight-images (promotion attestation, offline), [Audit, Warn] with failurePolicy: Ignore, plus kyverno/flight-registry-read from Infisical flight-registry-read-dockerconfigjson |
promotion is on in Glidepath |
pullFromFlight |
tenant registry-credentials reads flight-registry-read-dockerconfigjson (read-only) instead of the Ground credential |
every app’s prod release pins Flight; an app still on Ground could not pull on its next restart |
enforce |
[Deny] with failurePolicy: Fail |
a server-side dry run of every running tenant pod gives no warning. While Kyverno’s admission controller is down, new tenant pods are refused |
allowedPrefixes |
the third-party prefixes flight-only-images lets through (CloudNativePG, OTel auto-instrumentation, bitnamilegacy) |
a tenant runs another image no Glidepath build produces |
hangar-prod: Audit 2026-10-29; pullFromFlight and enforce on since 2026-10-30.
Clearance gate (clearance.gate, upper clusters)
On an upper cluster, Apron renders Clearance’s deployment gate for Argo CD (clearance docs/gate.md,
G-ARGO-2) into 56-clearance-gate/: the ClearanceGrant CRD and two native ValidatingAdmissionPolicies.
clearance-protected-writes lets Argo CD’s application controller write to a namespace labelled
clearance.hangar.io/protected=true only if the object carries the CMP’s clearance.hangar.io/rendered stamp and the
namespace’s one active, valid grant lists it at that hash; clearance-grant-writer lets only the grant-sync agent write
grants. Both match one service account, so no other writer is touched. The policy also exempts the plugin’s injected clearance.hangar.io/injected: wait PreSync Job, but only the Job exactly
as the plugin builds it: the image exactly versions.clearance.waitImage (required with clearance.gate.enabled),
args --change <id> --timeout <d> only, CLEARANCE_URL pinned to clearance.gate.url, hostAliases pinned to
clearance.gate.hostAlias, no command, envFrom, host namespace, privileged container or volume besides the
audience-clearance token (the waiter mounts a clearance token, so its URL must not be changeable).
| Key | Renders | Turn it on when |
|---|---|---|
enabled |
the CRD, both policies and their bindings, [Warn, Audit] with parameterNotFoundAction: Allow |
the cluster is a gate target (scratch namespace first) |
enforce |
[Deny, Audit] and parameterNotFoundAction: Deny |
Warn is clean on every protected namespace, and the agent keeps one active grant on each (with Deny and no active grant, every matched write in the namespace is refused, from any user) |
argocdServiceAccount, agentServiceAccount |
the two usernames the policies name (<namespace>:<name>) |
the defaults (argocd-apps:argocd-application-controller, clearance-system:clearance-argocd-agent) are wrong |
tests/policy/test_clearance_gate.py proves the rendered policies against a real kube-apiserver (envtest); clearance’s
providers/argocd/crd/test_admission.py carries the full matrix. The two copies of the manifests are kept in step by hand.
Install
-
Create two new repos per cluster, the cluster repo and its tenants repo (
<repo>-tenantsby default): by hand, or withapron init --create-repos, which runsgh repo create <owner>/<name> --privateonly for repos that don’t exist. Old cluster repos are never touched. -
Init writes
DIR/cluster.yaml(committed: it is what re-renders from), checks it, and renders the repo:apron init dev --name hangar-dev --runtime kiac --out ~/gitops-cluster-hangar-dev \ --repo-url https://github.com/<owner>/gitops-cluster-hangar-dev --scm-owner <owner> --tower-cluster hangar-prod apron init prod --name hangar-prod --runtime kind --out ~/gitops-cluster-hangar-prod \ --repo-url https://github.com/<owner>/gitops-cluster-hangar-prod --scm-owner <owner> \ --fleet-hub hangar-dev --fleet-repo-url https://github.com/<owner>/gitops-cluster-hangar-dev--runtime existing --context <ctx>adopts an existing cluster instead. -
Commit and push the cluster repo (
--pushdoes it). -
Up:
apron up --out DIR(add--dry-runto see the plan). Phase 1 is the runtime: create the cluster, or verify an existing one is reachable, and make its context equal the name. Phase 2 is onboarding, the same for every runtime: CNI if the runtime needs one, Argo CD, then a stop for every secret a person must plant (exact commands printed, values never). Re-runapron upafter planting them: it resumes and applies the root app. -
Fleet:
apron fleet add --from DIR --fleet <hub repo>/clusters.yamlwrites the cluster’s record (with the file’s requiredscm: {owner}block) in the hub’s fleet file. After Infisical’s first-run setup,apron fleet set-infisical --fleet ... --org-id X --url Ufills theinfisical:block. -
Identity:
apron identity --out DIRgives the cluster its issuer and, on the hub, trusts Tower’s cluster (hack/cluster-identity.shwith names derived from the cluster; docs/cluster-identity.md). Publish the issuer’s keys first.
Day 2, after upgrading Apron or bumping versions.yaml: apron render --out DIR re-renders from cluster.yaml. It
prints what it added, changed and removed, and leaves cluster.yaml and clusters.yaml alone. Review, commit, push.
Install Hangar on a cluster
./apron does it in two commands (docs/cli.md). Pick a name: it becomes the cluster’s kubectl
context, fleet entry, Infisical project stem, issuer path and hostname stem.
An existing cluster (the primary path). Its kubectl context must be the cluster’s name
(apron up --rename-context <current> renames it):
./apron init prod --name <name> --runtime existing --repo-url https://github.com/<owner>/gitops-<name>.git \
--scm-owner <owner> --out ../gitops-<name> --create-repos --push
./apron up --out ../gitops-<name>
A local cluster: the same, with --runtime kiac (Apple container VMs) or --runtime kind. apron up creates the
cluster first, then onboards it exactly as above.
apron up stops once, after installing Argo CD, to list the Secrets you create by hand (it prints the commands with
<value> placeholders). Create them and run apron up again. Then follow the steps it prints
(docs/after-up.md) and add the cluster to the fleet with
./apron fleet add --from ../gitops-<name> --fleet <control-plane repo>/clusters.yaml.
AWS (--runtime eks, the platform profile)
hangar-infra (Terraform) builds the accounts, networks, Infisical as its own
service and the EKS clusters; Apron onboards each cluster like any existing one. Its docs/install.md is the walkthrough.
What changes on EKS:
- Runtime
eks(runtimes/eks.yaml):apron uprunsaws eks update-kubeconfig --alias <name>instead of creating a cluster. Traefik’s Service is an NLB from the AWS Load Balancer Controller (traefik.loadBalancer): TLS ends at the NLB with an ACM certificate, 443 forwards to Traefik’s HTTP entrypoint, and onlysourceRangesget in.gateway.fleetRoutesputs the hub’s release relay (relay.<domain>) and the PaC webhook (pac.<domain>) on the gateway, since there are no shared NodePorts between VPCs.gateway.scheme: httpsmakes the URLs Apron builds on<service>.<domain>(Clearance, people-dex) https. - Issuers: EKS takes no API server flags, so
issueris the cluster’s own EKS OIDC issuer (apron checkrefuses anything else oneks), andapron identityis refused. Tower’s cluster is trusted through an EKS OIDC identity provider config (Terraform) with username prefixhangar:<tower cluster>:, so withtower.trust: eks-oidcthe RBAC subjects arehangar:<tower>:system:serviceaccount:<ns>:<sa>(@@TOWER_USER_*@@). Settower.issuerto Tower’s cluster’s EKS issuer. - Infisical off every cluster:
infisical.host: falseeverywhere andinfisical.urlset to the service. On the control plane, Glidepath’s chart rendersplatform-secret-storewith universal auth (glidepathsecretStore.infisical.auth), and Apron’s own consumer store stays off. Every cluster getsprovider-infisical-credsplanted by hand (apron upprints the command). - Profile
platform: upper zone,roles: [platform-services], Tower (towerAccess) and its database, no app workloads, no Mongo/RabbitMQ/AI triage. With a platform cluster, prod setsroles: [workloads],components.towerAccess: falseandcomponents.towerAccessHub: true(Tower reads it from the platform cluster).
Rebuilding the lab
The lab’s dev cluster runs on a Mac with kiac, its prod cluster on a second MacBook with kind. That is a hardware
choice, not a rule: either profile runs on any runtime. Steps 1-6 above with --name hangar-dev --runtime kiac and
--name hangar-prod --runtime kind. Then:
- Hosts reach a kiac or kind cluster’s Gateway through its node:
/etc/hostsmapsargocd.<name>.localand the rest (apron upprints them) to the node’s IP. kind maps host port 80; on kiac use<node IP>:30080, or forward 80 to it. - The prod cluster reaches the hub’s Infisical and release relay at
<hub>.local:31800/:30880. If its pods can’t resolve that name, setfleet.hubAddressto an IP the hub’s NodePorts answer on, and Apron adds hostAliases.
Layout
| Path | What |
|---|---|
templates/ |
every file a cluster repo gets, by numbered group (00 bootstrap … 70 AI triage); MANIFEST selects paths, #@if selects lines; tokens in templates/TOKENS.md |
profiles/, runtimes/, versions.yaml |
defaults layered under cluster.yaml |
hack/render.py, hack/check.py, hack/new_cluster.py |
render, invariants (zone/roles, singletons, one-name rule), starter cluster.yaml |
hack/cluster-identity.sh |
issuer and trust on a running control-plane node |
hack/build-trust-root.sh |
the Sigstore trusted root and Fulcio chain from the cluster repo’s public signing-pki/ files (docs/signing-pki.md) |
tests/run.sh |
offline tests: renders, re-render, every invariant refused |
apron, hack/apron_fleet.py, tests/cli/ |
the CLI (init, render, up, fleet, identity, verify, resume, down) and its tests |
docs/ |
the fleet file, cluster identity, the CLI, after apron up |