Grill verdict: RETHINK (0.45) → revised plan addresses all 12 binding conditions (C-50..C-61): - C-50: install podman if absent (linux/lead) - C-51: DNATTarget validation (nft injection guard) - C-53: apt-get idempotency (command -v podman check) - C-54: offline-first tension documented (podman pull exception) - C-55: native-mode nft single-apply (discover LXC IP first) - C-56: MAC collision check against registry - C-57: v0.13→v0.14 upgrade path (remove legacy systemd+binary) - C-58: mount static config from host (preserve REQ-100 opt-out) - C-59: migration 0009 (not 0007) - C-60: certpaths.CACertPath() (not CAPath()) - C-61: --restart=unless-stopped, omit :Z - C-62/G-003: mTLS deferred to v0.15 (confidence 0.55 < 0.60) ---ci--- project: orca phase: 0 milestone: v0.14 status: grill ---/ci---
22 KiB
PLAN v0.14: Ingress Bootstrap Completeness
Status: active. 9 phases (P0 + P1..P7 + P8 final). Each phase ships a patch tag on the v0.13.x line. This plan references requirement IDs from REQUIREMENTS.md and follows the vertical-slice integrity rule (each phase is independently shippable).
Research-validated decisions (from RESEARCH_v0.14.md + GRILL_v0.14.md):
- nft postrouting:
ip saddr 127.0.0.0/8 oifname != "lo" masquerade - nft first-apply: pre-create table (
nft add table inet orca-ingress 2>/dev/null || true) beforenft -f - pve-firewall: shift orca input/forward chains to
priority -10(before pve-firewall's 0) - LXC features:
nesting=1,keyctl=1,fuse=1(fuse=1 for fuse-overlayfs) - traefik TLS: drop
certResolver: orca— does not exist in v3.3; emittls: {}for v0.14 (real mTLS via dynamictls.certificates+clientAuth.caFilesdeferred to v0.15 — grill G-003 confidence 0.55 < 0.60 threshold, auto-resolved to defer) - podman restart:
--restart=unless-stopped+ enablepodman-restart.service - volumes: omit
:Zflag, use:roon both mounts - traefik image:
FROM traefik:v3.3.0,ENTRYPOINT ["/traefik"]inherited,CMD ["--configFile=/etc/traefik/traefik.yml"] NftClusterConfig.DNATTarget: default127.0.0.1:8443/:8080; proxmox-native =<lxc-ip>:8443/:8080- Migration:
0009_ingress_mode.sql(NOT 0007 — already taken by certs_serial_unique) - CA path:
certpaths.CACertPath()(NOTcertpaths.CAPath()— does not exist) - Upgrade path: P2 must detect+remove legacy
orca-traefik.service+/usr/local/bin/traefikbefore starting podman container (C-57) - Podman install:
BootstrapLocalIngressandBootstrapRemoteIngressmust install podman if absent (C-50) - Offline-first:
podman pullrequires registry reachability — documented exception to R-001 for ingress bootstrap (C-54) - Static config: mount from host (not baked) to preserve
traefik-on-public-ipopt-out (C-58) - DNATTarget validation:
net.ParseIPorip:portparse before render (C-51) - apt-get idempotency:
command -v podmancheck before install (C-53) - MAC collision: check against existing nodes' MACs (C-56)
- Native-mode nft: first apply uses LXC IP (not default 127.0.0.1) — discover LXC IP before first nft apply (C-55)
Phase 0: Pre-execution (this phase)
Status: complete. SPECIFY → CLARIFY → RESEARCH → PLAN → GRILL → SHIP.
Ships as v0.13.0.
Phase 1: orca-traefik container image + release pipeline (REQ-171)
Tag: v0.13.1 | Type: feat | Persona: release-engineer (phase-specific) + backend-engineer
Wave 1 (image)
- T1: Create
docker/orca-traefik/traefik.yml— the default static config baked into the image (used when no host-side override is mounted):NoentryPoints: websecure: address: "127.0.0.1:8443" web: address: "127.0.0.1:8080" traefik: address: "127.0.0.1:8081" providers: file: directory: "/etc/traefik/dynamic" watch: true log: level: INFO format: json accessLog: format: jsoncertificatesResolvers(research finding: does not exist for CA-based; TLS is via dynamic config). C-58: The baked config is a default. The podman run command also mounts a host-side/etc/traefik/traefik.ymlif it exists (overriding the baked one), preserving thetraefik-on-public-ipopt-out (REQ-100). The reconciler renders the static config viaemitter.RenderTraefikStaticConfigto/etc/traefik/traefik.ymlon the host, then mounts it-v /etc/traefik/traefik.yml:/etc/traefik/traefik.yml:ro. This wayPublicBindingopt-out still works. - T2: Create
Dockerfile.traefikat repo root:(ENTRYPOINT inherited asFROM traefik:v3.3.0 LABEL org.opencontainers.image.title="orca-traefik" LABEL org.opencontainers.image.source="https://git.cloudinit.dev/coreci/orca" COPY docker/orca-traefik/traefik.yml /etc/traefik/traefik.yml CMD ["--configFile=/etc/traefik/traefik.yml"]["/traefik"]from base image.) - T3: Create placeholder
docker/orca-traefik/step-ca-root.crt(empty file) — real CA is volume-mounted at runtime. If absent, traefik starts without TLS termination (graceful).
Wave 2 (release pipeline)
- T4:
scripts/release.sh— add a second docker block after the existing one (~line 212):# Build + push orca-traefik image TRAEFIK_IMAGE="${CONTAINER_REGISTRY}/${CONTAINER_OWNER}/orca-traefik" if command -v docker >/dev/null 2>&1; then docker build -f Dockerfile.traefik -t "${TRAEFIK_IMAGE}:${VERSION}" -t "${TRAEFIK_IMAGE}:latest" . docker push "${TRAEFIK_IMAGE}:${VERSION}" docker push "${TRAEFIK_IMAGE}:latest" fi - T5:
.coreci.yml— addcontainer-publish-traefikstep mirroringcontainer-publishwithCONTAINER_IMAGE=orca-traefik+DOCKERFILE=Dockerfile.traefik.
Wave 3 (tests)
- T6: Verify
docker build -f Dockerfile.traefik .succeeds and the resulting image starts traefik with--configFile=/etc/traefik/traefik.yml(can test withdocker run --rm orca-traefik --version).
Must-haves
Dockerfile.traefikbuilds successfully- Image starts traefik with the baked static config
release.shpublishesorca-traefik:<version>+:latest.coreci.ymlhascontainer-publish-traefikstep
Phase 2: Podman traefik reconciler (REQ-172)
Tag: v0.13.2 | Type: feat | Persona: lead-developer
Wave 1 (reconciler)
- T1: Rewrite
internal/traefik/install.go— replace binary+systemd install with podman container reconciler:EnsureTraefikContainer(ctx, execFn, image, tag)— idempotent:podman inspect orca-traefik→ if running, no-op; if stopped,podman start orca-traefik; if absent, go to step 2mkdir -p /etc/traefik/dynamic /etc/orcapodman pull <image>:<tag>podman run -d --name orca-traefik --restart=unless-stopped --network host -v /etc/traefik/dynamic:/etc/traefik/dynamic:ro -v /etc/orca/step-ca-root.crt:/etc/orca/step-ca-root.crt:ro <image>:<tag>
EnsureTraefikContainerLocal(ctx, image, tag)— usesexec.CommandContext("podman", ...)locallyEnsureTraefikContainerRemote(ctx, execFn, image, tag)— uses SSH exec function- Image/tag resolution:
git.cloudinit.dev/coreci/orca-traefik:<version>where version =internal/cli.version(orlatestif dev) - Remove systemd unit generation +
systemctl enable
- T2: Add
podman-restart.serviceenable step:systemctl enable --now podman-restart.service(research finding: needed for reboot persistence) - T2a: C-50:
EnsureTraefikContainerLocal/Remotemust checkcommand -v podmanfirst. If absent: on localhost, attemptapt-get install -y podman(or fail with clear install instructions if no apt). On remote,apt-get install -y podman conmon crun fuse-overlayfsvia SSH. Non-fatal warn if podman unavailable (offline host) — traefik won't start butorca initsucceeds (same tolerance as v0.13).
Wave 2 (callsite updates + v0.13 upgrade path)
- T3:
internal/cli/init.go:254-266— replaceinstallTraefikLocal()withEnsureTraefikContainerLocal - T4:
internal/linux/bootstrap.go:160-172— replacetraefik.InstallRemotewithEnsureTraefikContainerRemote - T5:
internal/proxmox/bootstrap.go:250-255— replacetraefik.InstallRemotewithEnsureTraefikContainerRemote(for native mode; floating-IP calls it inside the LXC in P6) - T6: C-57 (v0.13→v0.14 upgrade path):
internal/cli/upgrade.go— rewrite the Traefik cutover to:- Detect legacy
orca-traefik.service:systemctl is-active orca-traefik.service - If active:
systemctl stop orca-traefik.service && systemctl disable orca-traefik.service - Remove
/etc/systemd/system/orca-traefik.service+/usr/local/bin/traefik(if exists) systemctl daemon-reload- Render static config via
emitter.RenderTraefikStaticConfigto/etc/traefik/traefik.yml EnsureTraefikContainerLocal(pull + run podman container)- Idempotent: if no legacy unit, skip steps 1-4
- Detect legacy
- T7:
internal/cli/traefik_install.go— update CLI wrapper
Wave 3 (TLS model fix — research finding)
- T8:
internal/emitter/traefik.go— dropcertResolver: orcafrom the dynamic config router TLS stanza (line ~185-188). Replace withtls: {}(empty TLS stanza — traefik uses its default cert). Document that real mTLS viatls.certificates+tls.options.default.clientAuth.caFileswill be wired when step-ca mints certs into the dynamic dir (post-v0.14 or v1.x). - T9: Update
internal/emitter/traefik_test.go— remove assertion forcertResolver: orca, add assertion fortls: {}presence.
Wave 4 (tests)
- T10: Create
internal/traefik/install_test.go(new file — F1.3: does not exist today) — assertpodman runis invoked (notcurl|tar),--restart=unless-stopped --network hostpresent, volume mounts present,podman-restart.serviceenabled. - T10a: C-57/F6.2: Add v0.13→v0.14 upgrade test: simulate a host with
orca-traefik.servicepresent (fake), run upgrade, assert unit stopped+disabled+removed, podman container running.
Must-haves
orca init→podman inspect orca-traefikshows runningpodman logs orca-traefikshows traefik started with baked config- No systemd
orca-traefik.servicegenerated --restart=unless-stopped+podman-restart.serviceenabledcertResolver: orcaremoved from dynamic config
Phase 3: nft SNAT+DNAT + orca init ingress bootstrap (REQ-173)
Tag: v0.13.3 | Type: feat | Persona: lead-developer + security-engineer
Wave 1 (nft emitter extension)
- T1:
internal/emitter/nft.go— addDNATTargetfield toNftClusterConfig(default127.0.0.1). Render DNAT rules asdnat to <DNATTarget>:8443/dnat to <DNATTarget>:8080. C-51: ValidateDNATTargetwithnet.ParseIPbefore rendering. Reject invalid values with error (same F9 injection guard pattern aspartitionTrustedProbes). - T2:
internal/emitter/nft.go— addEnableSNAT bool(default true) +postroutingchain:Only whenchain postrouting { type nat hook postrouting priority 100; policy accept; ip saddr 127.0.0.0/8 oifname != "lo" masquerade }EnableSNAT == true. - T3:
internal/emitter/nft.go— shiftinputandforwardchain priorities fromfilter(=0) to-10(research finding: avoids pve-firewall same-priority undefined order). - T4:
internal/emitter/nft.go— fix first-apply flush-table bug: changeflush table inet orca-ingresstodelete table inet orca-ingress(nft ≥1.0 treats delete-of-missing as warning in-fmode). If that's version-unsafe, the apply step (T7) pre-creates the table.
Wave 2 (ingress bootstrap)
- T5: New
internal/ingress/bootstrap.go:BootstrapLocalIngress(ctx):mkdir -p /etc/traefik/dynamic /etc/orca- C-60: Push cluster root CA to
/etc/orca/step-ca-root.crtfromcertpaths.CACertPath()(if exists, else empty placeholder) - Render static config via
emitter.RenderTraefikStaticConfigto/etc/traefik/traefik.yml(preservestraefik-on-public-ipopt-out — C-58) - Render
orca.nftviaNftEmitter.RenderNftConfig+ write to/etc/nftables.d/orca.nft - Pre-create table:
nft add table inet orca-ingress 2>/dev/null || true - Apply:
nft -f /etc/nftables.d/orca.nft - C-50: Ensure podman installed (check
command -v podman, install if absent) EnsureTraefikContainerLocal(from P2) — mounts/etc/traefik/traefik.yml:ro+/etc/traefik/dynamic:ro+/etc/orca/step-ca-root.crt:ro
- Each step non-fatal warn (offline host tolerance)
- T6: Wire into
internal/cli/init.goafterEnsureTraefikContainerLocal(Step 4e, replacing the old traefik install step).
Wave 3 (doctor nft update)
- T7:
internal/cli/doctor_nft.go— extend assertions: postrouting masquerade present, DNAT target matchesNftClusterConfig.DNATTarget.
Wave 4 (tests)
- T8:
internal/emitter/nft_test.go— assert postrouting chain present whenEnableSNAT=true, absent when false. AssertDNATTargetsubstitution. Assert priority-10on input/forward. - T9: Integration test:
orca init→nft list table inet orca-ingressshows DNAT + postrouting;podman inspect orca-traefikrunning.
Must-haves
orca init→ nft table has DNAT + postrouting masquerade- nft input/forward chains at priority -10
- First-apply doesn't error (table pre-created or delete-table idiom)
/etc/orca/step-ca-root.crtexists (real CA or placeholder)podman inspect orca-traefikrunning
Phase 4: orca node join --type linux remote ingress bootstrap (REQ-174)
Tag: v0.13.4 | Type: feat | Persona: lead-developer
Wave 1 (remote ingress)
- T1:
internal/ingress/bootstrap.go— addBootstrapRemoteIngress(ctx, execFn):mkdir -p /etc/traefik/dynamic /etc/orca(remote)- C-60: Push step-ca root CA to remote
/etc/orca/step-ca-root.crtfromcertpaths.CACertPath()viaWriteFile - Render
orca.nft+ write to remote/etc/nftables.d/orca.nftviaWriteFile nft add table inet orca-ingress 2>/dev/null || true(remote)nft -f /etc/nftables.d/orca.nft(remote)systemctl enable --now podman-restart.service(remote)EnsureTraefikContainerRemote(from P2)
- T2: Wire into
internal/linux/bootstrap.goafter the traefik container reconciler step. - T3: Extend
linux.ResultwithIngressOK boolfor reporting.
Wave 2 (tests)
- T4: Fake-SSH test: assert remote
nft -f+podman run+WriteFilefor step-ca CA invoked.
Must-haves
orca node join --type linux --host <ip>→ remote has podman traefik running + nft applied + step-ca CA mounteddoctor ingress --peer <linux-node>passes
Phase 5: Proxmox native ingress mode (REQ-175)
Tag: v0.13.5 | Type: feat | Persona: backend-engineer + data-engineer
Wave 1 (flags + schema)
- T1: Add flags to
node join:--ingress-mode(values:nativedefault,floating-ip),--floating-ip,--gateway,--mac,--net-prefix(default24). - T2: Add
IngressModefield tomodel.Node(string:"","native","floating-ip"). - T3: C-59: Schema migration
0009_ingress_mode.sql(NOT 0007 — already taken):ALTER TABLE nodes ADD COLUMN ingress_mode TEXT DEFAULT '';
Wave 2 (native mode bootstrap)
- T4: In
proxmox.BootstrapProxmox, whenIngressMode == "native":- On the PVE host: render+apply nft with
DNATTarget = <lxc-bridge-ip>(the traefik LXC's IP, discovered afterpct start) - Create unprivileged LXC with
--features nesting=1,keyctl=1,fuse=1(research finding: fuse=1 for fuse-overlayfs).pct create <vmid> local:vztmpl/<template> --hostname orca-traefik --unprivileged 1 --features nesting=1,keyctl=1,fuse=1 --onboot 1 --memory 2048 --swap 0 --rootfs local:8 pct start <vmid>3a. C-55: Discover LXC IP viapct config <vmid>(parsenet0line forip=) orpct exec <vmid> -- hostname -IBEFORE the first nft apply. The nft DNAT target is set to the LXC IP from the start — no two-phase apply, no downtime window.- Inside the LXC: C-53:
command -v podman >/dev/null 2>&1 || (apt-get update && apt-get install -y podman conmon crun fuse-overlayfs nftables)— idempotent, skip if podman already installed - Configure podman storage (
/etc/containers/storage.conf):mount_program = "/usr/bin/fuse-overlayfs"(fallback:driver = "vfs") systemctl enable --now podman-restart.service(inside LXC)- Push step-ca root CA into LXC
EnsureTraefikContainerRemote(podman pull + run inside LXC with--network host)- C-55: Render+apply nft on PVE host with
DNATTarget = <lxc-ip>(discovered in step 3a) — single apply, no downtime window
- On the PVE host: render+apply nft with
Wave 3 (registration)
- T5: Register PVE host as
proxmoxnode withIngressMode: "native".
Wave 4 (tests)
- T6: Fake-SSH test: assert
pct createwith--features nesting=1,keyctl=1,fuse=1,apt-get install podman,podman runinside LXC, nft DNAT target = LXC IP.
Must-haves
orca node join --type proxmox --host <ip>(native) → LXC created with nesting+keyctl+fuse- Podman + orca-traefik running inside LXC
- PVE host nft DNATs to LXC IP
IngressMode: "native"on node record- Schema migration 0007 applied
Phase 6: Proxmox floating-IP LXC "ingress" (REQ-176)
Tag: v0.13.6 | Type: feat | Persona: backend-engineer
Wave 1 (LXC provisioning)
- T1: New
internal/proxmox/ingress_lxc.go—ProvisionIngressLXC(ctx, opts):pveam download local <template>(idempotent, already in bootstrap)- Deterministic VMID for "ingress" (hash of host+"ingress")
pct create <vmid> local:vztmpl/<template> --hostname ingress --unprivileged 1 --features nesting=1,keyctl=1,fuse=1 --net0 name=eth0,bridge=vmbr0,hwaddr=<mac>,ip=<floating-ip>/<prefix>,gw=<gateway> --onboot 1 --memory 2048 --swap 0 --rootfs local:8pct start <vmid>- Wait for LXC network (retry SSH to
<floating-ip>with backoff, 60s timeout) - Inside the LXC: C-53:
command -v podman >/dev/null 2>&1 || (apt-get update && apt-get install -y podman conmon crun fuse-overlayfs nftables)— idempotent - Configure podman storage (fuse-overlayfs / vfs fallback)
systemctl enable --now podman-restart.service- Push step-ca root CA into LXC
- Render+apply nft INSIDE the LXC (DNAT
:443→127.0.0.1:8443,:80→127.0.0.1:8080+ postrouting masquerade) —DNATTarget = 127.0.0.1here because traefik runs with--network hostinside the LXC EnsureTraefikContainerRemote(podman pull + run inside LXC with--network host)- Push orca SSH pubkey into LXC for future
job runtraefik dynamic-config pushes
Wave 2 (registration)
- T2: Register LXC as managed node:
registry.JoinwithKind: "linux",Name: "ingress",Address: "<floating-ip>:8443",OS: "linux",IngressMode: "floating-ip". - T3: Also register PVE host as
proxmoxnode (for workload dispatch).
Wave 3 (interactive prompting)
- T4: Interactive prompting in
joinProxmox(node.go): when--ingress-modeempty and!jsonOutput:- Prompt "Ingress mode [native/floating-ip] (default native): "
- If
floating-ip: prompt for floating IP (validatenet.ParseIP), gateway (validatenet.ParseIP), MAC (validatenet.ParseMAC; generate02:XX:XX:XX:XX:XXrandom if empty + confirm; C-56: check MAC against existing nodes' MACs in cluster registry, regenerate on collision), net-prefix (default 24, validate 8-32) - In
--jsonmode: require--macexplicitly if--ingress-mode floating-ip(no silent generation)
Wave 4 (routing)
- T5:
joinProxmoxinnode.goroutes: native → P5 path; floating-ip →ProvisionIngressLXC+ register LXC + register PVE host.
Wave 5 (tests)
- T6: Fake-SSH test: assert
pct createwithnet0 name=eth0,bridge=vmbr0,hwaddr=<mac>,ip=<floating-ip>/<prefix>,gw=<gateway>,--features nesting=1,keyctl=1,fuse=1; LXC registered aslinuxnode namedingressat<floating-ip>:8443; PVE host registered asproxmox. - T7: Interactive prompt test: stdin simulation → mode selection + param entry + validation.
Must-haves
orca node join --type proxmox --host <ip> --ingress-mode floating-ip --floating-ip 203.0.113.10 --gateway 203.0.113.1 --mac 02:01:02:03:04:05→ LXCingresscreated- LXC has podman traefik running + nft applied inside LXC
- Node
ingressregistered aslinuxat203.0.113.10:8443 - PVE host registered as
proxmox - Interactive prompt works when flags absent + not
--json - IP/MAC/gateway validation rejects invalid input
Phase 7: doctor ingress + docs + integration tests (REQ-177, REQ-178, REQ-179)
Tag: v0.13.7 | Type: feat+docs+test | Persona: backend-engineer + lead-developer
Wave 1 (doctor ingress)
- T1:
internal/cli/doctor_ingress.go—orca doctor ingress [--peer <name>]:podman inspect orca-traefik→ running?- nft DNAT+SNAT applied (reuse
doctor_nftlogic) /etc/traefik/dynamicexists- step-ca root CA mounted (
podman inspectvolume check or file-exists check) - For proxmox-native: checks the LXC (via
pct exec) - For floating-ip: checks the ingress LXC over SSH
- Uses SSH-push for remote peers
Wave 2 (UAT assertions)
- T2:
scripts/uat-signoff.sh— add assertions:40 ingress_podman_traefik,41 ingress_nft_dnat_snat,42 ingress_linux_worker,43 ingress_proxmox_native_lxcor43 ingress_floating_ip_lxc(depending on topology).
Wave 3 (docs)
- T3:
docs/cli.md— document--ingress-mode,--floating-ip,--gateway,--mac,--net-prefixflags +orca doctor ingress. - T4:
docs/uat.md— add floating-IP topology variant; update native topology to assert ingress bootstrap. - T5:
docs/ingress.md— podman-traefik image section:Dockerfile.traefik, volume mounts, TLS model (dynamictls.certificates, not certResolver),--network hostrationale. - T6:
docs/docker.md—orca-traefikimage: build, publish, pull. - T7:
.ciagent/ARCHITECTURE.md— R-024 + ingress bootstrap section (3 topologies, nft+podman stack on each).
Wave 4 (integration tests)
- T8:
tests/ingress_bootstrap_test.go— hermetic fake-SSH harness:- init → podman traefik running + nft applied
- linux join → remote podman + nft + step-ca CA push
- proxmox native → LXC created with
nesting=1,keyctl=1,fuse=1+ podman traefik + nft DNAT to LXC IP - floating-ip →
pct createwith correctnet0args + LXC registered aslinuxnode - release.sh builds orca-traefik image (Dockerfile.traefik parses)
Wave 5 (verify)
- T9:
make build && make test && make lint && make verify-docsall pass.
Must-haves
orca doctor ingressexits 0 on a properly bootstrapped node- UAT signoff script includes new assertions
make verify-docspasses- Integration tests pass in CI
validate - ARCHITECTURE.md ingress section matches shipped code
Phase 8: Final review + ship + audit (milestone release)
Tag: v0.13.8 = v0.14 milestone release | Type: docs+review
- Multi-persona code review across all phases
- Audit:
verify-reqs, git-log ↔.ciagent/reconstruction - Merge
phase/08→milestone/v0.14→main - Tag
v0.13.8+ release with milestone summary - Build + publish both container images (
orca+orca-traefik) - Delete all milestone branches (tags preserve history)
- Mark all REQ-171..179 as complete in REQUIREMENTS.md + ROADMAP.md