Hangar Platform · Airframe · Developer Guide · Part 2
Part 1 took a NodeJS service from nothing to a canary. This part does the same for a Spring Boot service that owns a PostgreSQL database, then connects part 1's boarding-api to it so the gate board shows real data. We build flight-api: 24 flights and their gates in Postgres, a REST API, and a simulator that keeps moving flights around.
What has and hasn't been verified
Verified: the service (17 unit tests in the platform's own Java build image, 4 integration tests and the full app against a real PostgreSQL, and the build: build.sh compiles natively in the exact Java agent image in about 30 seconds, and the thin image built from it starts and answers against a real PostgreSQL), and boarding-api calling it end to end. The scaffold's own Containerfile, which compiles inside the image build, did finish, but took about 17 minutes under amd64 emulation (the amd64 leg alone took 11), which is why this guide replaces it. Verified on both clusters: the database component (Ready, data kept across a restart, clean teardown), and on prod, which enforces network policy, a control run without the component's operator policy never became healthy. Verified by rendering only: the chart passing the database Secret into the container. Verified by walking it (2026-09-25): create → pipeline → ground deploy on the dev cluster; boarding-api on dev serves from flight-api (live status from the simulator); flight-api runs on prod staging with a two-instance database. Walking it found and fixed the slow emulated Java build and a liveness probe pointed at the readiness endpoint. Not yet confirmed: boarding-api on prod staging still uses its built-in lookup (FLIGHT_API_URL not set there), so the staging call path has not been exercised.
Needs airframe v0.3.88 or later
env: entries with valueFrom arrive in v0.3.88. Do part 1 first: this guide assumes boarding-api is running in its dev environment and links back rather than repeating the mechanics.
THE PATH
The new idea is that a database is a component, a block in the environment's values, not a Tower form. Everything else is part 1's mechanics.
01 — WHAT YOU'RE BUILDING
flight-api and its database live in one namespace. The database is a dedicated PostgreSQL cluster for this app environment, not a shared one: deleting the environment deletes its data, and nothing outside the namespace can reach it.
The component creates a database and the role that owns it with the same name (flight_db). Its pg_hba lets a role connect only to the database named for it, so the app cannot reach any other database, postgres or template1.
CNPG writes flight-db-app (host, port, dbname, username, password, uri…). The container reads it directly with valueFrom.secretKeyRef. Nothing is copied into Infisical.
Namespaces deny cross-namespace ingress by default, which would cut the operator off. The component adds a policy that admits cnpg-system and nothing else.
02 — CREATE THE APP
| Field | Value |
|---|---|
| Name | flight-api |
| devCluster | dev |
| description | Skyport system of record: flights and gates in Postgres |
| javaVersion | 21 |
| buildTool | maven |
| groupId | io.skyport.flight |
| port | 8080 |
| visibility | private |
Two values that matter
dev exactly, even if your dev cluster goes by another name (see part 1, section 02: the dev ApplicationSets hard-code that name). io.skyport.flight for groupId: the scaffold uses it as the app's one Java package, and the code you copy in sits in io/skyport/flight/.
Merge the PR Tower opens, then check the XR:
$ kubectl get springbootapplication flight-api -n app-flight-api-cicd NAME SYNCED READY COMPOSITION AGE flight-api True True springbootapplications.catalog.idp.io 3m
The source repo starts with a hello-world Application.java, a pom.xml (web and actuator only), an application.properties and a Containerfile. If a previous flight-api was decommissioned, finish decommissioning first (including its Infisical projects).
03 — PIPELINE ONBOARDING
Same as part 1, section 03: Glidepath opens a PR adding .tekton/. Merge it, or pushes do nothing.
04 — PULL IN THE CODE
The real flight-api lives in the airframe repo under examples/skyport/flight-api/.
git clone https://github.com/jfillman/airframe.git /tmp/airframe # skip if you still have it git clone https://github.com/jfillman/flight-api.git && cd flight-api cp -R /tmp/airframe/examples/skyport/flight-api/. . git add -A && git commit -m "flight-api: flights and gates in Postgres"
This overwrites the scaffold's pom.xml, Application.java, application.properties and Containerfile, and adds the other classes, the SQL migrations, the tests, test.sh, build.sh and the Maven Wrapper. The wrapper matters: the platform builds Java in a plain JDK image with no Maven, so test.sh runs ./mvnw, which downloads Maven on first use.
Why the Containerfile is replaced (unlike part 1)
The scaffold's Java Containerfile compiles the app inside the image build. That build runs for two architectures, and the amd64 leg runs under QEMU emulation on the arm64 build node, where Maven on a JVM took about 11 minutes, while the native arm64 leg took about five (the legs run one after the other: about 17 minutes in all). Java bytecode is identical on both, so the fix is to compile once, natively: build.sh compiles inside the pipeline's Java agent, and the new Containerfile only copies the jar into a JRE image. Measured: the emulated amd64 packaging leg takes about 30 seconds. FROM --platform=$BUILDPLATFORM, the usual answer, does not work here: this platform's builder (kaniko) ignores it, so the build stage still ran emulated.
./test.sh # 17 passing (needs a JDK 21 locally, or run it in a container)
| Endpoint | Purpose |
|---|---|
GET /api/flights/{flight} | Gate, status, scheduled and estimated departure, capacity, boarding group |
GET /api/flights | All 24 flights |
GET /api/flights/{flight}/events | The change log for one flight, newest first |
PUT …/gate · PUT …/delay | Move a flight, or delay it |
GET /api/whoami | Version and pod |
GET /actuator/health/readiness | Ready only when the database answers |
Every gate or delay change is written to a flight_events table in the same transaction as the change, so the log can never disagree with the flights. Phase 2's message broker will publish from that table. A simulator (every 30 s) moves a random flight or grows a delay so there is something to watch.
05 — GROUND
Same two-part shape as part 1 (a file that creates the environment, a pipeline stage that deploys to it) with a database added.
envName: dev
rollout: null # explicit; the deploy stage fills in the image later
components:
- type: postgresql
name: flight-db # the Secret this creates is named flight-db-app
spec: { size: small, instances: 1, storageSize: 1Gi }
env:
# Read straight from the Secret the database component creates - no copying into Infisical.
- { name: DB_HOST, valueFrom: { secretKeyRef: { name: flight-db-app, key: host } } }
- { name: DB_PORT, valueFrom: { secretKeyRef: { name: flight-db-app, key: port } } }
- { name: DB_NAME, valueFrom: { secretKeyRef: { name: flight-db-app, key: dbname } } }
- { name: DB_USER, valueFrom: { secretKeyRef: { name: flight-db-app, key: username } } }
- { name: DB_PASSWORD, valueFrom: { secretKeyRef: { name: flight-db-app, key: password } } }
- { name: SIMULATOR_INTERVAL_MS, value: "30000" }
networkPolicy:
allowIngressFrom: # let boarding-api's namespace reach us (section 06)
- { namespace: app-boarding-api-dev, ports: [8080] }
The database is created before the app runs. With rollout: null there is no workload yet, so the first thing this file produces is the PostgreSQL XR and its cluster, within about a minute:
kubectl get postgresql -n app-flight-api-dev # READY True kubectl get cluster.postgresql.cnpg.io -n app-flight-api-dev
apiVersion: platform/v1
kind: PipelineConfig
build:
agent: openjdk-21
script: ./build.sh # compiles natively in the Java agent (section 04)
containerfile: ./Containerfile # thin: just copies the jar in
unitTest:
enabled: true # runs ./test.sh
deploy:
lowerEnvironments: [dev]
pipelines:
ci:
trigger: { source: git, event: push, branch: main }
steps:
- stage: build
- stage: test
env: dev
- stage: deploy
env: dev
As in part 1 the deploy stage commits the image straight into platform/envs/dev.yaml on main, so git pull before your next push. The first start is slower than a NodeJS app: the JVM starts, then Flyway waits for the database if it isn't up yet (it retries for about two and a half minutes) and runs the two migrations.
Once the deploy stage has written the image, add probes to the same file. (Adding them earlier would make rollout non-null with no image.)
rollout:
image: { … } # keep what the deploy stage wrote
resources:
requests: { cpu: 500m, memory: 512Mi } # a JVM starts CPU-bound
limits: { memory: 1Gi } # memory only: no CPU limit
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: 8080 }
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 } # NOT /readiness
initialDelaySeconds: 60
periodSeconds: 20
timeoutSeconds: 5
failureThreshold: 6
Readiness is what makes the pod wait for the database: it isn't sent traffic until Postgres answers.
Two mistakes that make a healthy app restart
Liveness must use /actuator/health/liveness, not /readiness. Readiness includes the database, so a brief database or CPU stall makes a healthy pod look dead and Kubernetes kills it. This was hit for real: both dev pods restarted 5–6 times (exit 137, Liveness probe failed) until the path was corrected, after which the same pods started in 24–33 seconds and stayed up. Give it a CPU request. Startup is CPU-bound, and pods without a request are BestEffort, the lowest CPU priority. While a pipeline build shared the node, the same pods had not finished starting after about 100 seconds. (That link is inferred from the timings, not isolated with a controlled test.) timeoutSeconds: 5 matters too: the 1-second default fails whenever the JVM is briefly busy.
kubectl port-forward -n app-flight-api-dev svc/flight-api 8081:8080 curl -s localhost:8081/api/flights/AC123 curl -s localhost:8081/api/flights/AC123/events
The simulator writes an event about every 30 seconds; watch events grow. And because the component labels everything it creates, one selector finds the app, its database, its Secret and its volume together:
kubectl get all,pvc,secret,networkpolicy -n app-flight-api-dev -l hangar.io/app=flight-api
This was tested on prod, which enforces network policy, with the same database cluster in two namespaces that both carry the app baseline (ingress only from the same namespace).
With the operator policy
flight-db-cnpg-operator, which admits cnpg-system on 8000 and 5432.Ready=True. A pod in another namespace is blocked.Without it (control)
Instance Status Extraction Error: HTTP communication issue: the operator can't reach the instance's manager, so the cluster never reports ready.06 — POINT BOARDING-API AT IT
cd boarding-api
cp /tmp/airframe/examples/skyport/boarding-api/{app.js,index.js,reservations.js} .
cp /tmp/airframe/examples/skyport/boarding-api/public/index.html public/
cp /tmp/airframe/examples/skyport/boarding-api/test/app.test.js test/
npm test # 11 passing
Then add one line to its platform/envs/dev.yaml, commit and push:
env:
- { name: REDIS_URL, value: "redis://cache-master:6379" }
- { name: FLIGHT_API_URL, value: "http://flight-api.app-flight-api-dev.svc.cluster.local:8080" }
With FLIGHT_API_URL unset the app falls back to its built-in stand-in, so it still runs anywhere. Set, it never invents an answer: an unknown flight is a 404, and flight-api being down is a 502 that is not cached.
The source row now says flight-api (Postgres) and there is a status row (ON_TIME, or DELAYED (+15 min) once the simulator has touched it). The first lookup takes tens of milliseconds because it really hit a database; the second is a cache hit.
curl -s -X PUT -H 'Content-Type: application/json' -d '{"gate":"B2"}' localhost:8081/api/flights/AC123/gate
Look up AC123 on the gate board again: it still shows the old gate, from the cache, and is right only after the three-minute TTL. Waiting for a TTL is the wrong way to keep a gate board accurate. The fix is for flight-api to publish flight.gate-changed and for boarding-api to evict the entry, which is Phase 2 of Skyport. The flight_events table is already there for it.
07 — FLIGHT
Works exactly as part 1, section 07. Only what differs is here. prod has the CloudNativePG operator and the PostgreSQL XRD installed. It runs Kubernetes 1.37, which CloudNativePG 1.30 lists as tested but not supported upstream, so the component was verified there rather than assumed.
Add upperEnvironments: [{ name: staging, cluster: prod }], governance.allowedCommitSigners and a release stage to cicd.yaml (as in part 1), merge the .tekton/ PR, then Tower → Create → ApplicationEnvironment named flight-api-prod-staging. The environment starts as rollout: null, so the database is created first, in app-flight-api-staging.
rollout:
replicas: 2
ports: [{ name: http, containerPort: 8080 }]
resources:
requests: { cpu: 500m, memory: 512Mi }
limits: { memory: 1Gi }
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: 8080 }
initialDelaySeconds: 30
timeoutSeconds: 5
failureThreshold: 6
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 } # NOT /readiness
initialDelaySeconds: 60
timeoutSeconds: 5
failureThreshold: 6
components:
- type: postgresql
name: flight-db
spec: { size: small, instances: 2, storageSize: 5Gi } # a primary and a replica
env:
- { name: DB_HOST, valueFrom: { secretKeyRef: { name: flight-db-app, key: host } } }
- { name: DB_PORT, valueFrom: { secretKeyRef: { name: flight-db-app, key: port } } }
- { name: DB_NAME, valueFrom: { secretKeyRef: { name: flight-db-app, key: dbname } } }
- { name: DB_USER, valueFrom: { secretKeyRef: { name: flight-db-app, key: username } } }
- { name: DB_PASSWORD, valueFrom: { secretKeyRef: { name: flight-db-app, key: password } } }
- { name: SIMULATOR_INTERVAL_MS, value: "60000" }
networkPolicy:
allowIngressFrom:
- { namespace: app-boarding-api-staging, ports: [8080] }
instances: 2 gives a real failover pair; on a small single-node cluster 1 is fine. The image is not set here: the release pipeline sets it.
Release as a signed merge, as in part 1. In boarding-api's staging values, set FLIGHT_API_URL to http://flight-api.app-flight-api-staging.svc.cluster.local:8080. Deleting this environment deletes its database and both volumes.
Tower and valueFrom
Tower's Environment variables section is a name/value form. Older builds saved the whole env list back as name/value pairs, which replaced a valueFrom entry with an empty value (the app then started with a blank DB_HOST). Builds from Backstage commit 366ea8c on show valueFrom rows read-only (← Secret flight-db-app / host) and keep them on save. If your Tower predates that, edit this section in the PR directly. Either way, read the PR diff before merging.
CHEAT SHEET
devCluster: dev exactly, and groupId: io.skyport.flight: the code you copy in assumes that package.
A block in the environment's components:, not a Tower Create form. Read its credentials with valueFrom.secretKeyRef to <name>-app.
Not in the Containerfile. The amd64 image leg runs under emulation: a Maven build there took about 11 minutes (17 for the whole image), the packaging-only leg about 30 seconds.
Give the pod a CPU request too. Symptom of getting it wrong: repeated restarts (exit 137, Liveness probe failed) though the database is fine.
rollout must stay null until the deploy stage has written an image.
Namespaces deny ingress from others by default; networkPolicy.allowIngressFrom opens exactly one.
It is dedicated, and its volume goes with it. Database name equals role name; the component enforces it.
-l hangar.io/app=<app> finds everything. Older Tower builds drop valueFrom when you edit env variables; read the PR diff. test.sh uses ./mvnw because the Java agent has no Maven.