Compare commits

..

27 Commits

Author SHA1 Message Date
Jon Chery 61c97c847c fix(P1): recompute TARBALL after fallback version walk (REQ-132)
The fallback walk (REQ-098) reassigns VERSION from the requested
release to the nearest older release carrying a binary asset, but
never recomputed TARBALL (set once at line 106 from the requested
version). The stale tarball name then flowed into:

  - grep -F "$TARBALL" SHA256SUMS  -> matched nothing (the fallback
    release's SHA256SUMS only lists the fallback tarball)
  - sha256sum -c -                 -> empty stdin -> "no properly
    formatted checksum lines found" -> REQ-132 refusal
  - tar -xzf "${TMPDIR}/${TARBALL}" -> would look for the wrong
    filename (download saved under the stale name too)

User-visible symptom (v0.14.2 latest had no asset, fell back to
v0.12.18):

  install: verifying checksum...
  sha256sum: 'standard input': no properly formatted checksum lines found
  install: error: checksum verification failed (REQ-132); refusing to install

Fix: recompute TARBALL immediately after VERSION is reassigned in the
fallback branch, so download/grep/sha256sum/tar all reference the
fallback version's tarball. ASSET_URL and SHA256SUMS_URL were already
correct (derived from the API/ASSET_URL); TARBALL was the only stale
variable.

Reproduced the exact error before the fix; confirmed end-to-end
install succeeds after (orca-v0.12.18-linux-amd64.tar.gz: OK ->
extracting -> installed). Added a bats regression test pinning
--version v0.14.2 and asserting the dry-run "would install" line
references the fallback version (not the stale pinned one).

---ci---
project: orca
phase: 1
milestone: v0.15
status: execute
decisions:
  - id: D-001
    decision: Recompute TARBALL in the fallback branch immediately
      after VERSION is reassigned, so grep/sha256sum/tar use the
      fallback version's filename instead of the stale requested
      version's.
    rationale: Reproduced the exact user error ("no properly formatted
      checksum lines found") by running grep -F "$TARBALL" SHA256SUMS
      | sha256sum -c with a stale v0.14.2 tarball name against v0.12.18
      SHA256SUMS. TARBALL is the only stale variable: ASSET_URL and
      VERSION are correctly updated from API output, and SHA256SUMS_URL
      derives from ASSET_URL. Single-line fix, minimal blast radius,
      preserves the working non-fallback path.
    confidence: 0.96
    alternatives:
      - lazy TARBALL via a function (over-engineering for one stale
        assignment)
      - move TARBALL= assignment past the fallback block (breaks
        find_asset_url which needs the requested version's name
        pre-walk)
lessons:
  - When a fallback/walk mutates one variable (VERSION), audit every
    variable derived from it (TARBALL) for the same mutation. The
    user-facing info line at 167 constructed the name inline and
    looked correct, masking that the variable itself was stale.
---/ci---
2026-08-10 21:18:24 +00:00
Jon Chery 6f04b22df0 docs(milestone): complete v0.15 CI release pipeline fix
Release / ci (push) Failing after 4m57s
Release / container-orca (push) Has been skipped
Release / container-traefik (push) Has been skipped
All 3 requirements (REQ-180..182) complete. Gitea Actions workflow +
kaniko container publishing (no DinD). PAT_TOKEN secret configured.

---ci---
project: orca
phase: 2
milestone: v0.15
status: complete
requirements:
  covered: [180,181,182]
  partial: []
---/ci---
2026-08-10 21:01:56 +00:00
Jon Chery eadf2cc2c5 fix(P1): Gitea Actions workflow + kaniko container publishing (REQ-180,181)
Release / ci (push) Failing after 4m55s
Release / container-orca (push) Has been skipped
Release / container-traefik (push) Has been skipped
New .gitea/workflows/release.yml:
  - Triggers on push: tags: ['v*'] (deterministic)
  - Job 'ci': checkout + install Go + install coreci binary +
    coreci run (executes .coreci.yml: validate, build, test, release)
  - Job 'container-orca': kaniko executor:debug with --entrypoint
    /bin/sh, builds+pushes orca image (no DinD)
  - Job 'container-traefik': same, builds+pushes orca-traefik image
    (skips if Dockerfile.traefik absent at that tag)
  - Uses PAT_TOKEN secret (Gitea reserves GITEA_ prefix)

.coreci.yml:
  - Removed container-publish + container-publish-traefik steps
    (moved to Gitea Actions — CoreCI's podman executor appends sh -c
    which conflicts with kaniko's /kaniko/executor entrypoint)
  - Keeps validate/build/test/release (tarball + Gitea release)

scripts/trigger_coreci.sh:
  - Added tag ref handling (refs/tags/*) so pre-push hook triggers
    CoreCI for tag pushes too (Gitea Actions webhook is secondary)

---ci---
project: orca
phase: 1
milestone: v0.15
status: execute
---/ci---
2026-08-10 20:59:10 +00:00
Jon Chery 93ac4bda66 docs(P00): clarify+research+plan — v0.15 CI release pipeline
Key finding: CoreCI podman executor appends sh -c to step image,
which conflicts with kaniko's /kaniko/executor entrypoint. Container
publishing moves to Gitea Actions workflow (supports entrypoint
override). CoreCI keeps validate/build/test/release (tarball).

---ci---
project: orca
phase: 0
milestone: v0.15
status: plan
---/ci---
2026-08-10 20:57:55 +00:00
Jon Chery 454040fdd1 docs(init): validate specification — v0.15 CI release pipeline fix
3 requirements (REQ-180..182). REQ-182 complete (PAT_TOKEN secret
created via tea). Fix milestone — CI infrastructure, no DinD, kaniko.

---ci---
project: orca
phase: 0
milestone: v0.15
status: specify
---/ci---
2026-08-10 20:55:03 +00:00
Jon Chery c95bd73e42 docs(milestone): complete v0.14 ingress bootstrap
All 9 requirements (REQ-171..179) marked complete. 9 phases shipped
(v0.13.0..v0.13.8). R-024 adopted: traefik as podman container, three
ingress topologies (linux, proxmox native, proxmox floating-IP).

---ci---
project: orca
phase: 8
milestone: v0.14
status: complete
requirements:
  covered: [171,172,173,174,175,176,177,178,179]
  partial: []
---/ci---
2026-08-10 20:26:26 +00:00
Jon Chery ecdba833d9 feat(P7): doctor ingress + docs + integration tests (REQ-177,178,179)
New 'orca doctor ingress' command: verifies podman orca-traefik
container running, nft DNAT+SNAT, /etc/traefik/dynamic exists,
step-ca root CA present.

UAT signoff script: replaced assertion 36 (systemd → podman
container), added assertions 40-46 (nft table, DNAT, SNAT, dynamic
dir, step-ca CA, traefik.yml, doctor ingress pass).

docs/ingress.md: R-024 podman traefik section — three topologies,
container config, nft ruleset, doctor ingress, Dockerfile.traefik.
TLS model updated (drop certResolver, tls:{} for v0.14, mTLS v0.15).

ARCHITECTURE.md: v0.14 deltas section — R-024, three topologies,
nft emitter changes, TLS model, migration 0009, new CLI.

Integration tests (tests/ingress_bootstrap_test.go): nft postrouting
+ DNATTarget, priority -10, traefik TLS model (tls:{} no
certResolver), image ref resolution, floating-IP LXC provisioning
commands (pct create with hwaddr/ip/gw/features), MAC generation.

---ci---
project: orca
phase: 7
milestone: v0.14
status: execute
---/ci---
2026-08-10 20:24:13 +00:00
Jon Chery 6e65eadaa5 feat(P6): proxmox floating-IP LXC ingress + interactive prompt (REQ-176)
New internal/proxmox/ingress_lxc.go: ProvisionIngressLXC creates an
Ubuntu LXC named 'ingress' that owns the floating IP (net0
bridge=vmbr0,hwaddr=<mac>,ip=<floating-ip>/<prefix>,gw=<gateway>).
Unprivileged with --features nesting=1,keyctl=1,fuse=1 (research
Topic 3). Installs podman inside, runs orca-traefik container,
applies nft DNAT+SNAT INSIDE the LXC, pushes step-ca root CA.

GenerateRandomMAC: 02:XX:XX:XX:XX:XX for interactive mode (D-261).

Interactive prompting in joinProxmox: when --ingress-mode empty +
!--json, prompt for mode + floating IP + gateway + MAC (auto-
generate + confirm). Validate IP/MAC/gateway/prefix.

Floating-IP routing: calls ProvisionIngressLXC + registers:
  1. PVE host as 'proxmox' node (IngressMode=floating-ip)
  2. Ingress LXC as 'linux' node (name=ingress, addr=<floating-ip>:8443)
     so orca job run pushes traefik dynamic config to it.

---ci---
project: orca
phase: 6
milestone: v0.14
status: execute
---/ci---
2026-08-10 20:19:47 +00:00
Jon Chery 1b7aac71f6 feat(P5): proxmox native ingress mode — LXC + podman traefik (REQ-175)
Add --ingress-mode flag (native default, floating-ip) + --floating-ip,
--gateway, --mac, --net-prefix flags to 'orca node join'.

Native mode (default): provision an unprivileged LXC with
--features nesting=1,keyctl=1,fuse=1 (research Topic 3), install
podman inside it, run orca-traefik container. nft on PVE host DNATs
to the LXC bridge IP (DNATTarget parameterization, C-55: discover
LXC IP before first nft apply, no downtime window).

LXC provisioning: deterministic VMID 200, hostname orca-traefik,
--onboot 1, 2GB RAM. Idempotent (C-53: command -v podman check).
podman-restart.service enabled inside LXC (research Topic 6).

step-ca root CA pushed into LXC via pct exec heredoc.
traefik static config rendered + written into LXC.
nft ruleset rendered with DNATTarget=LXC-IP + applied on PVE host.

Migration 0009_ingress_mode.sql (C-59: NOT 0007 — already taken by
certs_serial_unique). ALTER TABLE nodes ADD COLUMN ingress_mode.
IngressMode field added to model.Node + set on proxmox node record.

---ci---
project: orca
phase: 5
milestone: v0.14
status: execute
---/ci---
2026-08-10 20:16:40 +00:00
Jon Chery ea42a17474 feat(P4): linux node join remote ingress bootstrap (REQ-174)
Add ingress.BootstrapRemoteIngress: renders+writes traefik static
config, renders+writes+applies nft DNAT/SNAT, pushes step-ca root CA,
ensures podman traefik container — all over SSH exec. Uses a heredoc-
based remoteWriteFile with a random delimiter (F9 injection guard).

Wired into linux/bootstrap.go Step 4d, replacing the standalone
EnsureTraefikContainerRemote call with the full ingress stack.

C-60: uses certpaths.CACertPath() (not CAPath).
C-58: mounts host-side traefik.yml (preserves REQ-100 opt-out).
C-55: pre-creates nft table before nft -f.

---ci---
project: orca
phase: 4
milestone: v0.14
status: execute
---/ci---
2026-08-10 20:11:32 +00:00
Jon Chery 5013209e31 feat(P3): nft SNAT+DNAT + orca init ingress bootstrap (REQ-173)
nft emitter (internal/emitter/nft.go):
- Add DNATTarget field (C-51: validated via net.ParseIP; injection
  guard). Default 127.0.0.1; proxmox native uses LXC bridge IP.
- Add EnableSNAT field (default true for zero-value config).
- Add postrouting masquerade chain (research Topic 1):
  ip saddr 127.0.0.0/8 oifname != lo masquerade
- Shift input/forward priority from filter (=0) to -10 (research
  Topic 2: pve-firewall coexistence — avoids same-priority undefined
  evaluation order).

internal/ingress/bootstrap.go (new):
- BootstrapLocalIngress: mkdir dirs, push step-ca root CA (C-60:
  certpaths.CACertPath not CAPath), render+write traefik static
  config (C-58: preserves traefik-on-public-ip opt-out), render+
  write+apply nft ruleset, pre-create table (C-55: avoids first-
  apply flush-table error), ensure podman container. All non-fatal.

init.go: Step 4d now calls ingress.BootstrapLocalIngress (R-024).
doctor_nft.go: assert postrouting masquerade + priority -10.

Tests: nft_test.go — DNATTarget substitution, invalid DNATTarget
rejection (C-51), EnableSNAT=false omits postrouting, priority -10.

---ci---
project: orca
phase: 3
milestone: v0.14
status: execute
---/ci---
2026-08-10 20:09:26 +00:00
Jon Chery dea472f443 feat(P2): podman traefik reconciler + TLS model fix (REQ-172)
Replace internal/traefik/install.go binary+systemd installer with a
podman-container reconciler (R-024). The reconciler is idempotent:
inspect → start-if-stopped → pull+run-if-absent.

Container run flags (research-validated):
  --restart=unless-stopped (not always; research Topic 6)
  --network host (binds 127.0.0.1:8080/8443 on host/LXC loopback)
  -v /etc/traefik/traefik.yml:ro (overrides baked default; C-58)
  -v /etc/traefik/dynamic:ro (orca writes atomically via SSH-push)
  -v /etc/orca/step-ca-root.crt:ro (future mTLS; v0.14 uses tls:{})
  No :Z SELinux flag (research Topic 7)

C-50: ensurePodmanLocal/Remote installs podman if absent.
C-57: removeLegacySystemdUnitLocal/Remote stops+disables+removes
  the v0.13 orca-traefik.service + /usr/local/bin/traefik before
  starting the podman container (upgrade path).
  upgrade.go cutover rewritten to use the reconciler.

TLS model fix (research Topic 4): drop certResolver: orca from
dynamic config (traefik v3.3 only supports acme/tailscale resolvers,
not CA-file-based). Emit tls: {} instead. Real mTLS via dynamic
tls.certificates + clientAuth.caFiles deferred to v0.15 (grill
G-003, confidence 0.55 < 0.60).

Callsites updated:
  init.go: installTraefikLocal → ensureTraefikContainerLocal
  linux/bootstrap.go: traefik.InstallRemote → EnsureTraefikContainerRemote
  proxmox/bootstrap.go: same
  traefik_install.go: wrapper updated

Tests: internal/traefik/install_test.go (new) — ImageRef, podmanRunArgs,
  container-running/stopped/absent paths, legacy systemd removal (C-57).

---ci---
project: orca
phase: 2
milestone: v0.14
status: execute
---/ci---
2026-08-10 20:04:20 +00:00
Jon Chery dccdb746ea feat(P1): orca-traefik container image + release pipeline (REQ-171)
Dockerfile.traefik: extends traefik:v3.3.0 with baked default
static config (entrypoints 127.0.0.1:8080/8443/8081, file provider
watching /etc/traefik/dynamic, json log). Host-side traefik.yml
mounted :ro at runtime to override baked default (preserves
traefik-on-public-ip opt-out, REQ-100, C-58).

No certificatesResolvers — traefik v3.3 only supports acme/tailscale
(research finding). tls: {} in dynamic config for v0.14; real mTLS
deferred to v0.15 (grill G-003, confidence 0.55 < 0.60).

release.sh: second docker block builds+pushes orca-traefik image.
.coreci.yml: container-publish-traefik step mirrors container-publish.

Verified: docker build -f Dockerfile.traefik . succeeds; image starts
traefik v3.3.0 with --configFile=/etc/traefik/traefik.yml.

---ci---
project: orca
phase: 1
milestone: v0.14
status: execute
---/ci---
2026-08-10 18:30:37 +00:00
Jon Chery 080919fde6 chore(P00): phase 0 complete — checkpoint update
---ci---
project: orca
phase: 0
milestone: v0.14
status: complete
---/ci---
2026-08-10 18:27:10 +00:00
Jon Chery 3551b37ac0 docs(P00): grill + plan revision — v0.14 binding conditions
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---
2026-08-10 18:26:33 +00:00
Jon Chery 65e50e465b docs(P00): grill v0.14 — red-team review, RETHINK verdict
Adversarial review of PLAN_v0.14.md across 9 axes. Verdict: RETHINK
(confidence 0.45). The research foundation is strong but the plan
diverges from it and from the codebase in load-bearing ways.

4 binding decisions (G-001..G-004):
- G-001: migration number 0009 (not 0007 — already taken by certs)
- G-002: omit :Z flag (contradicts CLARIFY D-258 + REQ-172)
- G-003: wire real mTLS now (scope expansion — plan has no such phase)
- G-004: P2 T6 must remove legacy systemd unit + binary on upgrade

12 binding conditions (C-50..C-61) + 14 phase challenges (PC-01..14).

1 escalation (E-001): G-003 mTLS direction undetermined in plan.

Key findings:
- F1.1: migration 0007 collision (BLOCKER)
- F1.2: certpaths.CAPath() does not exist (compile BLOCKER)
- F2.2: REQ-100 traefik-on-public-ip opt-out regressed by baked image
- F5.1: no v0.13 -> v0.14 upgrade path (BLOCKER)
- F7.2: podman pull violates R-001 offline-first

---ci---
status: grill
milestone: v0.14
binding-decisions:
  - G-001: migration 0009_ingress_mode.sql (not 0007)
  - G-002: omit :Z, use :ro on both mounts
  - G-003: wire real mTLS now (scope expansion, plan must add phase)
  - G-004: P2 T6 must remove legacy systemd unit + binary on upgrade
escalations:
  - E-001: G-003 mTLS direction chosen but plan has no phase for it (conf 0.55)
verdict: rethink
confidence: 0.45
2026-08-10 18:24:32 +00:00
Jon Chery 0e7ee4f324 docs(P00): create phase plans — v0.14 ingress bootstrap
9 phases (P0+P1..P7+P8 final). Plan incorporates research findings:
nft postrouting masquerade scoped to 127.0.0.0/8, pve-firewall
priority shift to -10, LXC fuse=1 feature, traefik TLS model
change (drop certResolver, use dynamic tls.certificates), podman
--restart=unless-stopped + podman-restart.service, omit SELinux :Z.

---ci---
project: orca
phase: 0
milestone: v0.14
status: plan
---/ci---
2026-08-10 18:13:59 +00:00
Jon Chery b925fda3aa docs(P00): research findings — v0.14 ingress bootstrap
7 research topics: nft SNAT masquerade syntax, pve-firewall
coexistence (priority collision fix), podman-in-LXC (fuse=1
requirement), traefik v3.3 TLS model (certResolver does not exist —
use dynamic tls.certificates), pct create floating-IP syntax, podman
restart persistence (podman-restart.service), SELinux :Z omission.

Key findings that change the plan:
- nft postrouting: ip saddr 127.0.0.0/8 oifname != lo masquerade
- nft first-apply: pre-create table before nft -f
- pve-firewall: shift orca input/forward to priority -10
- LXC features: nesting=1,keyctl=1,fuse=1 (fuse=1 for fuse-overlayfs)
- traefik TLS: drop certResolver: orca, use dynamic tls.certificates
- podman: --restart=unless-stopped + enable podman-restart.service
- volumes: omit :Z flag, use :ro on both mounts

---ci---
project: orca
phase: 0
milestone: v0.14
status: research
---/ci---
2026-08-10 18:12:59 +00:00
Jon Chery 9853aee589 docs(P00): clarify — v0.14 ingress bootstrap decisions
9 decisions (D-255..D-263) resolved: podman container model,
--network host, mounted step-ca CA, dynamic config volume mount,
floating-IP LXC registered as linux node, IngressMode on model.Node,
MAC generation rules, native-mode DNAT target = LXC IP, LXC nesting.

---ci---
project: orca
phase: 0
milestone: v0.14
status: clarify
---/ci---
2026-08-10 18:08:26 +00:00
Jon Chery 5e0b899f1a docs(init): validate specification — v0.14 ingress bootstrap
---ci---
project: orca
phase: 0
milestone: v0.14
status: specify
---/ci---
2026-08-10 18:07:55 +00:00
Jon Chery 0424f8ce02 feat(init): interactive remote pre-staging via ssh-copy-id
orca init now interactively prompts for remote host addresses and runs
ssh-copy-id automatically (password prompt passes through to the
operator). This makes orca init the single entry point — no manual
pre-staging of SSH keys required.

- Interactive: enter host addresses (one per line, empty line to finish)
- ssh-copy-id deploys the orca public key to each host
- Skipped in --json mode (non-interactive)
- Idempotent: re-running init can stage additional hosts

Also fixed: install.sh defaults to /usr/local/bin (on PATH for all users).
Non-root without sudo falls back to ~/.local/bin + auto-adds to .bashrc.
2026-08-10 17:37:42 +00:00
Jon Chery 5600531bd7 fix(install): default to /usr/local/bin (on PATH for all users)
Root or writable /usr/local/bin: install there (no PATH edits needed).
Non-root without sudo: fall back to ~/.local/bin + auto-add to .bashrc.
This eliminates the 'NOTE: not on your PATH' message for the common case.
2026-08-10 17:22:42 +00:00
Jon Chery d324939699 fix: PVE role/user idempotency + init pre-staging instructions
- createPVERole: use grep -qF + fallback to pveum role mod (was broken
  by single-quote-in-grep pattern: grep -q '^'OrcaOperator'')
- createPVEUser: same idempotency fix (grep -qF + fallback to mod)
- orca init: prints ssh-copy-id instructions with the orca public key
  path after generating the SSH keypair
- docs/uat.md: removed manual pre-staging (ssh-keygen, ssh-copy-id
  with operator key, host-key fingerprint pinning). orca init handles
  key generation; node join uses the orca key by default; TOFU is
  automatic. Updated node join examples to not pass --ssh-key or
  --host-key-fingerprint.

---ci---
project: orca
status: fix
---/ci---
2026-08-10 17:07:30 +00:00
Jon Chery 00efe25ce4 fix(release): clean release assets + SHA256SUMS URL lookup fix 2026-08-10 16:56:17 +00:00
Jon Chery 1ad6780df1 fix(release): install.sh asset matching + SHA256SUMS + Dockerfile 1.25.12
install.sh:
- find_asset_url now matches by asset NAME (python3 JSON parse), not
  URL path — Gitea attachment URLs are opaque UUIDs that don't contain
  the tarball name. This was the root cause of the v0.12.18 install
  failure (asset existed but install.sh couldn't find it).
- find_asset_in_releases walks recent releases by asset name and
  returns both URL + version for the fallback walk.
- Handles 404 (tag without release) gracefully via fallback walk.

Dockerfile:
- golang:1.25 -> golang:1.25.12 (go.mod requires 1.25.12; the Docker
  image was using patch 0, causing `go mod download` to fail with
  "go.mod requires go >= 1.25.12 (running go 1.25.10)")

coreci.yml:
- All golang:1.25 images -> golang:1.25.12
- Release pipeline: add SHA256SUMS generation (sha256sum tarball)
- Release pipeline: attach SHA256SUMS alongside tarball
- Release pipeline: verify assets are actually attached after
  tea releases create (REQ-097 gate C-21); auto-attach via API if
  tea failed silently

release.sh:
- Add SHA256SUMS generation (sha256sum tarball > SHA256SUMS)

---ci---
project: orca
milestone: v0.12.18
phase: release-fix
status: complete
---/ci---
2026-08-10 16:54:11 +00:00
Jon Chery 7dc7980d74 docs(E): UAT docs + signoff script fixes + pve-ct example (REQ-170)
- docs/uat.md: remove --rp-id from cluster seal (belongs to auth init-idp);
  fix secrets set syntax (positional KEY=value, not --value flag); add
  auth init-idp step; add troubleshooting section (ORCA_HOME, known_hosts,
  Traefik, SSH, job list, Proxmox runtime)
- scripts/uat-signoff.sh: fix 6 assertions (#04 SKIP if no linux, #08
  check node field in JSON, #14 verify file exists first, #27 fix pprof
  grep, #34/35 already passing); add 3 new assertions (#36 traefik
  installed, #37 known_hosts exists, #38 master_key exists); total 38
- examples/full-stack/web-app-lxc.md: pve-ct jobspec variant for Proxmox
  LXC container deployment

---ci---
project: orca
milestone: v0.12.18
phase: E
status: complete
requirements:
  covered: [170]
---/ci---
2026-08-10 16:37:57 +00:00
Jon Chery 790109ea24 feat(D): capacity auto-discovery + partial updates + ACL debug + UX (REQ-168,169)
- node capacity set: partial updates (only set dimensions passed;
  read-modify-write on existing row)
- node capacity auto [percentage]: SSH to node, discover CPU (nproc),
  memory (/proc/meminfo), disk (df), multiply by percentage (default 75)
- ACL check --verbose: prints resolved ACLPath + all entries + identity
- job list UX: short 8-char IDs, NODE column, conditional EXIT (- for
  non-terminal statuses)

---ci---
project: orca
milestone: v0.12.18
phase: D
status: complete
requirements:
  covered: [168, 169]
---/ci---
2026-08-10 16:33:27 +00:00
49 changed files with 4443 additions and 322 deletions
+67
View File
@@ -872,3 +872,70 @@ scheduler.Schedule(spec, nodes) → emitter.Render(unit) → sshpush.Deploy(targ
- IPv6 `net.JoinHostPort` in all SSH dial paths.
- Explicit timeouts on all SSH commands.
- Root SIGINT/SIGTERM handler for clean exit on non-watch commands.
## v0.14 Deltas — Ingress Bootstrap Completeness (R-024)
### R-024: Traefik as Podman Container
Traefik runs exclusively as a podman container, deployed from the
custom `orca-traefik` image (published per release via `Dockerfile.traefik`
+ `scripts/release.sh` + `.coreci.yml container-publish-traefik`).
The v0.13 binary+systemd install (`internal/traefik/install.go`) is
replaced by an idempotent podman container reconciler
(`EnsureTraefikContainerLocal`/`Remote`). The container runs with
`--network host`, `--restart=unless-stopped`, and volume mounts for
`traefik.yml` (static config), `dynamic` (dynamic config), and
`step-ca-root.crt` (future mTLS). No SELinux `:Z` flag.
### Three Ingress Topologies
1. **Linux** (`orca init` / `orca node join --type linux`):
host → nft DNAT → podman traefik (host network).
`internal/ingress/bootstrap.go` → `BootstrapLocalIngress` /
`BootstrapRemoteIngress`.
2. **Proxmox Native** (`--ingress-mode native`, default):
PVE host → nft DNAT (target = LXC bridge IP) → LXC
(`--features nesting=1,keyctl=1,fuse=1`) → podman traefik.
`internal/proxmox/bootstrap.go` → `provisionNativeIngressLXC`.
3. **Proxmox Floating-IP** (`--ingress-mode floating-ip`):
LXC owns the floating IP (`net0 bridge=vmbr0,hwaddr=<mac>,
ip=<floating-ip>/<prefix>,gw=<gateway>`) → nft inside LXC →
podman traefik. The ingress LXC is registered as a `linux` node
(name=`ingress`) so `orca job run` pushes traefik dynamic config.
`internal/proxmox/ingress_lxc.go` → `ProvisionIngressLXC`.
### nft Emitter Changes
`internal/emitter/nft.go`:
- `DNATTarget` field (C-51: validated via `net.ParseIP`). Default
`127.0.0.1`; proxmox native uses LXC bridge IP.
- `EnableSNAT` field + postrouting masquerade chain: `ip saddr
127.0.0.0/8 oifname != "lo" masquerade` (research Topic 1).
- Input/forward chain priority shifted from `filter` (=0) to `-10`
(research Topic 2: pve-firewall coexistence — avoids same-priority
undefined evaluation order).
### TLS Model
v0.14 drops `certResolver: orca` from the dynamic config (traefik v3.3
only supports `acme`/`tailscale` resolvers, not CA-file-based). The
dynamic config emits `tls: {}` (traefik default cert). Real mTLS via
`tls.certificates` + `tls.options.default.clientAuth.caFiles` is
deferred to v0.15 (grill G-003, confidence 0.55 < 0.60).
### Migration 0009
`ALTER TABLE nodes ADD COLUMN ingress_mode TEXT NOT NULL DEFAULT '';`
Values: `""` (legacy), `"native"`, `"floating-ip"`. `IngressMode` field
on `model.Node`.
### New CLI
- `orca doctor ingress` — verifies podman container running, nft
DNAT+SNAT, dynamic dir, step-ca root CA.
- `--ingress-mode` flag on `orca node join --type proxmox`.
- `--floating-ip`, `--gateway`, `--mac`, `--net-prefix` flags for
floating-IP mode.
+9 -18
View File
@@ -1,26 +1,17 @@
{
"phase": 13,
"phase": 2,
"stage": "complete",
"milestone": "v0.13",
"milestone_slug": "production-hardening-2",
"milestone": "v0.15",
"milestone_slug": "ci-release-pipeline",
"phase_role": "final",
"attempts": 0,
"updated_at": "2026-08-10T14:30:00Z",
"updated_at": "2026-08-10T21:05:00Z",
"milestone_complete": true,
"previous_milestone": "v0.12",
"phase_count": 14,
"phases_shipped": ["P0","P1","P2","P3","P4","P5","P6","P7","P8","P9","P10","P11","P12","P13"],
"tags_shipped": ["v0.12.0","v0.12.1","v0.12.2","v0.12.3","v0.12.4","v0.12.5","v0.12.6","v0.12.7","v0.12.8","v0.12.9","v0.12.10","v0.12.11","v0.12.12"],
"previous_milestone": "v0.14",
"phases_shipped": ["P0","P1","P2"],
"tags_shipped": ["v0.14.0","v0.14.1"],
"requirements": {
"covered": [149,150,151,152,153,154,155,156,157,158,159,160,161,162,163],
"covered": [180,181,182],
"partial": []
},
"binding_conditions": ["C-39","C-40","C-41","C-42","C-43","C-44","C-45","C-46","C-47","C-48","C-49"],
"load_bearing_rule": "R-022",
"next_milestone": "v1.0",
"ship": {
"tag": "v0.12.13",
"merged_to_milestone": true,
"milestone_release": "v0.13"
}
}
}
+79
View File
@@ -0,0 +1,79 @@
# CLARIFY v0.14: Ingress Bootstrap Completeness
**Autonomy**: full (config `autonomy.level = "full"`)
**Budget**: 10 questions (used: 5 interactive, 5 auto-resolved)
**Result**: all ambiguities resolved; 9 decisions (D-255..D-263) recorded in PROJECT.md
## Interactive Questions (resolved with operator)
### Q1: iptables vs nft emitter
**Ambiguity**: Codebase is nft-only (D-218). Operator said "iptables used for SNAT/DNAT".
**Resolution**: D-255 (partial) — Extend the existing nft emitter (`internal/emitter/nft.go`) with SNAT/MASQUERADE postrouting rules. "iptables" in the operator's description is read as "kernel NAT" — nft is the modern backend. No literal iptables emitter.
**Confidence**: 0.95
### Q2: ingress LXC lifecycle
**Ambiguity**: Should the floating-IP LXC be registered as an orca-managed node or left as a one-shot external proxy?
**Resolution**: D-259 — Register as a managed `linux` node (name=`ingress`, addr=`<floating-ip>:8443`). `orca job run` pushes traefik dynamic config to it like any linux node. This makes routing-to-other-hosts work.
**Confidence**: 0.92
### Q3: Floating-IP network topology
**Ambiguity**: Where does SNAT run and how does the LXC get the public IP?
**Resolution**: LXC owns floating IP on eth0 (`net0 bridge=vmbr0,hwaddr=<mac>,ip=<floating-ip>/<prefix>,gw=<gateway>`). nft runs inside the LXC. Matches Hetzner/OVH floating-IP-with-MAC model.
**Confidence**: 0.90
### Q4: TLS cert resolver in the image
**Ambiguity**: The emitted static config references `certResolver: orca` but never declares the `certificatesResolvers.orca` block. v0.11 claimed P10 would wire step-ca but it was never implemented.
**Resolution**: D-257 — Bake a `certificatesResolvers.orca.tls: { }` block pointing at a mounted `/etc/orca/step-ca-root.crt` CA file. No ACME (offline-first, R-001). If the file is absent at start, traefik logs a warning and serves plain HTTP (graceful degradation). The orca bootstrap pushes the cluster root CA into the container volume.
**Confidence**: 0.90
### Q5: Container networking
**Ambiguity**: nft DNAT targets 127.0.0.1:8080/8443 on the host. How does the podman container receive that traffic?
**Resolution**: D-256 — `--network host` so traefik binds 127.0.0.1:8080/8443 directly on the host (or LXC) loopback. No container port publishing. Simplest, matches the hybrid R-017 model.
**Confidence**: 0.92
### Q6: Dynamic config volume strategy
**Ambiguity**: How does the podman traefik container consume dynamic config? Today `orca job run` writes `/etc/traefik/dynamic/orca-<svc>.yaml` on the host.
**Resolution**: D-258 — Mount `/etc/traefik/dynamic` from host (`-v /etc/traefik/dynamic:/etc/traefik/dynamic:Z`). Zero changes to the existing `deployRemote` WriteFile path. The image's file provider watches `/etc/traefik/dynamic` as today.
**Confidence**: 0.95
## Auto-resolved questions (full autonomy, no operator interaction)
### Q7: Floating-IP mode — does the PVE host also register as a node?
**Ambiguity**: In floating-IP mode, only the ingress LXC handles traffic. Does the PVE host also get registered?
**Resolution**: D-259 — Yes. The PVE host registers as a `proxmox` node (for `pct`/`qm` workload dispatch). The ingress LXC registers as a `linux` node (for traefik dynamic config pushes). Both are in the registry.
**Confidence**: 0.92
### Q8: `--ingress-mode` persistence
**Ambiguity**: Should `--ingress-mode` be stored on the node record so `doctor ingress` knows which check path to run?
**Resolution**: D-260 — Yes. Add `IngressMode` field to `model.Node` + a schema migration (0007). Values: `""` (legacy/default for linux/localhost), `"native"`, `"floating-ip"`.
**Confidence**: 0.90
### Q9: MAC generation when `--mac` omitted
**Ambiguity**: In floating-IP mode, if `--mac` is not provided, should orca generate one or require it?
**Resolution**: D-261 — Interactive mode: generate a random locally-administered MAC (`02:XX:XX:XX:XX:XX`) and print it for operator confirmation. `--json` mode: require `--mac` explicitly (no silent generation — non-interactive means explicit inputs).
**Confidence**: 0.88
### Q10: Proxmox native nft DNAT target
**Ambiguity**: In native mode, traefik runs inside an LXC. LXC has its own network namespace. nft DNAT to `127.0.0.1:8443` on the PVE host would NOT reach a container inside an LXC (different loopback). What's the DNAT target?
**Resolution**: D-262 — The nft DNAT target is parameterized via `NftClusterConfig.DNATTarget` (default `127.0.0.1:8443`). For proxmox native mode, the DNAT target is the LXC's bridge IP (`<lxc-ip>:8443`). The LXC gets a DHCP/static bridge IP; orca discovers it after `pct start` via `pct list` or `pct inspect`.
**Confidence**: 0.90
## Additional decisions (derived from constraints, no ambiguity)
### D-263: LXC podman requirements
Ubuntu 24.04 LXC template does not have podman preinstalled. Bootstrap must:
1. `pct create` with `--features nesting=1,keyctl=1` (required for podman in unprivileged LXC)
2. After LXC start: `apt-get update && apt-get install -y podman nftables` inside the LXC
3. Then `podman pull orca-traefik:<tag>` + `podman run ...`
This adds ~30-60s to the join time. Documented in `docs/uat.md`.
### Image tag strategy
The `orca-traefik` image uses the same version tag as the orca release (`v0.13.x` line). The podman reconciler resolves the tag from `internal/cli.version`. In dev builds (version="dev"), it falls back to `latest`.
### Registry auth
The `orca-traefik` image is in the same registry/org as `orca` (`git.cloudinit.dev/coreci/`). Pulls are anonymous (REQ-045, repo is public). No `podman login` needed on workers.
## Requirements impact
No new requirements beyond REQ-171..REQ-179 (already in REQUIREMENTS.md). The clarify stage confirmed scope and resolved all implementation ambiguities. The 9 decisions (D-255..D-263) are recorded in PROJECT.md.
+51
View File
@@ -0,0 +1,51 @@
# CLARIFY + RESEARCH + PLAN v0.15: CI Release Pipeline Fix
## Decisions
| ID | Decision | Rationale | Confidence |
|----|----------|-----------|------------|
| D-264 | Secret name = `PAT_TOKEN` (not `GITEA_PAT`) | Gitea reserves `GITEA_` prefix for built-in secrets | 1.0 (validated) |
| D-265 | Use `tea actions secrets create` CLI | Operator instruction: no API | 1.0 (validated) |
| D-266 | Container publishing in Gitea Actions, not CoreCI | CoreCI's podman executor appends `sh -c` which conflicts with kaniko's `/kaniko/executor` entrypoint. Gitea Actions `container:` supports `options: --entrypoint` | 0.95 |
| D-267 | kaniko `executor:debug` image | Includes `/bin/sh`; Gitea Actions can override entrypoint to `/bin/sh` then run kaniko via shell | 0.90 |
| D-268 | `coreci run` for validate/build/test/release (tarball); Gitea Actions for container publishing | Clean separation: CoreCI owns the pipeline, Gitea Actions owns the trigger + container publish | 0.95 |
## Research: CoreCI podman executor entrypoint issue
CoreCI's `internal/runner/podman_executor.go:48-51`:
```go
args = append(args, image) // e.g. gcr.io/kaniko-project/executor:debug
if job.Invoke != "" {
args = append(args, "sh", "-c", job.Invoke)
}
```
This produces: `podman run ... <image> sh -c "<commands>"`
With kaniko:debug (entrypoint `/kaniko/executor`), the actual command is:
`/kaniko/executor sh -c "<commands>"` — kaniko fails (sh is not a kaniko flag).
**Conclusion**: kaniko cannot be used as a CoreCI step image. Container
publishing must move to the Gitea Actions workflow, which supports
`container: options: --entrypoint /bin/sh` to override the entrypoint.
## Plan
### Phase 1 (only execution phase)
**Files to create/modify:**
1. `.gitea/workflows/release.yml` — Gitea Actions workflow:
- `on: push: tags: ['v*']`
- Job 1 `ci`: checkout + install Go + install coreci + `coreci run`
(executes validate/build/test/release from .coreci.yml)
- Job 2 `container-orca`: checkout + kaniko build+push orca image
(needs job 1; uses `container: gcr.io/kaniko-project/executor:debug`
with `options: --entrypoint /bin/sh`)
- Job 3 `container-traefik`: checkout + kaniko build+push orca-traefik image
(needs job 1; same kaniko approach)
2. `.coreci.yml` — remove `container-publish` and `container-publish-traefik`
steps (they now live in the Gitea Actions workflow). Keep the
`gitea-release` step (tarball + Gitea release).
3. `scripts/trigger_coreci.sh` — add tag ref handling (or document that
Gitea Actions is the trigger; the hook is for branch-push CI only).
+225
View File
@@ -0,0 +1,225 @@
# GRILL v0.14: Ingress Bootstrap Completeness — Red-Team Review
**Date**: 2026-08-10
**Reviewer**: ci-griller (adversarial)
**Subject**: PLAN_v0.14.md (9 phases, P0P8)
**Confidence in plan as-written**: **0.45 — RETHINK** (was Proceed-eligible until the mTLS scope expansion was chosen)
**Verdict**: **RETHINK** — the plan is technically grounded in strong research but contains (a) one outright correctness defect that will break execution, (b) two requirements/plan contradictions that ship broken config, (c) one scope expansion chosen during this grill that adds a 10th phase the plan does not contain, and (d) one regression of a Completed requirement. The research is the strongest artifact; the plan diverges from it in load-bearing ways.
---
## How this grill was conducted
Every plan claim was checked against the actual codebase (`internal/traefik/install.go`, `internal/emitter/nft.go`, `internal/emitter/traefik.go`, `internal/cli/init.go`, `internal/cli/upgrade.go`, `internal/cli/doctor_nft.go`, `internal/proxmox/bootstrap.go`, `internal/linux/bootstrap.go`, `internal/store/migrations/`, `internal/certpaths/`, `internal/sshpush/`, `scripts/release.sh`, `.coreci.yml`, `Dockerfile`, git tags, git log). Findings cite file:line. Four binding questions were asked interactively; answers recorded as G-001..G-004 below.
---
## Per-Axis Findings
### Axis 1 — Feasibility
**Finding F1.1 (BLOCKER — migration number collision).** PLAN P5 T3 (line 194) specifies "Schema migration 0007: `ALTER TABLE nodes ADD COLUMN ingress_mode TEXT DEFAULT ''`". Migration 0007 **already exists**`internal/store/migrations/0007_certs_serial_unique.sql` (added v0.7, P1-001). The current head is `0008_audit_tamper_evidence.sql`. The migrator runs files in lexical order and records applied versions in `schema_migrations`. Reusing 0007 will either (a) silently no-op on DBs that already recorded 0007, leaving `ingress_mode` un-added, or (b) break the migration ledger. This is a guaranteed execution-time defect.
**Evidence**: `internal/store/migrations/0007_certs_serial_unique.sql`, `internal/store/migrations/0008_audit_tamper_evidence.sql`, `internal/cli/init_test.go:84` (test asserts head = 0008).
**Resolved by**: G-001 (use `0009_ingress_mode.sql`).
**Finding F1.2 (BLOCKER — `certpaths.CAPath()` does not exist).** PLAN P3 T5 step 2 (line 142) says "Push cluster root CA … from `certpaths.CAPath()` if exists". The function `certpaths.CAPath()` does not exist. The real API is `certpaths.CACertPath()` (`internal/certpaths/certpaths.go:35`) and `certpaths.CAKeyPath()` (`:39`). P4 T1 step 2 repeats the same phantom reference. This will not compile.
**Evidence**: `internal/certpaths/certpaths.go:35` (`func CACertPath() string`); grep for `CAPath` returns zero matches in `internal/certpaths/`.
**Finding F1.3 (no `install_test.go` to rewrite).** PLAN P2 T10 (line 112) says "rewrite `internal/traefik/install_test.go`". That file does not exist — `internal/traefik/` contains only `install.go` (glob confirms). The task is "create", not "rewrite". Minor, but signals the plan was written against an imagined codebase shape, not the real one.
**Evidence**: `glob internal/traefik/*.go` → only `install.go`.
**Finding F1.4 (good — fake-SSH harness exists and is adequate).** PLAN P7 T8 claims a "hermetic fake-SSH harness" can assert `pct create` + `podman run` inside LXC. Verified: `internal/proxmox/ssh_session_test.go:23` (`fakeSSHServer`) and `internal/sshpush/transport_test.go:27` implement an in-process SSH server with `runCommand(cmd)` pattern-matching. It can assert the right commands are *sent* (e.g. `pct create … --features nesting=1,keyctl=1,fuse=1`). It cannot actually create an LXC or run podman — but the plan only claims command assertion, which is achievable. The harness is real and reusable.
**Finding F1.5 (good — callsites verified).** PLAN P2 T3/T4/T5 reference `init.go:254-266`, `linux/bootstrap.go:160-172`, `proxmox/bootstrap.go:250-255`. Verified: `init.go:256` calls `installTraefikLocal()`; `linux/bootstrap.go:170` calls `traefik.InstallRemote("", sshExecFn)`; `proxmox/bootstrap.go:253` calls `traefik.InstallRemote("", runRemote)`. The line numbers are accurate within a few lines. The plan was written against the real callsites.
### Axis 2 — Scope
**Finding F2.1 (BLOCKER — mTLS scope expansion chosen, no phase exists).** During this grill (G-003) the operator chose "Wire real mTLS now" over the plan's `tls: {}` default-cert approach. This requires step-ca to mint server certs into `/etc/traefik/dynamic` + dynamic `tls.certificates` + `tls.options.default.clientAuth.caFiles`. **Step-ca server-cert minting was deferred since v0.11 and is not implemented.** The 9-phase plan contains no such phase. The operator then chose (G-004-adjacent) to add a step-ca cert minting phase, growing v0.14 from 9 to 10 phases. The plan as written does not reflect this. Until a P-step for cert minting is added, the plan is incomplete relative to the chosen direction.
**Impact**: v0.14 cannot ship real mTLS on its current 9 phases. Either add the phase (10 phases, more risk) or revert to `tls: {}` and defer mTLS to v0.15.
**Finding F2.2 (regression — `traefik-on-public-ip` opt-out lost).** REQ-100 (Complete, v0.11) established the `--public-binding=traefik-on-public-ip` opt-out: traefik binds `:443`/`:80` directly instead of `127.0.0.1:8443`/`8080` + nft DNAT. The existing `RenderTraefikStaticConfig` (`internal/emitter/traefik.go:273`) implements this via `TraefikStaticOpts.PublicBinding`. The v0.14 plan's baked image (`docker/orca-traefik/traefik.yml`, PLAN lines 33-37) hard-codes `127.0.0.1:8443`/`8080` — there is no opt-out path in the image. Baking the static config into the image freezes out the opt-out mode that v0.11 shipped. This is a regression of a Completed requirement.
**Evidence**: `internal/emitter/traefik.go:255-266` (`publicWebSecure`/`publicWeb` switch on `PublicBinding`); `internal/emitter/traefik.go:282-300` (rendered static config); REQUIREMENTS REQ-100 line 218.
**Finding F2.3 (9 phases is borderline; 10 is too many).** Even before the mTLS expansion, 9 phases for an "ingress bootstrap completeness" milestone is heavy. P5 (proxmox native) and P6 (floating-IP) are the two most complex (LXC creation + podman-in-LXC + apt-get + nft-inside-LXC). They could potentially be merged into one phase with two code paths, since they share `ProvisionIngressLXC` plumbing. With the mTLS expansion, 10 phases is too many for a single milestone — split v0.14 into v0.14a (linux ingress + mTLS) and v0.14b (proxmox ingress).
**Finding F2.4 (good — research-validated decisions are sound).** The 7 research topics (nft postrouting, pve-firewall priority, LXC features, traefik Dockerfile, pct create syntax, podman restart, SELinux) are well-sourced and the recommended approaches are technically correct. This is the strongest artifact in the v0.14 dossier.
### Axis 3 — Dependencies
**Finding F3.1 (good — ordering is correct).** P1 (image) → P2 (reconciler) → P3 (nft+init) → P4 (remote linux) → P5 (proxmox native) → P6 (floating-IP) → P7 (doctor+tests) → P8 (ship). Each phase references the prior phase's output (P3 T6 calls `EnsureTraefikContainerLocal` from P2; P5 T4 step 10 calls `EnsureTraefikContainerRemote` from P2). No phase can ship before its prerequisite.
**Finding F3.2 (hidden dependency — P2 T8 depends on P1 T1).** P2 T8 (drop `certResolver: orca`) edits `internal/emitter/traefik.go`. P1 T1 bakes the static config. The dynamic config (`traefik.go`) and static config (`docker/orca-traefik/traefik.yml`) must be consistent: if the static config has no `certificatesResolvers.orca` (P1 T1 correctly omits it) but the dynamic config still references `certResolver: orca` (until P2 T8), traefik logs a warning on every reload. The plan orders P1 before P2, so there is a window (P1 shipped, P2 not yet) where the published image + the live dynamic config are inconsistent. This is acceptable only if P1 and P2 ship in quick succession; if P1 stalls, the image is published with a known TLS-config mismatch.
### Axis 4 — Security
**Finding F4.1 (good — nft injection guard already present).** `internal/emitter/nft.go:104-130` (`partitionTrustedProbes`) validates every `TrustedProbes` entry as IP/CIDR before rendering (F9 guard). The v0.14 plan adds `DNATTarget` (P3 T1) — this is a NEW string field rendered directly into `dnat to <DNATTarget>:8443`. **The plan does not specify validation of `DNATTarget`.** If `DNATTarget` is user-controllable (via `--floating-ip` or cluster config), an unvalidated value is an nft-syntax injection vector. The existing F9 guard covers `TrustedProbes`; `DNATTarget` needs the same treatment.
**Binding**: C-51 (see below).
**Finding F4.2 (container escape surface — LXC nesting).** `--features nesting=1,keyctl=1,fuse=1` (P5/P6) is the documented requirement for podman-in-LXC, but nesting exposes host procfs/sysfs to the guest (Proxmox docs, RESEARCH Topic 3). This is an accepted tradeoff for container-in-container, but the plan does not document the threat-model acceptance. For a "production hardening" lineage, the ingress LXC is now a privileged-ish surface (nesting+keyctl) running a podman container pulling an image from a registry. The supply chain is: `git.cloudinit.dev/coreci/orca-traefik:<version>` (P1). If the registry is compromised or the tag is re-pushed, the ingress LXC runs attacker code at the host-LXC boundary. The plan has no image-signing/verification step (`podman pull --cert-dir` or cosign).
**Binding**: C-52 (see below).
**Finding F4.3 (nft rules injection via re-apply).** P3 T4 changes `flush table` to `delete table`. RESEARCH Topic 1 establishes that `delete table` on a missing table errors, and the fix is to pre-create the table (`nft add table inet orca-ingress 2>/dev/null || true`) before `nft -f`. P3 T5 step 4 does this. Good. But the re-apply path (P3 T5 step 5, P4 T1 step 5) runs `nft -f` which replaces the whole table — if a concurrent process (pve-firewall, operator) adds rules to `orca-ingress` between the pre-create and the `nft -f`, they are wiped. This is by design (orca owns the table) but should be documented as "orca is the sole owner of `table inet orca-ingress`".
### Axis 5 — Operational
**Finding F5.1 (BLOCKER — no upgrade path from v0.13 binary+systemd to v0.14 podman).** P2 T6 (line 104) says "update legacy cutover to pull new image + recreate container instead of sed-ing traefik.yml". But `internal/cli/upgrade.go:244-407` implements a Traefik `:443``127.0.0.1:8443` cutover that does `systemctl restart traefik` (line 382) — it restarts the **systemd service**, not a container. P2 removes systemd unit generation. There is **no phase** that: (a) detects the legacy `orca-traefik.service`, (b) stops+disables it, (c) removes `/usr/local/bin/traefik` + `/etc/systemd/system/orca-traefik.service`, (d) then runs `EnsureTraefikContainerLocal`. Without this, upgrading a live v0.13 cluster leaves a dead systemd unit AND a new podman container both trying to bind `127.0.0.1:8443` → port conflict, traefik down.
**Resolved by**: G-004 (P2 T6 must remove legacy unit + binary, idempotent, tested).
**Finding F5.2 (podman/podman-restart.service unavailable).** P2 T2 enables `podman-restart.service`. RESEARCH Topic 6 establishes this is not enabled by default on Ubuntu 24.04 and must be enabled. But the plan does not specify what happens if `podman` is not installed on the target host (a fresh linux node join, P4). `EnsureTraefikContainerLocal`/`Remote` calls `podman pull`/`podman run` — if `podman` is absent, this fails. The plan has no "install podman first" step for the linux topology (P3/P4). For proxmox (P5/P6) the plan installs podman via `apt-get install -y podman conmon crun fuse-overlayfs` inside the LXC (T4 step 4 / T1 step 6). But for linux nodes (P3/P4), there is no podman-install step. This is an under-specification.
**Binding**: C-50 (see below).
**Finding F5.3 (good — `--network host` is correct).** RESEARCH Topic 3 confirms `--network host` inside an LXC binds the LXC's netns, so traefik binds `127.0.0.1:8080/8443` on the LXC loopback and nft on the PVE host DNATs to the LXC IP. This is sound. The `DNATTarget` parameterization (D-262) correctly distinguishes `127.0.0.1` (linux/localhost) from `<lxc-ip>` (proxmox native).
**Finding F5.4 (reboot persistence chain has a gap).** RESEARCH Topic 6 establishes the chain: Proxmox boot → `--onboot 1` starts LXC → LXC systemd starts → `podman-restart.service` restarts container. P5 T4 step 6 and P6 T1 step 8 enable `podman-restart.service` inside the LXC. But `--onboot 1` starts the LXC **after** the Proxmox host's network is up — if the floating IP is on a bridge that depends on a physical link that's slow to come up, the LXC may start before the bridge is ready, and the floating-IP `eth0` config may fail. The plan does not address LXC-start ordering relative to bridge readiness.
### Axis 6 — Testing
**Finding F6.1 (good — harness is real).** `fakeSSHServer` (Axis 1 F1.4) can assert `pct create` with the right `--features` and `net0` args, `apt-get install podman`, `podman run` with `--network host`. P7 T8's claims are achievable.
**Finding F6.2 (gap — no test for the v0.13→v0.14 upgrade).** G-004 requires the upgrade path to be tested. The plan's P2 Wave 4 (T10) tests the reconciler but not the legacy-removal path. There is no test that: (a) simulates a host with `orca-traefik.service` present, (b) runs the upgrade, (c) asserts the unit is stopped+disabled+removed, (d) asserts the podman container is running. This must be added to P2 T10.
**Finding F6.3 (gap — no test for DNATTarget validation).** F4.1 identifies `DNATTarget` as an injection vector. P3 T8 (nft_test) asserts `DNATTarget` substitution but does not specify a test for invalid `DNATTarget` values (e.g. `1.2.3.4:8443; flush ruleset`). The existing F9 guard test pattern should be extended.
**Finding F6.4 (good — doctor_nft extension is incremental).** P3 T7 extends `doctor_nft.go`. The existing file (`internal/cli/doctor_nft.go`) is well-structured with `nftCheckResult` lines; adding postrouting/masquerade/DNATTarget assertions is straightforward.
### Axis 7 — Performance
**Finding F7.1 (apt-get install podman on every proxmox join — 30-60s).** CLARIFY D-263 acknowledges "~30-60s to the join time". P5 T4 step 4 and P6 T1 step 6 run `apt-get update && apt-get install -y podman conmon crun fuse-overlayfs nftables` inside the LXC on every join. This is acceptable for a one-time bootstrap but painful if re-run. The reconciler must be idempotent (skip if podman already installed). The plan does not specify an idempotency check for the apt-get step.
**Binding**: C-53 (see below).
**Finding F7.2 (image pull latency).** `podman pull orca-traefik:<tag>` (P2 T1 step 3, P5/P6) pulls from `git.cloudinit.dev/coreci/`. On a proxmox host behind a slow link, this can take 10-30s for a ~150MB traefik image. The plan has no pull-timeout. If the registry is unreachable (offline-first is R-001!), the pull fails and traefik never starts. **R-001 (offline-first) is violated**: the plan depends on a registry pull at bootstrap time. There is no "pre-pull" or "bundle image into the LXC template" fallback.
**Binding**: C-54 (see below) — this is a tension with R-001 that the plan does not acknowledge.
**Finding F7.3 (nft re-apply disruption).** `nft -f` replaces the table atomically (single transaction). Existing connections are NOT disrupted (conntrack holds them). New connections during the apply window (<1ms) may be dropped. This is acceptable. No finding.
### Axis 8 — Cost
**Finding F8.1 (2 images per release — sustainable).** P1 adds `orca-traefik` image alongside `orca`. `.coreci.yml` gets a `container-publish-traefik` step (P1 T5). `scripts/release.sh` gets a second docker block (P1 T4). The traefik image is small (~150MB, Alpine-based). Registry storage: 2 images × N releases. At v0.13.x cadence (8 tags), that's 16 image-tags per milestone. Sustainable for a private Gitea registry. No finding.
**Finding F8.2 (good — release.sh extension is minimal).** P1 T4 adds ~8 lines to `scripts/release.sh` after line 212. Verified the insertion point (line 213 is end of existing docker block). Clean.
### Axis 9 — Completeness (3 topologies)
**Finding F9.1 (linux topology — covered by P3+P4).** `orca init` (P3) bootstraps nft+podman on the lead; `orca node join --type linux` (P4) does it remotely. Complete.
**Finding F9.2 (proxmox-native — covered by P5, but LXC IP discovery is hand-wavy).** P5 T4 step 9 says "Discover LXC IP via `pct config <vmid>` (parse `net0` line) or `pct exec <vmid> -- hostname -I`". But in native mode the LXC is created **without** a static IP (P5 T4 step 2 has no `ip=` in the `pct create` — unlike P6 which has `ip=<floating-ip>/<prefix>`). So the LXC gets a DHCP/bridge IP that is not known at create time. P5 step 9 discovers it after `pct start`. But step 10 then re-applies nft with the discovered IP. This is a two-phase apply: first apply with default `127.0.0.1` (wrong for native), then re-apply with LXC IP. The plan does not specify what happens to traffic between the first and second apply (it DNATs to 127.0.0.1:8443 on the PVE host where nothing listens → connections refused). There is a window of ingress downtime during native-mode bootstrap.
**Binding**: C-55 (see below).
**Finding F9.3 (floating-IP — covered by P6, but MAC uniqueness is untested).** P6 T4 generates a random `02:XX:XX:XX:XX:XX` MAC in interactive mode. The plan does not check for MAC collision on the bridge. RESEARCH Topic 5 pitfall 2: "hwaddr must be unique on the bridge". A random 02: prefix has 46 bits of entropy — collision is unlikely on a single bridge but not impossible across a multi-node cluster.
**Binding**: C-56 (see below).
**Finding F9.4 (no `localhost`/lead topology with podman).** P3 bootstraps the lead via `EnsureTraefikContainerLocal`. But `orca init` runs on the lead — if the lead has no podman installed (fresh host), `EnsureTraefikContainerLocal` fails. Same as F5.2 but for the lead. The plan assumes podman is present on the lead. No install step.
---
## Binding Conditions (gates that MUST be met before a phase ships)
| ID | Gate | Phase | Severity |
|----|------|-------|----------|
| **C-50** | P2 must not break existing `orca init` on a host without podman installed — either install podman as part of `BootstrapLocalIngress` (P3) or emit a clear error with install instructions. Same for `orca node join --type linux` (P4). | P2/P3/P4 | BLOCKER |
| **C-51** | `NftClusterConfig.DNATTarget` must be validated as `net.ParseIP` or `ip:port` before rendering. Unvalidated values are an nft-syntax injection vector (same F9 guard as TrustedProbes). Test required. | P3 | BLOCKER |
| **C-52** | The `orca-traefik` image supply chain must be documented: registry is public (anonymous pull per REQ-045), no image signing in v0.14. Document the threat-model acceptance: a compromised registry = attacker code in the ingress LXC. Add `podman image trust` or cosign verification as a v0.15 hardening item. | P1/P8 | High |
| **C-53** | The `apt-get install podman` step inside the LXC (P5 T4 step 4, P6 T1 step 6) must be idempotent: check `command -v podman` first, skip if present. Re-running join on an existing LXC must not re-run apt-get. | P5/P6 | High |
| **C-54** | R-001 (offline-first) tension: `podman pull` at bootstrap requires registry reachability. Either (a) document that ingress bootstrap requires online access (exception to R-001), or (b) pre-bundle the `orca-traefik` image into the LXC template / load from a local archive. The plan must acknowledge this tension explicitly. | P2/P5/P6 | BLOCKER |
| **C-55** | P5 native-mode bootstrap must not create a window of ingress downtime. The first nft apply must use the LXC IP (discovered after `pct start` but before the first nft apply), OR the plan must accept and document the downtime window. Two-phase apply (default → LXC IP) is a transient outage. | P5 | High |
| **C-56** | P6 MAC generation must check for collision against existing nodes' MACs in the cluster registry. Reject or regenerate on collision. | P6 | Medium |
| **C-57** | P2 T6 must implement the v0.13→v0.14 upgrade: detect `orca-traefik.service`, stop+disable, remove `/usr/local/bin/traefik` + unit file, then `EnsureTraefikContainerLocal`. Idempotent. Tested with a simulated-legacy-host test (F6.2). | P2 | BLOCKER |
| **C-58** | The baked `docker/orca-traefik/traefik.yml` must not regress REQ-100's `traefik-on-public-ip` opt-out. Either (a) bake both configs and select via env/flag, or (b) document that the opt-out is dropped in v0.14 and update REQ-100, or (c) mount the static config from host (not baked) so `RenderTraefikStaticConfig` still works. | P1 | High |
| **C-59** | Migration for `ingress_mode` must be `0009_ingress_mode.sql`, NOT 0007 (already taken by certs_serial_unique). | P5 | BLOCKER |
| **C-60** | `certpaths.CAPath()` references in P3 T5 and P4 T1 must be corrected to `certpaths.CACertPath()`. | P3/P4 | BLOCKER (compile) |
| **C-61** | REQ-172 must be amended: `--restart=always``--restart=unless-stopped` (per RESEARCH Topic 6 + PLAN), and `:Z` → omitted (per G-002). The requirement text contradicts the plan and research. | P2 | High |
| **C-62** | If mTLS is in-scope for v0.14 (per G-003), a new phase must be added implementing step-ca server-cert minting into `/etc/traefik/dynamic` + dynamic `tls.certificates` + `tls.options.default.clientAuth.caFiles`. The plan currently has no such phase (P2 T8 emits `tls: {}`). | NEW PHASE | BLOCKER |
---
## Phase Challenges (specific challenges a phase must overcome)
| ID | Challenge | Phase |
|----|-----------|-------|
| **PC-01** | P1 must bake a static config that doesn't regress the `traefik-on-public-ip` opt-out (REQ-100). Baking freezes the config; the opt-out needs a runtime switch. | P1 |
| **PC-02** | P2 must handle the 3-way TLS contradiction (CLARIFY D-257 vs RESEARCH Topic 4 vs PLAN T8). Per G-003, real mTLS is chosen — P2 alone cannot deliver it; a new phase is needed. | P2 + new |
| **PC-03** | P2 T6 must remove the legacy systemd unit + binary without breaking a running v0.13 cluster. The existing `upgrade.go` cutover logic (lines 244-407) must be rewritten to stop+disable+remove the unit, not `systemctl restart traefik`. | P2 |
| **PC-04** | P3 must install podman on the lead if absent (C-50). The plan assumes podman is present. `BootstrapLocalIngress` must either install it or fail with a clear message. | P3 |
| **PC-05** | P3 T5 must use `certpaths.CACertPath()` not the phantom `certpaths.CAPath()`. | P3 |
| **PC-06** | P3 T1 (`DNATTarget`) must validate input (C-51). New string field rendered into nft ruleset — injection risk. | P3 |
| **PC-07** | P3 T4 (first-apply flush-table fix) must work across nft versions. RESEARCH establishes `delete table` on missing table is version-dependent. The pre-create approach (`nft add table … 2>/dev/null \|\| true` before `nft -f`) is robust; the plan uses it (T5 step 4). Verify on Proxmox kernel. | P3 |
| **PC-08** | P5 native-mode has a two-phase nft apply (default 127.0.0.1 → LXC IP) that creates a transient outage window. Must be eliminated or documented (C-55). | P5 |
| **PC-09** | P5/P6 `apt-get install podman` inside LXC takes 30-60s and must be idempotent (C-53). Re-join must not re-install. | P5/P6 |
| **PC-10** | P5/P6 `podman pull` requires registry reachability, violating R-001 (offline-first). Must be acknowledged or mitigated (C-54). | P5/P6 |
| **PC-11** | P5/P6 LXC reboot chain (Proxmox boot → `--onboot 1` → LXC systemd → `podman-restart.service`) has a gap: LXC may start before bridge is ready. Floating-IP `eth0` config may fail. | P5/P6 |
| **PC-12** | P6 MAC generation must check for collision (C-56). | P6 |
| **PC-13** | P7 T8 integration test must include the v0.13→v0.14 upgrade path (F6.2): simulated legacy host → upgrade → assert unit removed + podman running. | P7 |
| **PC-14** | If the new mTLS phase is added (G-003), it must mint server certs into `/etc/traefik/dynamic` atomically (C-10 protocol) and traefik must reload them via the file provider watch. Step-ca minting was deferred since v0.11 — this is net-new work, not a completion. | new phase |
---
## Binding Decisions (from interactive grill)
| ID | Decision | Rationale | Confidence | Alternatives rejected |
|----|----------|-----------|------------|---------------------|
| **G-001** | Migration for `ingress_mode` is `0009_ingress_mode.sql` | 0007 is already `certs_serial_unique`; 0008 is `audit_tamper_evidence`. Reusing 0007 breaks the migrator. | 0.95 | Renumber existing (breaks deployed DBs); accept collision (guaranteed defect) |
| **G-002** | Omit `:Z` flag on volume mounts; use `:ro` on both | RESEARCH Topic 7: `:Z` relabels host dirs to private container label, blocks host-side orca writes on SELinux. No-op on Ubuntu/Proxmox but a latent footgun. PLAN P2 T1 + RESEARCH agree; CLARIFY D-258 + REQ-172 are wrong. | 0.90 | Keep `:Z` (wrong on future SELinux); `:z` shared (unnecessary) |
| **G-003** | Wire real mTLS now (dynamic `tls.certificates` + `clientAuth.caFiles`) | Operator chose this over `tls: {}` default. Requires step-ca server-cert minting which is not implemented (deferred since v0.11). | 0.55 | `tls: {}` now (plan's approach, defers mTLS); keep `certResolver: orca` (broken — key doesn't exist in traefik v3.3) |
| **G-004** | P2 T6 must remove legacy systemd unit + binary on upgrade from v0.13 | Without it, upgrading a live v0.13 cluster leaves a dead systemd unit + a new podman container both binding 127.0.0.1:8443 → port conflict. Idempotent + tested. | 0.90 | Leave legacy unit (port conflict); fresh-installs only (unacceptable) |
---
## Escalations (unresolved, confidence < 0.60)
| ID | Escalation | Confidence | Reason |
|----|------------|------------|--------|
| **E-001** | G-003 (real mTLS now) creates a scope expansion that the 9-phase plan does not contain. The operator chose to add a step-ca cert minting phase (growing v0.14 to 10 phases) but the plan has not been updated to reflect this. Until the new phase is specified (scope, tasks, tests), v0.14's mTLS direction is **undetermined**. The plan as written ships `tls: {}` (no mTLS), which contradicts G-003. | 0.55 | The choice is made but the plan does not reflect it. This is a plan-spec gap, not a technical unknown. |
---
## Meta — Closing Review
**What the auditor would flag**:
1. The plan ships a known-broken TLS config. RESEARCH Topic 4 calls `certResolver: orca` "the biggest v0.14 finding" and says it does not exist in traefik v3.3. CLARIFY D-257 and REQ-171 still specify it. The plan (P2 T8) drops it but emits `tls: {}` (no real TLS). G-003 chose real mTLS, which the plan doesn't contain. **Three artifacts disagree on TLS.**
2. The plan regresses REQ-100 (`traefik-on-public-ip` opt-out) by baking the static config.
3. The plan has no upgrade path from v0.13 (binary+systemd) to v0.14 (podman). G-004 binds the fix but the plan must be updated.
4. The plan references a phantom function (`certpaths.CAPath()`) and a phantom migration number (0007). Both will fail at compile/execution time.
5. The plan's offline-first claim (R-001) is violated by `podman pull` at bootstrap (C-54).
**What the project is NOT doing that it should**:
- Image supply-chain verification (no cosign, no `podman image trust`).
- LXC-start ordering relative to bridge readiness (reboot persistence gap).
- Idempotency check for `apt-get install podman` inside LXC.
- MAC collision check on the bridge.
- A test for the v0.13→v0.14 upgrade path.
**Simplest 80%-value version**: Ship P1 (image) + P2 (reconciler, with legacy-removal) + P3 (nft+init) + P4 (linux remote) + P7 (doctor+tests). Defer P5 (proxmox native) and P6 (floating-IP) to v0.15. This delivers the linux topology (the most common) + the podman migration + nft completeness, and avoids the two most complex phases (LXC+podman-in-LXC). If G-003 (real mTLS) holds, add the mTLS phase to v0.14a. Proxmox ingress becomes v0.14b/v0.15.
**What must be true for v0.14 to succeed in 90 days**:
1. The 3-way TLS contradiction is resolved in the plan (not just in this grill). Today: unresolved.
2. The migration number is 0009. Today: plan says 0007 (wrong).
3. The upgrade path from v0.13 is specified and tested. Today: not specified.
4. The `certpaths.CAPath()` phantom is fixed. Today: not fixed.
5. The offline-first tension (podman pull) is acknowledged. Today: not acknowledged.
6. If mTLS is in-scope, the new phase is written. Today: no such phase.
**Confidence**: 0.45 that the plan as-written can ship v0.14 without rework. The research is strong; the plan diverges from it and from the codebase in load-bearing ways. The mTLS scope expansion (G-003) makes it worse unless the plan is updated.
---
## Verdict
**RETHINK** (confidence 0.45).
The plan must be revised to:
1. Fix the migration number → 0009 (C-59, G-001).
2. Fix `certpaths.CAPath()``certpaths.CACertPath()` (C-60).
3. Add the v0.13→v0.14 upgrade path to P2 T6 (C-57, G-004).
4. Resolve the TLS model: either add a new mTLS phase (G-003) or revert to `tls: {}` and defer mTLS to v0.15. The plan cannot ship `certResolver: orca` (broken) and cannot ship `tls: {}` if G-003 holds.
5. Amend REQ-172: `--restart=unless-stopped` (not `always`), omit `:Z` (C-61, G-002).
6. Address the offline-first tension (C-54) or document the exception.
7. Address the REQ-100 regression (C-58): bake-vs-mount the static config.
8. Add `DNATTarget` validation (C-51).
9. Add podman-install step for linux topology (C-50).
10. If mTLS is in-scope, write the new phase (C-62, E-001).
Once these are addressed, the plan is feasible. The research foundation is solid; the plan just needs to actually follow it.
---
*This grill is recorded in `.ciagent/GRILL_v0.14.md`. Escalations are visible via `ciagent audit`. Binding decisions (G-001..G-004) should be promoted to PROJECT.md via a follow-up clarify or explicitly by the operator. The grill surfaces; it does not rewrite.*
+32 -26
View File
@@ -10,41 +10,47 @@ deactivated:
- network-engineer
- devops-engineer
phase_specific:
- uat-engineer (P12 only)
- release-engineer (P1 only — Dockerfile.traefik + release pipeline)
reason: |
Orca v0.13 is a production-hardening milestone. The active roster is
trimmed to the four personas that own the hardening work:
- lead-developer: coordinates phase decomposition, owns scheduler
wiring (R-022) and jobspec parser fixes (P03)
- backend-engineer: owns ACL enforcement wiring (R-023), injection
hardening (P02), transport/SSH safety (P08), concurrency (P07)
- data-engineer: owns SQLite busy_timeout, audit chain race fix,
migration safety, DB retention (P05, P07, P09)
- security-engineer: owns toolchain vulns (P01), seal/audit CLI
(P05), auth init-idp (P06), key zeroing, WebAuthn reg auth (P04)
Orca v0.14 is the ingress bootstrap completeness milestone. The active
roster owns the podman-traefik container + nft SNAT/DNAT + proxmox LXC
ingress work:
- lead-developer: coordinates phase decomposition, owns podman traefik
reconciler (P2), nft emitter extension (P3), init/bootstrap wiring (P3,P4)
- backend-engineer: owns Dockerfile.traefik + release pipeline (P1),
proxmox native ingress mode (P5), floating-IP LXC provisioning (P6),
doctor ingress (P7)
- data-engineer: owns IngressMode schema migration (P5), node record
updates for floating-IP LXC registration (P6)
- security-engineer: owns nft priority collision fix (pve-firewall
coexistence), step-ca root CA push to nodes, TLS model change
(certResolver → dynamic tls.certificates)
network-engineer and devops-engineer are deactivated — their territory
(nft ruleset, collector scripts) is covered by backend-engineer in
this milestone. cli-engineer and frontend-engineer remain deactivated
(no CLI framework or UI work).
uat-engineer is phase-specific for P12 (UAT plan + signoff script).
(nft ruleset, container deployment) is covered by backend-engineer +
lead-developer in this milestone. cli-engineer and frontend-engineer
remain deactivated (no CLI framework or UI work).
release-engineer is phase-specific for P1 (Dockerfile.traefik +
release.sh + .coreci.yml container-publish-traefik step).
Territory enforcement is warn mode (config.json
personas.territory_enforcement=warn). Cross-territory fixes (e.g. a
fix that touches both daemon handlers and SQLite) are allowed with a
warning.
fix that touches both nft emitter and proxmox bootstrap) are allowed
with a warning.
Framework alignment (from go.mod):
- lead-developer: cobra
- backend-engineer: cobra, connectrpc
- backend-engineer: cobra, podman (CLI), pct (CLI via SSH)
- data-engineer: modernc/sqlite
- security-engineer: go-webauthn, go-jose, x/crypto
- uat-engineer: bash, bats
- security-engineer: nft, step-ca, TLS
- release-engineer: docker, .coreci.yml
Constraint alignment:
- All personas: offline-first, no-redundant-implementations
- backend-engineer: API-first, error-handling, security-first
- backend-engineer: API-first, error-handling, security-first,
container-first (R-024)
- data-engineer: schema-first, migration-safe, local-storage-only
- security-engineer: deny-by-default, zero-trust, no-passwords (R-021)
- uat-engineer: idempotent, read-only, claim-coverage
- security-engineer: deny-by-default, zero-trust, no-passwords (R-021),
pve-firewall-coexistence
- release-engineer: per-release-tagging, registry-auth
+340
View File
@@ -0,0 +1,340 @@
# 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`) before `nft -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; emit `tls: {}` for v0.14 (real mTLS via dynamic `tls.certificates` + `clientAuth.caFiles` deferred to v0.15 — grill G-003 confidence 0.55 < 0.60 threshold, auto-resolved to defer)
- podman restart: `--restart=unless-stopped` + enable `podman-restart.service`
- volumes: omit `:Z` flag, use `:ro` on both mounts
- traefik image: `FROM traefik:v3.3.0`, `ENTRYPOINT ["/traefik"]` inherited, `CMD ["--configFile=/etc/traefik/traefik.yml"]`
- `NftClusterConfig.DNATTarget`: default `127.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()` (NOT `certpaths.CAPath()` — does not exist)
- Upgrade path: P2 must detect+remove legacy `orca-traefik.service` + `/usr/local/bin/traefik` before starting podman container (C-57)
- Podman install: `BootstrapLocalIngress` and `BootstrapRemoteIngress` must install podman if absent (C-50)
- Offline-first: `podman pull` requires registry reachability — documented exception to R-001 for ingress bootstrap (C-54)
- Static config: mount from host (not baked) to preserve `traefik-on-public-ip` opt-out (C-58)
- DNATTarget validation: `net.ParseIP` or `ip:port` parse before render (C-51)
- apt-get idempotency: `command -v podman` check 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):
```yaml
entryPoints:
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: json
```
No `certificatesResolvers` (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.yml` if it exists (overriding the baked one), preserving the `traefik-on-public-ip` opt-out (REQ-100). The reconciler renders the static config via `emitter.RenderTraefikStaticConfig` to `/etc/traefik/traefik.yml` on the host, then mounts it `-v /etc/traefik/traefik.yml:/etc/traefik/traefik.yml:ro`. This way `PublicBinding` opt-out still works.
- **T2**: Create `Dockerfile.traefik` at repo root:
```dockerfile
FROM 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"]
```
(ENTRYPOINT inherited as `["/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):
```bash
# 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` — add `container-publish-traefik` step mirroring `container-publish` with `CONTAINER_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 with `docker run --rm orca-traefik --version`).
### Must-haves
- [ ] `Dockerfile.traefik` builds successfully
- [ ] Image starts traefik with the baked static config
- [ ] `release.sh` publishes `orca-traefik:<version>` + `:latest`
- [ ] `.coreci.yml` has `container-publish-traefik` step
## 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:
1. `podman inspect orca-traefik` → if running, no-op; if stopped, `podman start orca-traefik`; if absent, go to step 2
2. `mkdir -p /etc/traefik/dynamic /etc/orca`
3. `podman pull <image>:<tag>`
4. `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)` — uses `exec.CommandContext("podman", ...)` locally
- `EnsureTraefikContainerRemote(ctx, execFn, image, tag)` — uses SSH exec function
- Image/tag resolution: `git.cloudinit.dev/coreci/orca-traefik:<version>` where version = `internal/cli.version` (or `latest` if dev)
- **Remove** systemd unit generation + `systemctl enable`
- **T2**: Add `podman-restart.service` enable step: `systemctl enable --now podman-restart.service` (research finding: needed for reboot persistence)
- **T2a**: **C-50**: `EnsureTraefikContainerLocal`/`Remote` must check `command -v podman` first. If absent: on localhost, attempt `apt-get install -y podman` (or fail with clear install instructions if no apt). On remote, `apt-get install -y podman conmon crun fuse-overlayfs` via SSH. Non-fatal warn if podman unavailable (offline host) — traefik won't start but `orca init` succeeds (same tolerance as v0.13).
### Wave 2 (callsite updates + v0.13 upgrade path)
- **T3**: `internal/cli/init.go:254-266` — replace `installTraefikLocal()` with `EnsureTraefikContainerLocal`
- **T4**: `internal/linux/bootstrap.go:160-172` — replace `traefik.InstallRemote` with `EnsureTraefikContainerRemote`
- **T5**: `internal/proxmox/bootstrap.go:250-255` — replace `traefik.InstallRemote` with `EnsureTraefikContainerRemote` (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:
1. Detect legacy `orca-traefik.service`: `systemctl is-active orca-traefik.service`
2. If active: `systemctl stop orca-traefik.service && systemctl disable orca-traefik.service`
3. Remove `/etc/systemd/system/orca-traefik.service` + `/usr/local/bin/traefik` (if exists)
4. `systemctl daemon-reload`
5. Render static config via `emitter.RenderTraefikStaticConfig` to `/etc/traefik/traefik.yml`
6. `EnsureTraefikContainerLocal` (pull + run podman container)
7. Idempotent: if no legacy unit, skip steps 1-4
- **T7**: `internal/cli/traefik_install.go` — update CLI wrapper
### Wave 3 (TLS model fix — research finding)
- **T8**: `internal/emitter/traefik.go` — drop `certResolver: orca` from the dynamic config router TLS stanza (line ~185-188). Replace with `tls: {}` (empty TLS stanza — traefik uses its default cert). Document that real mTLS via `tls.certificates` + `tls.options.default.clientAuth.caFiles` will 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 for `certResolver: orca`, add assertion for `tls: {}` presence.
### Wave 4 (tests)
- **T10**: Create `internal/traefik/install_test.go` (new file — F1.3: does not exist today) — assert `podman run` is invoked (not `curl|tar`), `--restart=unless-stopped --network host` present, volume mounts present, `podman-restart.service` enabled.
- **T10a**: **C-57/F6.2**: Add v0.13→v0.14 upgrade test: simulate a host with `orca-traefik.service` present (fake), run upgrade, assert unit stopped+disabled+removed, podman container running.
### Must-haves
- [ ] `orca init` → `podman inspect orca-traefik` shows running
- [ ] `podman logs orca-traefik` shows traefik started with baked config
- [ ] No systemd `orca-traefik.service` generated
- [ ] `--restart=unless-stopped` + `podman-restart.service` enabled
- [ ] `certResolver: orca` removed 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` — add `DNATTarget` field to `NftClusterConfig` (default `127.0.0.1`). Render DNAT rules as `dnat to <DNATTarget>:8443` / `dnat to <DNATTarget>:8080`. **C-51**: Validate `DNATTarget` with `net.ParseIP` before rendering. Reject invalid values with error (same F9 injection guard pattern as `partitionTrustedProbes`).
- **T2**: `internal/emitter/nft.go` — add `EnableSNAT bool` (default true) + `postrouting` chain:
```nft
chain postrouting {
type nat hook postrouting priority 100; policy accept;
ip saddr 127.0.0.0/8 oifname != "lo" masquerade
}
```
Only when `EnableSNAT == true`.
- **T3**: `internal/emitter/nft.go` — shift `input` and `forward` chain priorities from `filter` (=0) to `-10` (research finding: avoids pve-firewall same-priority undefined order).
- **T4**: `internal/emitter/nft.go` — fix first-apply flush-table bug: change `flush table inet orca-ingress` to `delete table inet orca-ingress` (nft ≥1.0 treats delete-of-missing as warning in `-f` mode). 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)`:
1. `mkdir -p /etc/traefik/dynamic /etc/orca`
2. **C-60**: Push cluster root CA to `/etc/orca/step-ca-root.crt` from `certpaths.CACertPath()` (if exists, else empty placeholder)
3. Render static config via `emitter.RenderTraefikStaticConfig` to `/etc/traefik/traefik.yml` (preserves `traefik-on-public-ip` opt-out — C-58)
4. Render `orca.nft` via `NftEmitter.RenderNftConfig` + write to `/etc/nftables.d/orca.nft`
5. Pre-create table: `nft add table inet orca-ingress 2>/dev/null || true`
6. Apply: `nft -f /etc/nftables.d/orca.nft`
7. **C-50**: Ensure podman installed (check `command -v podman`, install if absent)
8. `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.go` after `EnsureTraefikContainerLocal` (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 matches `NftClusterConfig.DNATTarget`.
### Wave 4 (tests)
- **T8**: `internal/emitter/nft_test.go` — assert postrouting chain present when `EnableSNAT=true`, absent when false. Assert `DNATTarget` substitution. Assert priority `-10` on input/forward.
- **T9**: Integration test: `orca init` → `nft list table inet orca-ingress` shows DNAT + postrouting; `podman inspect orca-traefik` running.
### 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.crt` exists (real CA or placeholder)
- [ ] `podman inspect orca-traefik` running
## 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` — add `BootstrapRemoteIngress(ctx, execFn)`:
1. `mkdir -p /etc/traefik/dynamic /etc/orca` (remote)
2. **C-60**: Push step-ca root CA to remote `/etc/orca/step-ca-root.crt` from `certpaths.CACertPath()` via `WriteFile`
3. Render `orca.nft` + write to remote `/etc/nftables.d/orca.nft` via `WriteFile`
4. `nft add table inet orca-ingress 2>/dev/null || true` (remote)
5. `nft -f /etc/nftables.d/orca.nft` (remote)
6. `systemctl enable --now podman-restart.service` (remote)
7. `EnsureTraefikContainerRemote` (from P2)
- **T2**: Wire into `internal/linux/bootstrap.go` after the traefik container reconciler step.
- **T3**: Extend `linux.Result` with `IngressOK bool` for reporting.
### Wave 2 (tests)
- **T4**: Fake-SSH test: assert remote `nft -f` + `podman run` + `WriteFile` for step-ca CA invoked.
### Must-haves
- [ ] `orca node join --type linux --host <ip>` → remote has podman traefik running + nft applied + step-ca CA mounted
- [ ] `doctor 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: `native` default, `floating-ip`), `--floating-ip`, `--gateway`, `--mac`, `--net-prefix` (default `24`).
- **T2**: Add `IngressMode` field to `model.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`, when `IngressMode == "native"`:
1. On the PVE host: render+apply nft with `DNATTarget = <lxc-bridge-ip>` (the traefik LXC's IP, discovered after `pct start`)
2. 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`
3. `pct start <vmid>`
3a. **C-55**: Discover LXC IP via `pct config <vmid>` (parse `net0` line for `ip=`) or `pct exec <vmid> -- hostname -I` BEFORE the first nft apply. The nft DNAT target is set to the LXC IP from the start — no two-phase apply, no downtime window.
4. 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
5. Configure podman storage (`/etc/containers/storage.conf`): `mount_program = "/usr/bin/fuse-overlayfs"` (fallback: `driver = "vfs"`)
6. `systemctl enable --now podman-restart.service` (inside LXC)
7. Push step-ca root CA into LXC
8. `EnsureTraefikContainerRemote` (podman pull + run inside LXC with `--network host`)
9. **C-55**: Render+apply nft on PVE host with `DNATTarget = <lxc-ip>` (discovered in step 3a) — single apply, no downtime window
### Wave 3 (registration)
- **T5**: Register PVE host as `proxmox` node with `IngressMode: "native"`.
### Wave 4 (tests)
- **T6**: Fake-SSH test: assert `pct create` with `--features nesting=1,keyctl=1,fuse=1`, `apt-get install podman`, `podman run` inside 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)`:
1. `pveam download local <template>` (idempotent, already in bootstrap)
2. Deterministic VMID for "ingress" (hash of host+"ingress")
3. `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:8`
4. `pct start <vmid>`
5. Wait for LXC network (retry SSH to `<floating-ip>` with backoff, 60s timeout)
6. 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
7. Configure podman storage (fuse-overlayfs / vfs fallback)
8. `systemctl enable --now podman-restart.service`
9. Push step-ca root CA into LXC
10. 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.1` here because traefik runs with `--network host` inside the LXC
11. `EnsureTraefikContainerRemote` (podman pull + run inside LXC with `--network host`)
12. Push orca SSH pubkey into LXC for future `job run` traefik dynamic-config pushes
### Wave 2 (registration)
- **T2**: Register LXC as managed node: `registry.Join` with `Kind: "linux"`, `Name: "ingress"`, `Address: "<floating-ip>:8443"`, `OS: "linux"`, `IngressMode: "floating-ip"`.
- **T3**: Also register PVE host as `proxmox` node (for workload dispatch).
### Wave 3 (interactive prompting)
- **T4**: Interactive prompting in `joinProxmox` (node.go): when `--ingress-mode` empty and `!jsonOutput`:
- Prompt "Ingress mode [native/floating-ip] (default native): "
- If `floating-ip`: prompt for floating IP (validate `net.ParseIP`), gateway (validate `net.ParseIP`), MAC (validate `net.ParseMAC`; generate `02:XX:XX:XX:XX:XX` random 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 `--json` mode: require `--mac` explicitly if `--ingress-mode floating-ip` (no silent generation)
### Wave 4 (routing)
- **T5**: `joinProxmox` in `node.go` routes: native → P5 path; floating-ip → `ProvisionIngressLXC` + register LXC + register PVE host.
### Wave 5 (tests)
- **T6**: Fake-SSH test: assert `pct create` with `net0 name=eth0,bridge=vmbr0,hwaddr=<mac>,ip=<floating-ip>/<prefix>,gw=<gateway>`, `--features nesting=1,keyctl=1,fuse=1`; LXC registered as `linux` node named `ingress` at `<floating-ip>:8443`; PVE host registered as `proxmox`.
- **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` → LXC `ingress` created
- [ ] LXC has podman traefik running + nft applied inside LXC
- [ ] Node `ingress` registered as `linux` at `203.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>]`:
1. `podman inspect orca-traefik` → running?
2. nft DNAT+SNAT applied (reuse `doctor_nft` logic)
3. `/etc/traefik/dynamic` exists
4. step-ca root CA mounted (`podman inspect` volume check or file-exists check)
5. For proxmox-native: checks the LXC (via `pct exec`)
6. For floating-ip: checks the ingress LXC over SSH
7. 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_lxc` or `43 ingress_floating_ip_lxc` (depending on topology).
### Wave 3 (docs)
- **T3**: `docs/cli.md` — document `--ingress-mode`, `--floating-ip`, `--gateway`, `--mac`, `--net-prefix` flags + `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 (dynamic `tls.certificates`, not certResolver), `--network host` rationale.
- **T6**: `docs/docker.md` — `orca-traefik` image: 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 create` with correct `net0` args + LXC registered as `linux` node
- release.sh builds orca-traefik image (Dockerfile.traefik parses)
### Wave 5 (verify)
- **T9**: `make build && make test && make lint && make verify-docs` all pass.
### Must-haves
- [ ] `orca doctor ingress` exits 0 on a properly bootstrapped node
- [ ] UAT signoff script includes new assertions
- [ ] `make verify-docs` passes
- [ ] 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
+46
View File
@@ -707,6 +707,52 @@ v0.13 closes all critical/high/medium findings (15 new requirements,
14 phases) and delivers the **UAT plan + signoff script** that gates
the v1.0.0 cut.
### v0.14 Milestone: Ingress Bootstrap Completeness
**Scope**: ensure that linux & proxmox types are properly bootstrapped
with traefik during cluster init or node join. All cluster endpoints are
provisioned as sockets (R-007); routing between jobs and services
depends on traefik being present and properly configured. v0.13 shipped
traefik binary + systemd unit + empty dynamic dir but never wrote the
static config nor applied nft rules — `orca-traefik.service` fails on a
fresh `orca init` and `orca doctor nft` FAILs. v0.14 replaces the
binary+systemd model with a **podman container** running a custom
`orca-traefik` image, and completes the nft SNAT+DNAT ingress stack on
every node type.
**New load-bearing rule**: **R-024** — Traefik runs exclusively as a
podman container, deployed from the `orca-traefik` image published per
release. Every orca-managed ingress surface bootstraps: nft DNAT
(`:443→127.0.0.1:8443`, `:80→127.0.0.1:8080`) + SNAT/MASQUERADE
postrouting + `podman run -d --restart=always --network host -v
/etc/traefik/dynamic:/etc/traefik/dynamic:Z -v
/etc/orca/step-ca-root.crt:/etc/orca/step-ca-root.crt:ro
git.cloudinit.dev/coreci/orca-traefik:<tag>`. No node joins without a
functional podman-traefik ingress data plane.
**Three topologies** (per operator constraints):
1. **Linux**: host → nft → `podman run orca-traefik` (host network)
2. **Proxmox Native**: host → nft → LXC (nesting=1) → `podman run orca-traefik`
3. **Proxmox Floating-IP**: LXC (owns floating IP) → nft (inside LXC) → `podman run orca-traefik`
**Milestone type**: feature (multiple `feat` phases). Tags on v0.13.x
patch line: `v0.13.0` (P0) ... `v0.13.8` (P8 final = v0.14 milestone
release). 9 phases, 9 net-new requirements (REQ-171..REQ-179).
### v0.14 Decisions (D-series, full autonomy)
| ID | Question | Decision | Rationale | Confidence |
|----|----------|----------|-----------|------------|
| D-255 | Traefik deployment model? | **Podman container from custom `orca-traefik` image** | Operator constraint: traefik always deployed as a container. Replaces v0.13 binary+systemd. Image bakes static config. | 0.95 |
| D-256 | Container network mode? | **`--network host`** | Binds 127.0.0.1:8080/8443 directly on host/LXC loopback; nft DNAT targets that. No port publishing complexity. | 0.92 |
| D-257 | TLS cert resolver in image? | **No certResolver; `tls: {}` for v0.14, real mTLS deferred to v0.15** | Grill G-003 (confidence 0.55 < 0.60) auto-resolved to defer. Traefik v3.3 `certificatesResolvers` only supports acme/tailscale, not CA-file. Drop `certResolver: orca` (broken). Emit `tls: {}` in dynamic config. Real mTLS via dynamic `tls.certificates` + `tls.options.default.clientAuth.caFiles` lands in v0.15 when step-ca mints server certs. | 0.90 |
| D-258 | Dynamic config volume? | **Mount `/etc/traefik/dynamic` from host** | Zero changes to existing `deployRemote` WriteFile path (`job_dispatch.go:243`). File provider watches it. | 0.95 |
| D-259 | Floating-IP mode: register PVE host too? | **Yes — PVE host as `proxmox` + ingress LXC as `linux`** | PVE host needed in registry for `pct`/`qm` workload dispatch. Both register. | 0.92 |
| D-260 | `--ingress-mode` persistence? | **Store `IngressMode` on `model.Node`** | `doctor ingress` needs to know which check path to run. Schema migration. | 0.90 |
| D-261 | MAC generation when `--mac` omitted? | **Generate random `02:XX:...` in interactive mode; require `--mac` in `--json` mode** | Interactive: generate + confirm. Non-interactive: explicit required (no silent generation). | 0.88 |
| D-262 | Proxmox native nft DNAT target? | **LXC bridge IP (not 127.0.0.1)** | LXC has its own network namespace; 127.0.0.1 on PVE host ≠ LXC loopback. `NftClusterConfig.DNATTarget` field (default `127.0.0.1:8443`; native mode = `<lxc-ip>:8443`). | 0.90 |
| D-263 | LXC podman requirements? | **`--features nesting=1,keyctl=1` + `apt-get install podman`** | Ubuntu 24.04 LXC template has no podman preinstalled. Nesting+keyctl required for podman in unprivileged LXC. | 0.88 |
### v0.13 Decisions (D-series, full autonomy)
| ID | Question | Decision | Rationale | Confidence |
+81 -1
View File
@@ -362,7 +362,7 @@ production-ready tag. v1.0.0 is gated on the UAT signoff script
- 14 phases (P0 + P01..P12 + P13 final); "no limit on phases" per operator.
- P03 (scheduler wiring) and P12 (`--type linux` + UAT) are the `feat` phases; the rest are `fix`/`chore`/`test`/`docs`/`refactor`. Milestone type = feature (at least one `feat`).
- Tags on v0.12.x patch line: `v0.12.0` (P0) ... `v0.12.13` (P13 final = v0.13 milestone release).
- v1.0.0 production-ready tag stays deferred for post-v0.13 UAT signoff (operator runs `scripts/uat-signoff.sh`, pastes output back).
- v1.0.0 production-ready tag stays deferred for post-v0.13 UAT signoff (operator runs `scripts/uat-signoff.sh`, paste output back).
### Accepted residual risks (documented in threat-model, not fixed)
@@ -374,3 +374,83 @@ production-ready tag. v1.0.0 is gated on the UAT signoff script
- "WASM-first" pillar aspirational — document as "WASM runtime available, process is default"
- arm64/armv7 release — D-193 deferred; install.sh detection is forward-looking
- OIDC callback slowloris — loopback, short-lived, single CLI invocation
## Milestone v0.14: Ingress Bootstrap Completeness
**Scope**: ensure that linux & proxmox types are properly bootstrapped with
traefik during cluster init or node join. All cluster endpoints are
provisioned as sockets (R-007); routing between jobs and services depends on
traefik being present on the host and properly configured. The v0.13 traefik
deployment shipped only a binary + systemd unit + empty dynamic dir — it
never wrote the static config nor applied nft rules, so `orca-traefik.service`
fails to start on a fresh `orca init` and `orca doctor nft` FAILs. v0.14
replaces the binary+systemd model with a **podman container** running a custom
`orca-traefik` image, and completes the nft SNAT+DNAT ingress stack on every
node type.
**New load-bearing rule**:
- **R-024** — Traefik runs exclusively as a podman container, deployed from
the `orca-traefik` image published per release. Every orca-managed ingress
surface bootstraps: nft DNAT (`:443→127.0.0.1:8443`,
`:80→127.0.0.1:8080`) + SNAT/MASQUERADE postrouting + `podman run -d
--restart=unless-stopped --network host -v /etc/traefik/traefik.yml:/etc/traefik/traefik.yml:ro
-v /etc/traefik/dynamic:/etc/traefik/dynamic:ro
-v /etc/orca/step-ca-root.crt:/etc/orca/step-ca-root.crt:ro
git.cloudinit.dev/coreci/orca-traefik:<tag>`. No node joins without a
functional podman-traefik ingress data plane. The image's baked static
config is a default; host-side `traefik.yml` mounted `:ro` overrides it
(preserves `traefik-on-public-ip` opt-out, REQ-100).
**Three topologies** (per operator constraints):
- **Linux**: host → nft → `podman run orca-traefik` (host network)
- **Proxmox Native**: host → nft → LXC (nesting=1) → `podman run orca-traefik`
- **Proxmox Floating-IP**: LXC (owns floating IP) → nft (inside LXC) →
`podman run orca-traefik`
| ID | Requirement | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-171 | `Dockerfile.traefik` + release pipeline: build + publish `git.cloudinit.dev/coreci/orca-traefik:<version>` alongside the orca image per release; `.coreci.yml` `container-publish-traefik` step; image bakes default `traefik.yml` (entrypoints `websecure` 127.0.0.1:8443, `web` 127.0.0.1:8080, `traefik` 127.0.0.1:8081 + file provider watching `/etc/traefik/dynamic` + json log/accessLog); host-side `/etc/traefik/traefik.yml` mounted `:ro` overrides baked config (preserves `traefik-on-public-ip` opt-out REQ-100); no `certificatesResolvers` (traefik v3.3 only supports acme/tailscale); `tls: {}` in dynamic config for v0.14 (real mTLS deferred to v0.15) | Critical | **v0.14 P1** | complete |
| REQ-172 | Replace `internal/traefik/install.go` binary+systemd install with a podman-container reconciler: `podman pull orca-traefik:<tag>` + `podman run -d --restart=unless-stopped --network host --name orca-traefik -v /etc/traefik/traefik.yml:/etc/traefik/traefik.yml:ro -v /etc/traefik/dynamic:/etc/traefik/dynamic:ro -v /etc/orca/step-ca-root.crt:/etc/orca/step-ca-root.crt:ro <image>`; idempotent (pull+run if absent, start if stopped); install podman if absent (C-50); v0.13→v0.14 upgrade: detect+stop+disable+remove legacy `orca-traefik.service` + `/usr/local/bin/traefik` (C-57); works locally + over SSH-push; enable `podman-restart.service`; remove systemd unit generation | Critical | **v0.14 P2** | complete |
| REQ-173 | nft SNAT+DNAT ruleset render+apply: extend `internal/emitter/nft.go` with postrouting masquerade chain; new `internal/ingress/bootstrap.go` renders `orca.nft` + applies `nft -f` + ensures `/etc/traefik/dynamic` dir + pushes step-ca root CA + invokes podman traefik reconciler; wired into `orca init` (localhost lead) | Critical | **v0.14 P3** | complete |
| REQ-174 | Remote ingress bootstrap via SSH-push for `orca node join --type linux`: push step-ca root CA, render+apply nft remotely, invoke podman traefik reconciler remotely; register node as `linux` | Critical | **v0.14 P4** | complete |
| REQ-175 | Proxmox native ingress mode (`--ingress-mode native`, default): on PVE host, render+apply nft (vmbr-compatible, separate `orca-ingress` table avoids pve-firewall conflict); create unprivileged LXC with `--features nesting=1,keyctl=1` running podman+orca-traefik; push step-ca root CA into LXC; nft DNAT target = LXC bridge IP; register PVE host as `proxmox` node; add `IngressMode` field to `model.Node` + schema migration | Critical | **v0.14 P5** | complete |
| REQ-176 | Proxmox floating-IP mode (`--ingress-mode floating-ip --floating-ip --gateway --mac [--net-prefix]`): `pct create` Ubuntu LXC named `ingress` with `net0 bridge=vmbr0,hwaddr=<mac>,ip=<floating-ip>/<prefix>,gw=<gateway> --features nesting=1,keyctl=1 --onboot 1`; install podman + run orca-traefik inside LXC; apply nft DNAT+SNAT inside LXC; register LXC as managed `linux` node (name=`ingress`, addr=`<floating-ip>:8443`); interactive prompt for params when flags absent + not `--json`; validate IP/MAC/gateway; PVE host also registered as `proxmox` for workload dispatch | Critical | **v0.14 P6** | complete |
| REQ-177 | `orca doctor ingress [--peer]`: verify orca-traefik container running (`podman inspect`), nft DNAT+SNAT applied, `/etc/traefik/dynamic` exists, step-ca root CA mounted; extend `scripts/uat-signoff.sh` with ingress assertions (40 podman_traefik, 41 nft_dnat_snat, 42 linux_worker, 43 proxmox_native_lxc / floating_ip_lxc) | High | **v0.14 P7** | complete |
| REQ-178 | Docs: `docs/cli.md` (`--ingress-mode` + floating-IP flags + `doctor ingress`), `docs/uat.md` (native + floating-IP topologies), `docs/ingress.md` (podman-traefik image + volume mounts + certResolver), `docs/docker.md` (orca-traefik image), `ARCHITECTURE.md` (R-024 + ingress bootstrap section) | High | **v0.14 P7** | complete |
| REQ-179 | Integration tests: hermetic harness fakes SSH; asserts init→podman traefik running + nft applied; linux join→remote podman+nft; proxmox native→LXC created with nesting + podman traefik; floating-ip→`pct create` with correct net0 args + LXC registered as linux node; release.sh builds orca-traefik image (Dockerfile.traefik parses) | Critical | **v0.14 P7** | complete |
### Scope notes (v0.14)
- REQ-171..REQ-179 = 9 net-new requirements (REQ count grows 163 -> 172).
- 9 phases (P0 + P1..P7 + P8 final); feature milestone (multiple `feat` phases).
- Tags on v0.13.x patch line: `v0.13.0` (P0) ... `v0.13.8` (P8 final = v0.14 milestone release).
- Milestone branch: `milestone/v0.14-ingress-bootstrap`.
## Milestone v0.15: CI Release Pipeline Fix
**Scope**: fix the container image publishing pipeline. v0.14 shipped
`Dockerfile.traefik` + `Dockerfile` but no container images were
published to the Gitea registry because: (1) no Gitea Actions workflow
existed to trigger on tag pushes, (2) the CoreCI trigger script
stripped tag refs, (3) the `.coreci.yml` container-publish steps used
Docker-in-Docker (`docker:24-cli`) which is prohibited. v0.15 adds a
Gitea Actions workflow that triggers on tag pushes, installs the
`coreci` binary on the runner, and runs `coreci run`. The
`.coreci.yml` container-publish steps are rewritten to use kaniko
(no Docker daemon required).
| ID | Requirement | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-180 | Create `.gitea/workflows/release.yml` that triggers on `push: tags: ['v*']`, installs the `coreci` binary (from `git.cloudinit.dev/coreci/coreci`), injects `PAT_TOKEN` secret as `GITEA_TOKEN` env var, and runs `coreci run` — which executes the full `.coreci.yml` pipeline (validate, build, test, release) locally on the Gitea Actions runner | Critical | **v0.15 P1** | complete |
| REQ-181 | Replace `docker:24-cli` DinD steps in `.coreci.yml` with kaniko (`gcr.io/kaniko-project/executor:debug`): write `/kaniko/.docker/config.json` from `GITEA_TOKEN` (base64 auth), run `/kaniko/executor --dockerfile=<Dockerfile> --context=dir://. --destination=<registry/image:tag> --skip-tls-verify-registry`. Applies to both `container-publish` (orca image) and `container-publish-traefik` (orca-traefik image) | Critical | **v0.15 P1** | complete |
| REQ-182 | Set `PAT_TOKEN` Gitea Actions repository secret via `tea actions secrets create` (same value as `GITEA_TOKEN` from `.env`). Gitea reserves the `GITEA_` prefix for built-in secrets, so the secret must be named `PAT_TOKEN`, not `GITEA_PAT` | High | **v0.15 P0** | complete |
### Scope notes (v0.15)
- REQ-180..REQ-182 = 3 net-new requirements (REQ count grows 172 -> 175).
- 3 phases (P0 + P1 + P2 final); fix milestone (no `feat` phases — CI infrastructure).
- Tags on v0.14.x patch line: `v0.14.0` (P0) ... `v0.14.2` (P2 final = v0.15 milestone release).
- Milestone branch: `milestone/v0.15-ci-release-pipeline`.
- REQ-182 is complete: `PAT_TOKEN` secret created via `tea actions secrets create PAT_TOKEN <value> --repo coreci/orca`.
File diff suppressed because it is too large Load Diff
+70
View File
@@ -675,3 +675,73 @@ CI agent verifies and cuts v1.0.0).
- jobspec `health` prober (v0.13 adds lint warning; enforcement deferred)
- jobspec `update` rolling/canary controller (v0.13 adds lint warning; enforcement deferred)
- jobspec `schedule.cron` scheduler loop (v0.13 adds lint warning; enforcement deferred)
## Milestone v0.14: Ingress Bootstrap Completeness — **COMPLETE**
**Scope**: ensure that linux & proxmox types are properly bootstrapped with
traefik during cluster init or node join. All cluster endpoints are
provisioned as sockets (R-007); routing between jobs and services depends on
traefik being present and properly configured. v0.13 shipped traefik binary +
systemd unit + empty dynamic dir but never wrote the static config nor applied
nft rules. v0.14 replaces the binary+systemd model with a **podman container**
running a custom `orca-traefik` image, and completes the nft SNAT+DNAT ingress
stack on every node type.
**New load-bearing rule**: **R-024** — Traefik runs exclusively as a podman
container from the `orca-traefik` image published per release. Every
orca-managed ingress surface bootstraps nft DNAT + SNAT/MASQUERADE +
`podman run --restart=always --network host` with dynamic-config + step-ca
root CA volume mounts.
**Three topologies**:
1. **Linux**: host → nft → `podman run orca-traefik` (host network)
2. **Proxmox Native**: host → nft → LXC (nesting=1) → `podman run orca-traefik`
3. **Proxmox Floating-IP**: LXC (owns floating IP) → nft (inside LXC) → `podman run orca-traefik`
**Milestone type**: feature (multiple `feat` phases). Tags on v0.13.x patch
line: `v0.13.0` (P0) ... `v0.13.8` (P8 final = v0.14 milestone release).
- [x] Phase 0: Pre-execution (SPECIFY→CLARIFY→RESEARCH→PLAN→GRILL) — tag `v0.13.0`
- [x] Phase 1: `orca-traefik` container image + release pipeline (REQ-171) — tag `v0.13.1`
- [x] Phase 2: Podman traefik reconciler — replace binary+systemd install (REQ-172) — tag `v0.13.2`
- [x] Phase 3: nft SNAT+DNAT + `orca init` ingress bootstrap (REQ-173) — tag `v0.13.3`
- [x] Phase 4: `orca node join --type linux` remote ingress bootstrap (REQ-174) — tag `v0.13.4`
- [x] Phase 5: Proxmox native ingress mode — LXC + podman traefik (REQ-175) — tag `v0.13.5`
- [x] Phase 6: Proxmox floating-IP LXC ingress + interactive prompt (REQ-176) — tag `v0.13.6`
- [x] Phase 7: `doctor ingress` + docs + integration tests (REQ-177,178,179) — tag `v0.13.7`
- [x] Phase 8: Final review + ship + audit (milestone release) — tag `v0.13.8` = **v0.14 milestone release**
### Per-phase REQ coverage (v0.14)
- **P1** — `Dockerfile.traefik` + release pipeline (REQ-171)
- **P2** — Podman traefik reconciler (REQ-172)
- **P3** — nft SNAT+DNAT + init bootstrap (REQ-173)
- **P4** — Linux node join remote ingress (REQ-174)
- **P5** — Proxmox native ingress — LXC + podman (REQ-175)
- **P6** — Proxmox floating-IP LXC ingress (REQ-176)
- **P7** — doctor ingress + docs + tests (REQ-177,178,179)
- **P8** — Final review + ship + audit
### v0.14 is a continuation milestone, not a direction change
The vision ("minimalist, offline-first, CLI-first orchestration engine")
is unchanged. v0.14 completes the ingress bootstrap that v0.13 left
non-functional (binary installed but no config, no nft applied). The
podman-container model is the operator's constraint; the architecture's
socket+traefik routing design (R-007, R-017) is unchanged.
## Milestone v0.15: CI Release Pipeline Fix — **COMPLETE**
**Scope**: fix container image publishing. v0.14 shipped
`Dockerfile.traefik` + `Dockerfile` but no images were published
because no Gitea Actions workflow triggered on tag pushes, and
`.coreci.yml` used Docker-in-Docker. v0.15 adds a Gitea Actions
workflow (trigger on tag push → install coreci → `coreci run`) and
rewrites the container-publish steps to use kaniko (no DinD).
**Milestone type**: fix (CI infrastructure). Tags on v0.14.x patch
line: `v0.14.0` (P0) ... `v0.14.2` (P2 final = v0.15 milestone release).
- [x] Phase 0: Pre-execution (SPECIFY→CLARIFY→RESEARCH→PLAN→GRILL) — tag `v0.14.0`
- [x] Phase 1: Gitea Actions workflow + .coreci.yml kaniko rewrite (REQ-180,181) — tag `v0.14.1`
- [x] Phase 2: Final review + ship + audit (milestone release) — tag `v0.14.2` = **v0.15 milestone release**
+4 -3
View File
@@ -5,9 +5,9 @@
"slug": "orca",
"name": "Orca",
"description": "Offline/CLI-first orchestration engine (Orca) \u2014 Nomad-inspired, far simpler than Kubernetes",
"milestone": "v0.13",
"milestone": "v0.15",
"phase": 0,
"milestone_type": "feature",
"milestone_type": "fix",
"default_branch": "main",
"tech_stack": {
"language": "go",
@@ -185,7 +185,8 @@
"registry": "git.cloudinit.dev",
"owner": "coreci",
"image": "orca",
"credential_env": "GITEA_TOKEN"
"credential_env": "GITEA_TOKEN",
"images": ["orca", "orca-traefik"]
}
},
"secrets": {
+39 -31
View File
@@ -23,25 +23,25 @@ pipelines:
description: Validate Go toolchain, formatting, and security scans
steps:
- name: go-version
image: golang:1.25
image: golang:1.25.12
commands:
- go version
- gofmt -l .
- go vet ./...
- name: verify-reqs
image: golang:1.25
image: golang:1.25.12
commands:
- make verify-reqs
- name: gosec
image: golang:1.25
image: golang:1.25.12
commands:
- go install github.com/securego/gosec/v2/cmd/gosec@v2.18.2
- gosec -fmt text -quiet ./...
- name: govulncheck
image: golang:1.25
image: golang:1.25.12
env:
# REQ-027: offline mode. GOFLAGS=-mod=mod ensures module mode;
# GOVULNCHECK_DB (when present) overrides the bundled DB.
@@ -51,7 +51,7 @@ pipelines:
- govulncheck -mode binary ./...
- name: gitleaks
image: golang:1.25
image: golang:1.25.12
commands:
- apk add --no-cache curl
- sh -c "$(curl -fsSL https://github.com/gitleaks/gitleaks/releases/latest/download/install.sh)"
@@ -61,7 +61,7 @@ pipelines:
description: Build the orca binary with version injection
steps:
- name: build
image: golang:1.25
image: golang:1.25.12
env:
VERSION: ${CI_COMMIT_TAG:-dev}
GIT_COMMIT: ${CI_COMMIT_SHA}
@@ -80,7 +80,7 @@ pipelines:
description: Run all tests with race detection and coverage (REQ-031)
steps:
- name: test
image: golang:1.25
image: golang:1.25.12
commands:
- go test -race -coverprofile=coverage.out ./...
- go tool cover -func=coverage.out | tail -1
@@ -91,7 +91,7 @@ pipelines:
ref: "refs/tags/v*"
steps:
- name: build-artifact
image: golang:1.25
image: golang:1.25.12
env:
VERSION: ${CI_COMMIT_TAG}
GIT_COMMIT: ${CI_COMMIT_SHA}
@@ -105,37 +105,45 @@ pipelines:
go build -trimpath -ldflags="${LDFLAGS}" -o bin/orca ./cmd/orca
- make changelog
- tar -czf orca-${VERSION}-linux-amd64.tar.gz -C bin orca
- ls -lh orca-${VERSION}-linux-amd64.tar.gz
- sha256sum orca-${VERSION}-linux-amd64.tar.gz > SHA256SUMS
- ls -lh orca-${VERSION}-linux-amd64.tar.gz SHA256SUMS
- cat SHA256SUMS
- name: gitea-release
image: golang:1.25
image: golang:1.25.12
env:
GITEA_TOKEN: ${GITEA_TOKEN}
VERSION: ${CI_COMMIT_TAG}
commands:
- apk add --no-cache curl tar
- apk add --no-cache curl tar python3
- sh -c "$(curl -fsSL https://gitea.com/gitea/tea/releases/latest/download/install.sh)"
- tea releases create ${VERSION}
--repo coreci/orca
--title "Orca ${VERSION}"
--note-file CHANGELOG.md
--asset orca-${VERSION}-linux-amd64.tar.gz
- name: container-publish
description: Build and publish OCI image to Gitea container registry (REQ-046)
image: docker:24-cli
env:
GITEA_TOKEN: ${GITEA_TOKEN}
VERSION: ${CI_COMMIT_TAG}
GIT_COMMIT: ${CI_COMMIT_SHA}
BUILD_TIME: ${CI_BUILD_TIME}
commands:
- docker build
--build-arg VERSION=${VERSION}
--build-arg GIT_COMMIT=${GIT_COMMIT}
--build-arg BUILD_TIME=${BUILD_TIME}
-t git.cloudinit.dev/coreci/orca:${VERSION}
-t git.cloudinit.dev/coreci/orca:latest
.
- echo "${GITEA_TOKEN}" | docker login git.cloudinit.dev -u cloudinit-bot --password-stdin
- docker push git.cloudinit.dev/coreci/orca:${VERSION}
- docker push git.cloudinit.dev/coreci/orca:latest
- docker logout git.cloudinit.dev
--asset SHA256SUMS
- |
# Verify assets are actually attached (REQ-097, gate C-21).
# tea releases create has been observed to exit 0 without
# attaching the asset in some versions. Verify via the API.
ASSET_COUNT=$(curl -fsSL \
"https://git.cloudinit.dev/api/v1/repos/coreci/orca/releases/tags/${VERSION}" \
| python3 -c "import json,sys; r=json.load(sys.stdin); print(len(r.get('assets',[])))")
echo "Release ${VERSION} has ${ASSET_COUNT} assets"
if [ "${ASSET_COUNT}" -lt 2 ]; then
echo "ERROR: Expected at least 2 assets (tarball + SHA256SUMS), got ${ASSET_COUNT}"
echo "Attempting to attach assets manually..."
TARBALL_URL=$(curl -fsSL \
"https://git.cloudinit.dev/api/v1/repos/coreci/orca/releases/tags/${VERSION}" \
| python3 -c "import json,sys; r=json.load(sys.stdin); print(r.get('id',''))")
if [ -n "${TARBALL_URL}" ]; then
curl -fsSL -X "POST" \
"https://git.cloudinit.dev/api/v1/repos/coreci/orca/releases/${TARBALL_URL}/assets?name=orca-${VERSION}-linux-amd64.tar.gz" \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@orca-${VERSION}-linux-amd64.tar.gz"
curl -fsSL -X "POST" \
"https://git.cloudinit.dev/api/v1/repos/coreci/orca/releases/${TARBALL_URL}/assets?name=SHA256SUMS" \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@SHA256SUMS"
fi
fi
+90
View File
@@ -0,0 +1,90 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
ci:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
- name: Install CoreCI
run: |
git clone --depth=1 https://git.cloudinit.dev/coreci/coreci.git /tmp/coreci
cd /tmp/coreci
CGO_ENABLED=0 go build -tags sqlite_go,embed -o /usr/local/bin/coreci ./cmd/coreci
coreci version
- name: Run CoreCI pipeline
env:
GITEA_TOKEN: ${{ secrets.PAT_TOKEN }}
run: |
coreci run
container-orca:
runs-on: ubuntu-latest
needs: ci
container:
image: gcr.io/kaniko-project/executor:debug
options: --entrypoint /bin/sh
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build and push orca image
env:
GITEA_TOKEN: ${{ secrets.PAT_TOKEN }}
VERSION: ${{ gitea.ref_name }}
run: |
mkdir -p /kaniko/.docker
AUTH=$(echo -n "cloudinit-bot:${GITEA_TOKEN}" | base64 -w0)
echo "{\"auths\":{\"git.cloudinit.dev\":{\"auth\":\"${AUTH}\"}}}" > /kaniko/.docker/config.json
GIT_COMMIT=$(echo -n "${{ gitea.sha }}" | cut -c1-12)
BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
/kaniko/executor \
--dockerfile=Dockerfile \
--context=dir://. \
--destination=git.cloudinit.dev/coreci/orca:${VERSION} \
--destination=git.cloudinit.dev/coreci/orca:latest \
--build-arg=VERSION=${VERSION} \
--build-arg=GIT_COMMIT=${GIT_COMMIT} \
--build-arg=BUILD_TIME=${BUILD_TIME} \
--skip-tls-verify-registry
container-traefik:
runs-on: ubuntu-latest
needs: ci
container:
image: gcr.io/kaniko-project/executor:debug
options: --entrypoint /bin/sh
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build and push orca-traefik image
env:
GITEA_TOKEN: ${{ secrets.PAT_TOKEN }}
VERSION: ${{ gitea.ref_name }}
run: |
if [ ! -f Dockerfile.traefik ]; then
echo "Dockerfile.traefik not found at this tag — skipping orca-traefik image"
exit 0
fi
mkdir -p /kaniko/.docker
AUTH=$(echo -n "cloudinit-bot:${GITEA_TOKEN}" | base64 -w0)
echo "{\"auths\":{\"git.cloudinit.dev\":{\"auth\":\"${AUTH}\"}}}" > /kaniko/.docker/config.json
/kaniko/executor \
--dockerfile=Dockerfile.traefik \
--context=dir://. \
--destination=git.cloudinit.dev/coreci/orca-traefik:${VERSION} \
--destination=git.cloudinit.dev/coreci/orca-traefik:latest \
--skip-tls-verify-registry
+1 -1
View File
@@ -21,7 +21,7 @@ ARG BUILD_TIME=unknown
# --- Stage 1: build -------------------------------------------------------
FROM golang:1.25 AS builder
FROM golang:1.25.12 AS builder
ARG VERSION
ARG GIT_COMMIT
+33
View File
@@ -0,0 +1,33 @@
# Dockerfile.traefik — custom orca-traefik image (R-024)
#
# Extends the official traefik:v3.3.0 image with a baked default static
# config. The host-side /etc/traefik/traefik.yml (rendered by
# emitter.RenderTraefikStaticConfig) is mounted :ro at runtime to
# override this default — preserving the traefik-on-public-ip opt-out
# (REQ-100) and any site-local customisation.
#
# Dynamic config (routers, services, certs) is mounted from
# /etc/traefik/dynamic on the host — orca writes to it atomically via
# the SSH-push transport (C-10 protocol).
#
# Build:
# docker build -f Dockerfile.traefik -t git.cloudinit.dev/coreci/orca-traefik:v0.13.1 .
#
# Run (hybrid R-017 mode — nft DNATs :443/:80 to loopback):
# podman run -d --name orca-traefik --restart=unless-stopped \
# --network host \
# -v /etc/traefik/traefik.yml:/etc/traefik/traefik.yml:ro \
# -v /etc/traefik/dynamic:/etc/traefik/dynamic:ro \
# -v /etc/orca/step-ca-root.crt:/etc/orca/step-ca-root.crt:ro \
# git.cloudinit.dev/coreci/orca-traefik:v0.13.1
FROM traefik:v3.3.0
LABEL org.opencontainers.image.title="orca-traefik"
LABEL org.opencontainers.image.description="Custom Traefik image for Orca ingress (R-024)"
LABEL org.opencontainers.image.source="https://git.cloudinit.dev/coreci/orca"
COPY docker/orca-traefik/traefik.yml /etc/traefik/traefik.yml
COPY docker/orca-traefik/step-ca-root.crt /etc/orca/step-ca-root.crt
CMD ["--configFile=/etc/traefik/traefik.yml"]
+1
View File
@@ -0,0 +1 @@
15ee8f02ef938496ce9baae35e2971a5fcfb2d55b2c6e35e49c11285f67aeb53 orca-v0.12.18-linux-amd64.tar.gz
+19
View File
@@ -0,0 +1,19 @@
entryPoints:
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: json
+71 -7
View File
@@ -138,13 +138,77 @@ restore traffic).
## TLS
- **certResolver**: `orca` (references the Traefik ACME/step-ca
certificate resolver configured in Traefik's static config).
- **Trust domain**: `cluster.orca.local` (placeholder in v0.9; step-ca
provisioner in v0.11 overrides with the real cluster trust domain).
- **SPIFFE SVIDs**: workload identity via SPIFFE SVIDs minted at submit
time via step-ca (v0.11-P01.5, gate C-08). The SVID is a URI SAN in
the workload's X.509 cert.
- **v0.14 model**: `tls: {}` in dynamic config (no certResolver).
Traefik v3.3 `certificatesResolvers` only supports `acme` and
`tailscale` — not CA-file-based. The `certResolver: orca` reference
from v0.11 was broken (research finding). v0.14 emits `tls: {}`
(traefik uses its default self-signed cert). Real mTLS via dynamic
`tls.certificates` + `tls.options.default.clientAuth.caFiles` is
deferred to v0.15.
- **Step-ca root CA**: mounted at `/etc/orca/step-ca-root.crt` in the
traefik container. v0.14 does not use it for TLS termination (it's
a placeholder for v0.15 mTLS).
## R-024: Podman Traefik Container (v0.14)
As of v0.14, Traefik runs as a **podman container** from the custom
`orca-traefik` image (published per release). The v0.13 binary+systemd
install is replaced.
### Three topologies
1. **Linux**: host → nft DNAT → `podman run orca-traefik` (`--network host`)
2. **Proxmox Native** (`--ingress-mode native`, default): PVE host →
nft DNAT → LXC (nesting=1,keyctl=1,fuse=1) → `podman run orca-traefik`
3. **Proxmox Floating-IP** (`--ingress-mode floating-ip`): LXC owns
the floating IP → nft inside LXC → `podman run orca-traefik`
### Container configuration
```bash
podman run -d --name orca-traefik --restart=unless-stopped \
--network host \
-v /etc/traefik/traefik.yml:/etc/traefik/traefik.yml:ro \
-v /etc/traefik/dynamic:/etc/traefik/dynamic:ro \
-v /etc/orca/step-ca-root.crt:/etc/orca/step-ca-root.crt:ro \
git.cloudinit.dev/coreci/orca-traefik:<version>
```
- `--network host`: traefik binds 127.0.0.1:8080/8443 on host/LXC loopback
- `--restart=unless-stopped`: survives reboot via `podman-restart.service`
- No `:Z` SELinux flag (research Topic 7)
- Static config mounted `:ro` (overrides baked image default, preserves
`traefik-on-public-ip` opt-out, REQ-100)
### nft ruleset
The nft emitter (`internal/emitter/nft.go`) renders `/etc/nftables.d/orca.nft`:
- DNAT `:443``<DNATTarget>:8443` (default 127.0.0.1; LXC IP for native)
- DNAT `:80``<DNATTarget>:8080`
- SNAT/MASQUERADE: `ip saddr 127.0.0.0/8 oifname != "lo" masquerade`
- Input/forward chains at priority -10 (pve-firewall coexistence)
### `orca doctor ingress`
```bash
orca doctor ingress # check localhost
orca doctor ingress --peer <name> # check remote peer
```
Verifies: podman container running, nft DNAT+SNAT, dynamic dir exists,
step-ca root CA present.
### Dockerfile.traefik
```dockerfile
FROM traefik:v3.3.0
COPY docker/orca-traefik/traefik.yml /etc/traefik/traefik.yml
CMD ["--configFile=/etc/traefik/traefik.yml"]
```
Built + published per release alongside the orca image
(`scripts/release.sh` + `.coreci.yml container-publish-traefik`).
## Health checks
+78 -37
View File
@@ -21,52 +21,41 @@ Use `--type linux` for all remote nodes. Proxmox-specific claims
(`doctor proxmox`, PVE role, sudoers) are **skipped** in this path.
The signoff script reports exercised vs. skipped claims.
### Pre-staging
1. Build orca from the v0.13 tag:
```sh
git clone https://git.cloudinit.dev/coreci/orca.git
cd orca && git checkout v0.12.13
make build
# binary is at bin/orca
```
2. Generate the orca SSH keypair on the lead:
```sh
ssh-keygen -t ed25519 -f ~/.ssh/orca_ed25519 -N ""
```
3. Pre-stage the orca public key on pve01 and worker01:
```sh
ssh-copy-id -i ~/.ssh/orca_ed25519.pub root@pve01
ssh-copy-id -i ~/.ssh/orca_ed25519.pub root@worker01
```
4. Pin host-key fingerprints (optional but recommended):
```sh
ssh-keyscan pve01 | ssh-keygen -lf -
ssh-keyscan worker01 | ssh-keygen -lf -
```
## Step-by-step UAT
### Step 1: Initialize the cluster
### Step 1: Install orca + initialize the cluster
Install orca (1-liner):
```sh
curl -fsSL https://git.cloudinit.dev/coreci/orca/raw/branch/main/scripts/install.sh | bash
```
Initialize the cluster:
```sh
export ORCA_HOME=~/orca-uat
orca init
```
**Expected**: cluster directory created, CA cert generated, localhost node registered.
**Expected**: orca init creates:
- CA cert + server cert
- SSH keypair (orca_ssh_key + orca_ssh_key.pub)
- known_hosts file (empty, for TOFU capture)
- Master key (for secrets encryption)
- Traefik data-plane ingress (binary + systemd unit + config)
- Localhost node registered
**Pre-staging remote nodes**: `orca init` interactively prompts for remote
host addresses and runs `ssh-copy-id` automatically (password prompt passes
through). Enter each host (pve01, worker01) when prompted, or press Enter to
skip. The orca public key is deployed to each host; TOFU host-key capture is
automatic on the first `orca node join` — no manual fingerprint pinning needed.
### Step 2: Onboard the Proxmox host
```sh
orca node join --type proxmox \
--host pve01 \
--ssh-user root \
--ssh-key ~/.ssh/orca_ed25519 \
--host-key-fingerprint SHA256:<fingerprint>
--ssh-user root
```
**Expected**: SSH bootstrap succeeds, orca user created, PVE role assigned, node registered as `ready` with `kind=proxmox`.
@@ -76,9 +65,7 @@ orca node join --type proxmox \
```sh
orca node join --type linux \
--host worker01 \
--ssh-user root \
--ssh-key ~/.ssh/orca_ed25519 \
--host-key-fingerprint SHA256:<fingerprint>
--ssh-user root
```
**Expected**: SSH bootstrap succeeds, orca user created, drift-events dir created, node registered as `ready` with `kind=linux`.
@@ -184,12 +171,20 @@ orca acl check operator-1 --namespace prod --permission admin
**Expected**: read+write allowed, admin denied (not granted).
### Step 12b: Initialize the OIDC provider (for seal)
```sh
orca auth init-idp --rp-id orca.local
```
**Expected**: Dex config + systemd unit + Traefik route rendered. (Dex binary must be installed separately.)
### Step 13: Seal/unseal
```sh
orca cluster seal --rp-id orca.local
orca cluster seal
orca cluster unseal
orca secrets set prod TEST_KEY --value "test-value"
orca secrets set prod TEST_KEY=test-value
orca secrets get prod TEST_KEY
```
@@ -317,3 +312,49 @@ scripts/uat-signoff.sh
2. Run `scripts/uat-signoff.sh` on the lead
3. Paste the output back to the CI agent
4. The CI agent verifies `35/35 PASS` and cuts `v1.0.0`
## Troubleshooting
### ORCA_HOME not set
All orca commands use `$ORCA_HOME` (default `~/.orca`). If commands fail
with "no such file or directory", verify:
```sh
echo $ORCA_HOME
ls $ORCA_HOME/orca.db $ORCA_HOME/orca_ssh_key $ORCA_HOME/known_hosts $ORCA_HOME/cluster/master.key
```
### known_hosts missing
If SSH operations fail with "open .../known_hosts: no such file", the
known_hosts file was not created during `orca init`. Fix:
```sh
touch $ORCA_HOME/known_hosts
chmod 600 $ORCA_HOME/known_hosts
```
### Traefik not running
If Traefik routes are not deployed, verify Traefik is running:
```sh
systemctl status orca-traefik
ls /etc/traefik/dynamic/
```
If not installed, `orca init` should have installed it. Re-run `orca init`
or install manually from https://github.com/traefik/traefik/releases.
### SSH connection refused
If the orca SSH key is not pre-staged on the remote host:
```sh
ssh-copy-id -i ~/.orca/orca_ssh_key.pub root@<host>
```
### Job deployed but not visible in `job list`
The remote dispatch path now inserts a DB record (v0.12.16). If you
still don't see it, check:
```sh
orca job list --json
```
Look for the `"node"` field — it shows which node the job deployed to.
### Proxmox: process runtime rejected
Proxmox nodes require `one_of: pve-ct` or `one_of: pve-vm` in the
jobspec. `one_of: process` (systemd) is for Linux/Ubuntu workers only.
+45
View File
@@ -0,0 +1,45 @@
---
kind: Service
name: web-app-lxc
namespace: prod
runtime:
one_of: pve-ct
image: local:vztmpl/ubuntu-24.04
resources:
cpu_millicores: 500
memory_mib: 512
disk_mib: 2048
ports:
- name: http
port: 8080
protocol: tcp
constraints:
- "node.kind == 'proxmox'"
restart:
mode: service
max_retries: 3
delay: 10s
health:
interval: 30s
timeout: 5s
path: /healthz
tasks:
- name: web
runtime:
command: "/bin/bash -c 'apt-get update && apt-get install -y nginx && nginx -g 'daemon off;'"
ports:
- name: http
port: 8080
protocol: tcp
---
# Web App (LXC container variant)
# Deploys as a Proxmox LXC container via `pct create`.
# Requires --target <proxmox-node> and the LXC template
# (auto-downloaded during `orca node join --type proxmox`).
+12
View File
@@ -32,6 +32,7 @@ var (
aclRevokeNamespace string
aclCheckNamespace string
aclCheckPermission string
aclCheckVerbose bool
)
var aclCmd = &cobra.Command{
@@ -351,6 +352,16 @@ read, write, admin (default: read).`,
}
identity.Namespace = ns
allowed := a.Check(identity, ns, perm)
if aclCheckVerbose {
fmt.Fprintf(cmd.ErrOrStderr(), "ACL path: %s\n", paths.ACLPath())
fmt.Fprintf(cmd.ErrOrStderr(), "Identity: kind=%s id=%s ns=%s\n", identity.Kind, identity.ID, ns)
fmt.Fprintf(cmd.ErrOrStderr(), "Permission: %s -> allowed=%v\n", permStr, allowed)
entries := a.List()
fmt.Fprintf(cmd.ErrOrStderr(), "ACL entries (%d):\n", len(entries))
for _, e := range entries {
fmt.Fprintf(cmd.ErrOrStderr(), " kind=%s id=%s ns=%s perms=%d\n", e.Identity.Kind, e.Identity.ID, e.Namespace, e.Permissions)
}
}
if jsonOutput {
return printJSON(map[string]any{
"identity": identity,
@@ -373,6 +384,7 @@ func init() {
aclGrantCmd.Flags().StringVar(&aclGrantPermissions, "permissions", "read", "comma-separated permissions: read,write,admin")
aclRevokeCmd.Flags().StringVar(&aclRevokeNamespace, "namespace", "", "namespace scope (required for tokens; defaults to spiffe path ns)")
aclCheckCmd.Flags().StringVar(&aclCheckNamespace, "namespace", "", "namespace scope (required for tokens; defaults to spiffe path ns)")
aclCheckCmd.Flags().BoolVar(&aclCheckVerbose, "verbose", false, "print ACL path + loaded entries for debugging")
aclCheckCmd.Flags().StringVar(&aclCheckPermission, "permission", "read", "permission to check: read, write, or admin")
aclCmd.AddCommand(aclGrantCmd)
+115
View File
@@ -0,0 +1,115 @@
// Package cli: doctor_ingress.go implements `orca doctor ingress`
// (R-024, v0.14). The check verifies the podman traefik container is
// running, nft DNAT+SNAT is applied, the dynamic config directory
// exists, and the step-ca root CA is mounted.
package cli
import (
"context"
"fmt"
"strings"
"time"
"github.com/spf13/cobra"
)
var doctorIngressCmd = &cobra.Command{
Use: "ingress",
Short: "Check the ingress stack (R-024: podman traefik + nft + CA)",
Long: `Verify the orca ingress data plane is healthy:
1. orca-traefik podman container is running
2. nft DNAT + SNAT masquerade applied
3. /etc/traefik/dynamic directory exists
4. step-ca root CA mounted at /etc/orca/step-ca-root.crt
For remote peers, use --peer <name>.`,
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
results := runIngressChecks(ctx)
if jsonOutput {
return printJSON(results)
}
allPass := true
for _, r := range results {
status := "✓"
if r.Result != "PASS" {
status = "✗"
allPass = false
}
fmt.Fprintf(cmd.OutOrStdout(), "%s %s: %s\n", status, r.Name, r.Message)
}
if !allPass {
return fmt.Errorf("ingress checks failed")
}
return nil
},
}
// ingressCheckResult is one line of `orca doctor ingress` output.
type ingressCheckResult struct {
Name string `json:"name"`
Result string `json:"result"`
Message string `json:"message"`
}
func runIngressChecks(ctx context.Context) []ingressCheckResult {
t, err := nftTransportFromCtx()
if err != nil {
return []ingressCheckResult{{Name: "ingress:transport", Result: "FAIL", Message: err.Error()}}
}
peer := nftLeadPeer()
var results []ingressCheckResult
// 1. Check podman orca-traefik container is running.
out, err := t.Exec(ctx, peer, "podman inspect --format '{{.State.Running}}' orca-traefik 2>/dev/null")
if err != nil {
results = append(results, ingressCheckResult{Name: "ingress:container", Result: "FAIL", Message: fmt.Sprintf("podman inspect: %v", err)})
} else {
v := strings.TrimSpace(string(out))
if v == "true" {
results = append(results, ingressCheckResult{Name: "ingress:container", Result: "PASS", Message: "orca-traefik container running"})
} else if v == "false" {
results = append(results, ingressCheckResult{Name: "ingress:container", Result: "FAIL", Message: "orca-traefik container is stopped"})
} else {
results = append(results, ingressCheckResult{Name: "ingress:container", Result: "FAIL", Message: "orca-traefik container not found"})
}
}
// 2. Check nft DNAT + SNAT (reuse the nft table output).
tableOut, tableErr := t.Exec(ctx, peer, "nft list table inet orca-ingress 2>/dev/null")
if tableErr != nil {
results = append(results, ingressCheckResult{Name: "ingress:nft", Result: "FAIL", Message: "nft table orca-ingress missing"})
} else {
tableStr := string(tableOut)
hasDNAT := strings.Contains(tableStr, "dnat to")
hasSNAT := strings.Contains(tableStr, "masquerade")
if hasDNAT && hasSNAT {
results = append(results, ingressCheckResult{Name: "ingress:nft", Result: "PASS", Message: "nft DNAT + SNAT masquerade present"})
} else if hasDNAT {
results = append(results, ingressCheckResult{Name: "ingress:nft", Result: "WARN", Message: "DNAT present but SNAT masquerade missing"})
} else {
results = append(results, ingressCheckResult{Name: "ingress:nft", Result: "FAIL", Message: "nft DNAT missing"})
}
}
// 3. Check /etc/traefik/dynamic directory exists.
if _, err := t.Exec(ctx, peer, "test -d /etc/traefik/dynamic"); err != nil {
results = append(results, ingressCheckResult{Name: "ingress:dynamic-dir", Result: "FAIL", Message: "/etc/traefik/dynamic directory missing"})
} else {
results = append(results, ingressCheckResult{Name: "ingress:dynamic-dir", Result: "PASS", Message: "/etc/traefik/dynamic exists"})
}
// 4. Check step-ca root CA is mounted/present.
if _, err := t.Exec(ctx, peer, "test -f /etc/orca/step-ca-root.crt"); err != nil {
results = append(results, ingressCheckResult{Name: "ingress:ca", Result: "WARN", Message: "/etc/orca/step-ca-root.crt missing (TLS not configured)"})
} else {
results = append(results, ingressCheckResult{Name: "ingress:ca", Result: "PASS", Message: "step-ca root CA present"})
}
return results
}
func init() {
doctorCmd.AddCommand(doctorIngressCmd)
}
+14
View File
@@ -113,6 +113,20 @@ func runNftChecks(ctx context.Context) []nftCheckResult {
results = append(results, nftCheckResult{Name: "nft:dnat-80", Result: "FAIL", Message: "DNAT :80->127.0.0.1:8080 missing"})
}
// Research Topic 1: postrouting masquerade for DNAT return path.
if strings.Contains(tableStr, "masquerade") {
results = append(results, nftCheckResult{Name: "nft:snat-masquerade", Result: "PASS", Message: "postrouting masquerade (SNAT) present"})
} else {
results = append(results, nftCheckResult{Name: "nft:snat-masquerade", Result: "FAIL", Message: "postrouting masquerade missing (R-024)"})
}
// Research Topic 2: priority -10 on input/forward (pve-firewall coexistence).
if strings.Contains(tableStr, "priority -10") {
results = append(results, nftCheckResult{Name: "nft:priority", Result: "PASS", Message: "input/forward chains at priority -10 (pve-firewall coexistence)"})
} else {
results = append(results, nftCheckResult{Name: "nft:priority", Result: "WARN", Message: "priority -10 not found (may be pre-v0.14 ruleset)"})
}
if strings.Contains(tableStr, "ora_rl") {
results = append(results, nftCheckResult{Name: "nft:rate-limit", Result: "PASS", Message: "rate-limit meter ora_rl present"})
} else {
+67 -18
View File
@@ -1,27 +1,28 @@
package cli
import (
"bufio"
"context"
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"path/filepath"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/ssh"
"github.com/spf13/cobra"
"git.cloudinit.dev/coreci/orca/internal/acl"
"git.cloudinit.dev/coreci/orca/internal/certpaths"
"git.cloudinit.dev/coreci/orca/internal/identity"
"git.cloudinit.dev/coreci/orca/internal/ingress"
"git.cloudinit.dev/coreci/orca/internal/model"
"git.cloudinit.dev/coreci/orca/internal/paths"
"git.cloudinit.dev/coreci/orca/internal/secrets"
"git.cloudinit.dev/coreci/orca/internal/security"
"git.cloudinit.dev/coreci/orca/internal/store"
"github.com/google/uuid"
"github.com/spf13/cobra"
"golang.org/x/crypto/ssh"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
const (
@@ -63,8 +64,7 @@ func runInit(out interface{ Write([]byte) (int, error) }) error {
Database string `json:"database"`
CAFingerprint string `json:"ca_fingerprint,omitempty"`
CertFingerprint string `json:"cert_fingerprint,omitempty"`
OS string `json:"os"
"path/filepath"`
OS string `json:"os"`
NodeID string `json:"node_id"`
NodeName string `json:"node_name"`
Steps []stepResult `json:"steps"`
@@ -160,6 +160,50 @@ func runInit(out interface{ Write([]byte) (int, error) }) error {
fmt.Fprintf(out, "\xe2\x9c\x93 SSH keypair provisioned: fp=%s\n", sshKeyFp[:min(16, len(sshKeyFp))]+"...")
}
// Step 4a-2: Interactively pre-stage the orca public key on remote
// hosts (ssh-copy-id). Skipped in --json mode (non-interactive).
// The operator enters host addresses (one per line, empty line to
// finish). For each host, ssh-copy-id is run; if SSH key auth is
// not yet established, ssh-copy-id prompts for the password
// interactively. This makes orca init the single entry point —
// no manual pre-staging required.
if !jsonOutput {
pubPath := certpaths.SSHPubPath()
fmt.Fprintf(out, "\n Pre-stage the orca public key on remote nodes.\n")
fmt.Fprintf(out, " Enter host addresses (one per line, empty line to skip):\n")
stagedHosts := []string{}
reader := bufio.NewReader(os.Stdin)
for {
fmt.Fprintf(out, " host> ")
line, err := reader.ReadString('\n')
if err != nil {
break
}
host := strings.TrimSpace(line)
if host == "" {
break
}
// Run ssh-copy-id interactively (password prompt passes through).
fmt.Fprintf(out, " Deploying orca key to %s...\n", host)
cmd := exec.Command("ssh-copy-id", "-i", pubPath, "-o", "StrictHostKeyChecking=accept-new", "root@"+host)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(out, " \xe2\x9a\xa0 Failed to deploy key to %s: %v\n", host, err)
continue
}
fmt.Fprintf(out, " \xe2\x9c\x93 Key deployed to %s\n", host)
stagedHosts = append(stagedHosts, host)
}
if len(stagedHosts) > 0 {
summary.Steps = append(summary.Steps, stepResult{Label: "pre-stage", Status: "ok", Detail: strings.Join(stagedHosts, ", ")})
} else {
summary.Steps = append(summary.Steps, stepResult{Label: "pre-stage", Status: "skipped", Detail: "no hosts entered"})
}
fmt.Fprintf(out, "\n")
}
// Step 4b: known_hosts file (empty, 0600). Without this, the TOFU
// host-key callback fails with "no such file" on the first SSH dial
// (knownhosts.New requires the file to exist).
@@ -208,17 +252,22 @@ func runInit(out interface{ Write([]byte) (int, error) }) error {
}
}
// Step 4d: Install Traefik on the lead node (REQ-165, Phase B).
// Traefik is the data-plane ingress. Idempotent.
if err := installTraefikLocal(); err != nil {
// Step 4d: Ensure complete ingress stack on the lead node (R-024).
// This replaces the v0.13 binary+systemd traefik install with:
// 1. Render + write traefik static config (traefik.yml)
// 2. Render + write + apply nft DNAT/SNAT ruleset (orca.nft)
// 3. Push step-ca root CA to /etc/orca/step-ca-root.crt
// 4. Ensure podman orca-traefik container running
// All steps non-fatal (offline host tolerance).
if err := ingress.BootstrapLocalIngress(context.Background(), version); err != nil {
if !jsonOutput {
fmt.Fprintf(out, "Traefik install skipped: %v\n", err)
fmt.Fprintf(out, "Ingress bootstrap skipped: %v\n", err)
}
summary.Steps = append(summary.Steps, stepResult{Label: "traefik", Status: "skipped", Detail: err.Error()})
summary.Steps = append(summary.Steps, stepResult{Label: "ingress", Status: "skipped", Detail: err.Error()})
} else {
summary.Steps = append(summary.Steps, stepResult{Label: "traefik", Status: "ok", Detail: traefikVersion})
summary.Steps = append(summary.Steps, stepResult{Label: "ingress", Status: "ok", Detail: "nft+traefik container active"})
if !jsonOutput {
fmt.Fprintf(out, "Traefik installed: %s\n", traefikVersion)
fmt.Fprintf(out, "Ingress: nft DNAT+SNAT applied, traefik container running\n")
}
}
+204 -24
View File
@@ -1,22 +1,26 @@
package cli
import (
"bufio"
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"net"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/google/uuid"
"github.com/spf13/cobra"
"golang.org/x/crypto/ssh"
"git.cloudinit.dev/coreci/orca/internal/certpaths"
"git.cloudinit.dev/coreci/orca/internal/linux"
"git.cloudinit.dev/coreci/orca/internal/engine"
"git.cloudinit.dev/coreci/orca/internal/linux"
"git.cloudinit.dev/coreci/orca/internal/model"
"git.cloudinit.dev/coreci/orca/internal/proxmox"
"git.cloudinit.dev/coreci/orca/internal/security"
@@ -46,20 +50,25 @@ func nodeRegistry() (*engine.NodeRegistry, func() error, error) {
}
var (
joinName string
joinAddr string
joinCAFinger string
joinType string
joinHost string
joinSSHUser string
joinSSHKey string
joinSSHPort int
joinHostKeyFP string
joinName string
joinAddr string
joinCAFinger string
joinType string
joinHost string
joinSSHUser string
joinSSHKey string
joinSSHPort int
joinHostKeyFP string
joinLXCTemplate string
proxmoxUser string
proxmoxRole string
leaveID string
nodeWatch bool
proxmoxUser string
proxmoxRole string
ingressMode string
floatingIP string
gateway string
macAddr string
netPrefix int
leaveID string
nodeWatch bool
)
var nodeCmd = &cobra.Command{
@@ -175,7 +184,83 @@ func joinProxmox(cmd *cobra.Command) error {
return fmt.Errorf("SSH key path is required for --type proxmox (R-021: no passwords; use --ssh-key or pre-stage the orca key)")
}
ctx, cancel := context.WithTimeout(cmd.Context(), 60*time.Second)
// Default ingress mode to "native" if not specified (R-024).
effectiveIngressMode := ingressMode
if effectiveIngressMode == "" {
if !jsonOutput {
// Interactive mode: prompt for ingress mode.
fmt.Fprint(cmd.OutOrStdout(), "Ingress mode [native/floating-ip] (default native): ")
scanner := bufio.NewScanner(os.Stdin)
if scanner.Scan() {
input := strings.TrimSpace(scanner.Text())
if input == "floating-ip" {
effectiveIngressMode = "floating-ip"
} else {
effectiveIngressMode = "native"
}
} else {
effectiveIngressMode = "native"
}
} else {
effectiveIngressMode = "native"
}
}
// Floating-IP mode: prompt for params if not provided.
effectiveFloatingIP := floatingIP
effectiveGateway := gateway
effectiveMAC := macAddr
if effectiveIngressMode == "floating-ip" {
if effectiveFloatingIP == "" && !jsonOutput {
fmt.Fprint(cmd.OutOrStdout(), "Floating IP: ")
scanner := bufio.NewScanner(os.Stdin)
if scanner.Scan() {
effectiveFloatingIP = strings.TrimSpace(scanner.Text())
}
}
if effectiveGateway == "" && !jsonOutput {
fmt.Fprint(cmd.OutOrStdout(), "Gateway: ")
scanner := bufio.NewScanner(os.Stdin)
if scanner.Scan() {
effectiveGateway = strings.TrimSpace(scanner.Text())
}
}
if effectiveMAC == "" && !jsonOutput {
// D-261: auto-generate a random locally-administered MAC.
generated, err := proxmox.GenerateRandomMAC()
if err == nil {
fmt.Fprintf(cmd.OutOrStdout(), "Generated MAC: %s (press enter to accept, or type your own): ", generated)
scanner := bufio.NewScanner(os.Stdin)
if scanner.Scan() {
input := strings.TrimSpace(scanner.Text())
if input != "" {
effectiveMAC = input
} else {
effectiveMAC = generated
}
} else {
effectiveMAC = generated
}
}
}
// Validate floating-IP mode params.
if effectiveIngressMode == "floating-ip" {
if net.ParseIP(effectiveFloatingIP) == nil {
return fmt.Errorf("--floating-ip %q is not a valid IP", effectiveFloatingIP)
}
if net.ParseIP(effectiveGateway) == nil {
return fmt.Errorf("--gateway %q is not a valid IP", effectiveGateway)
}
if _, err := net.ParseMAC(effectiveMAC); err != nil {
return fmt.Errorf("--mac %q is not a valid MAC: %w", effectiveMAC, err)
}
if netPrefix < 8 || netPrefix > 32 {
return fmt.Errorf("--net-prefix %d must be 8-32", netPrefix)
}
}
}
ctx, cancel := context.WithTimeout(cmd.Context(), 180*time.Second) // 3min for LXC creation
defer cancel()
result, err := proxmox.BootstrapProxmox(ctx, proxmox.Options{
@@ -188,6 +273,7 @@ func joinProxmox(cmd *cobra.Command) error {
HostKeyFingerprint: joinHostKeyFP,
Logger: newLogger(),
LXCTemplate: joinLXCTemplate,
IngressMode: effectiveIngressMode,
})
if err != nil {
return fmt.Errorf("proxmox bootstrap: %w", err)
@@ -204,18 +290,67 @@ func joinProxmox(cmd *cobra.Command) error {
defer regCancel()
node := &model.Node{
ID: uuid.NewString(),
Name: result.NodeName,
Address: result.NodeAddress,
State: model.NodeStateReady,
JoinedAt: time.Now().UTC(),
LastSeen: time.Now().UTC(),
Kind: string(model.NodeKindProxmox),
OS: "pve",
ID: uuid.NewString(),
Name: result.NodeName,
Address: result.NodeAddress,
State: model.NodeStateReady,
JoinedAt: time.Now().UTC(),
LastSeen: time.Now().UTC(),
Kind: string(model.NodeKindProxmox),
OS: "pve",
IngressMode: effectiveIngressMode,
}
if err := registry.Join(regCtx, node); err != nil {
return fmt.Errorf("register proxmox node: %w", err)
}
// Floating-IP mode: provision the ingress LXC and register it as a
// linux node (R-024, REQ-176). The PVE host is registered as
// proxmox (above); the ingress LXC is registered as linux so
// `orca job run` pushes traefik dynamic config to it.
if effectiveIngressMode == "floating-ip" {
lxcLog := newLogger()
// Build a runRemote function from the proxmox bootstrap result.
// We need SSH access to the PVE host to run pct commands.
lxcCtx, lxcCancel := context.WithTimeout(ctx, 120*time.Second)
defer lxcCancel()
// The BootstrapProxmox result gives us the host; we need to
// re-establish the SSH connection for the LXC provisioning.
lxcExecFn, lxcErr := proxmoxRemoteExecFn(result, sshKeyPath, joinSSHUser, joinSSHPort)
if lxcErr != nil {
fmt.Fprintf(cmd.OutOrStdout(), "Warning: could not establish SSH for LXC provisioning: %v\n", lxcErr)
} else {
if err := proxmox.ProvisionIngressLXC(lxcCtx, lxcExecFn, proxmox.FloatingIPOptions{
FloatingIP: effectiveFloatingIP,
Gateway: effectiveGateway,
MAC: effectiveMAC,
NetPrefix: netPrefix,
LXCTemplate: joinLXCTemplate,
}, lxcLog); err != nil {
fmt.Fprintf(cmd.OutOrStdout(), "Warning: ingress LXC provisioning failed: %v\n", err)
} else {
// Register the ingress LXC as a linux node.
ingressNode := &model.Node{
ID: uuid.NewString(),
Name: "ingress",
Address: fmt.Sprintf("%s:8443", effectiveFloatingIP),
State: model.NodeStateReady,
JoinedAt: time.Now().UTC(),
LastSeen: time.Now().UTC(),
Kind: string(model.NodeKindLinux),
OS: "linux",
IngressMode: "floating-ip",
}
if err := registry.Join(regCtx, ingressNode); err != nil {
fmt.Fprintf(cmd.OutOrStdout(), "Warning: register ingress node: %v\n", err)
}
if !jsonOutput {
fmt.Fprintf(cmd.OutOrStdout(), "✓ Ingress LXC joined: %s (%s) at %s\n", ingressNode.ID, ingressNode.Name, ingressNode.Address)
}
}
}
}
// REQ-156 / P07 T5: invalidate the nodes cache.
cacheInvalidate(cacheNodeClass)
if jsonOutput {
@@ -226,6 +361,46 @@ func joinProxmox(cmd *cobra.Command) error {
return nil
}
// proxmoxRemoteExecFn creates a RemoteExecFunc (func(string) ([]byte,
// error)) that runs commands on the PVE host via SSH. Used by the
// floating-IP LXC provisioning path (ProvisionIngressLXC).
func proxmoxRemoteExecFn(result *proxmox.Result, sshKeyPath, sshUser string, sshPort int) (func(string) ([]byte, error), error) {
cfg := &ssh.ClientConfig{
User: sshUser,
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 10 * time.Second,
}
if sshKeyPath != "" {
keyData, err := os.ReadFile(sshKeyPath)
if err != nil {
return nil, fmt.Errorf("read SSH key: %w", err)
}
signer, err := ssh.ParsePrivateKey(keyData)
if err != nil {
return nil, fmt.Errorf("parse SSH key: %w", err)
}
cfg.Auth = []ssh.AuthMethod{ssh.PublicKeys(signer)}
}
addr := result.NodeName
if sshPort != 22 {
addr = fmt.Sprintf("%s:%d", result.NodeName, sshPort)
} else {
addr = fmt.Sprintf("%s:%d", result.NodeName, sshPort)
}
client, err := ssh.Dial("tcp", addr, cfg)
if err != nil {
return nil, fmt.Errorf("ssh dial %s: %w", addr, err)
}
return func(cmd string) ([]byte, error) {
session, err := client.NewSession()
if err != nil {
return nil, err
}
defer session.Close()
return session.CombinedOutput(cmd)
}, nil
}
// joinLinux bootstraps a remote generic Linux worker via SSH and
// registers it as an orca node (REQ-161, P12). Uses SSH key auth
// (R-021: no passwords).
@@ -252,7 +427,7 @@ func joinLinux(cmd *cobra.Command) error {
SSHPort: joinSSHPort,
HostKeyFingerprint: joinHostKeyFP,
Logger: newLogger(),
})
})
if err != nil {
return fmt.Errorf("linux bootstrap: %w", err)
}
@@ -514,6 +689,11 @@ func init() {
nodeJoinCmd.Flags().StringVar(&proxmoxRole, "proxmox-role", "OrcaOperator", "PVE custom role to create (config-overridable)")
nodeJoinCmd.Flags().StringVar(&joinHostKeyFP, "host-key-fingerprint", "", "SSH host key SHA256:base64 fingerprint (pre-pin; supersedes TOFU for --type proxmox or --type linux)")
nodeJoinCmd.Flags().StringVar(&joinLXCTemplate, "lxc-template", "ubuntu-24.04", "LXC template for Proxmox (default ubuntu-24.04; alternatives: alpine-3.20, debian-12)")
nodeJoinCmd.Flags().StringVar(&ingressMode, "ingress-mode", "", "proxmox ingress mode: native (default, traefik in LXC) or floating-ip (ingress LXC owns floating IP)")
nodeJoinCmd.Flags().StringVar(&floatingIP, "floating-ip", "", "floating public IP for the ingress LXC (required for --ingress-mode floating-ip)")
nodeJoinCmd.Flags().StringVar(&gateway, "gateway", "", "gateway for the ingress LXC (required for --ingress-mode floating-ip)")
nodeJoinCmd.Flags().StringVar(&macAddr, "mac", "", "MAC address for the ingress LXC net0 (required for --ingress-mode floating-ip in --json mode; auto-generated in interactive mode)")
nodeJoinCmd.Flags().IntVar(&netPrefix, "net-prefix", 24, "network prefix (CIDR) for the ingress LXC IP (default 24; valid 8-32)")
nodeLeaveCmd.Flags().StringVar(&leaveID, "id", "", "node id")
nodeListCmd.Flags().BoolVar(&nodeWatch, "watch", false, "stream nodes until Ctrl-C (table refresh or --json per-event)")
+116 -6
View File
@@ -11,10 +11,14 @@ package cli
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
"git.cloudinit.dev/coreci/orca/internal/certpaths"
"git.cloudinit.dev/coreci/orca/internal/sshpush"
"git.cloudinit.dev/coreci/orca/internal/store"
)
@@ -72,8 +76,11 @@ var nodeCapacitySetCmd = &cobra.Command{
Short: "Declare capacity for a node (used by bin-packing)",
Long: "Write cpu_millicores, memory_mib, and disk_mib for the named node. Idempotent: subsequent calls overwrite.",
RunE: func(cmd *cobra.Command, args []string) error {
if capSetCPU <= 0 || capSetMem <= 0 || capSetDisk <= 0 {
return fmt.Errorf("--cpu, --memory, and --disk must all be positive")
// REQ-168: allow partial updates. At least one dimension
// must be positive; the others are read from the existing
// row (or default to 0 if no row exists yet).
if capSetCPU <= 0 && capSetMem <= 0 && capSetDisk <= 0 {
return fmt.Errorf("at least one of --cpu, --memory, or --disk must be positive")
}
id := capNodeID
if id == "" {
@@ -87,11 +94,27 @@ var nodeCapacitySetCmd = &cobra.Command{
}
defer closer()
repo := store.NewCapacityRepo(db)
// Read existing row for partial update.
existing, _ := repo.Get(ctx, id)
cpu := capSetCPU
mem := capSetMem
disk := capSetDisk
if existing != nil {
if cpu <= 0 {
cpu = existing.CPUMillicores
}
if mem <= 0 {
mem = existing.MemoryMiB
}
if disk <= 0 {
disk = existing.DiskMiB
}
}
c := &store.NodeCapacity{
NodeID: id,
CPUMillicores: capSetCPU,
MemoryMiB: capSetMem,
DiskMiB: capSetDisk,
CPUMillicores: cpu,
MemoryMiB: mem,
DiskMiB: disk,
}
if err := repo.Upsert(ctx, c); err != nil {
return err
@@ -137,6 +160,92 @@ var nodeCapacityListCmd = &cobra.Command{
},
}
// nodeCapacityAutoCmd discovers capacity by SSHing to the node and
// reading nproc, /proc/meminfo, df (REQ-168, Phase D2).
var capAutoPct int
var nodeCapacityAutoCmd = &cobra.Command{
Use: "auto [percentage]",
Short: "Auto-discover node capacity via SSH (default 75% of physical)",
Long: `SSH to the specified node and discover CPU cores, memory,
and disk capacity. Multiplies the physical values by the given
percentage (default 75) to reserve headroom for the OS. The discovered
values are written to the capacity table (same as 'orca node capacity set').`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
pct := 75
if len(args) > 0 {
var err error
pct, err = strconv.Atoi(args[0])
if err != nil || pct < 1 || pct > 100 {
return fmt.Errorf("percentage must be 1-100, got %q", args[0])
}
}
id := capNodeID
if id == "" {
return fmt.Errorf("--node is required for capacity auto")
}
// Build SSH transport and exec discovery commands.
transport := sshpush.NewTransport(certpaths.SSHKeyPath(), certpaths.KnownHostsPath())
defer transport.Close()
ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
defer cancel()
// CPU: nproc
cpuOut, err := transport.Exec(ctx, id, "nproc")
if err != nil {
return fmt.Errorf("capacity auto: SSH exec nproc on %s: %w", id, err)
}
cores, err := strconv.Atoi(strings.TrimSpace(string(cpuOut)))
if err != nil {
return fmt.Errorf("capacity auto: parse nproc output %q: %w", string(cpuOut), err)
}
// Memory: MemTotal from /proc/meminfo (in kB -> MiB)
memOut, err := transport.Exec(ctx, id, "awk '/MemTotal/{print $2}' /proc/meminfo")
if err != nil {
return fmt.Errorf("capacity auto: SSH exec meminfo on %s: %w", id, err)
}
memKB, err := strconv.ParseInt(strings.TrimSpace(string(memOut)), 10, 64)
if err != nil {
return fmt.Errorf("capacity auto: parse meminfo output %q: %w", string(memOut), err)
}
// Disk: df on root (1K-blocks -> MiB)
diskOut, err := transport.Exec(ctx, id, "df --output=size / | tail -1")
if err != nil {
return fmt.Errorf("capacity auto: SSH exec df on %s: %w", id, err)
}
diskKB, err := strconv.ParseInt(strings.TrimSpace(string(diskOut)), 10, 64)
if err != nil {
return fmt.Errorf("capacity auto: parse df output %q: %w", string(diskOut), err)
}
// Apply percentage, convert to millicores/MiB.
cpuM := int64(cores) * 1000 * int64(pct) / 100
memMib := memKB * int64(pct) / 100 / 1024
diskMib := diskKB * int64(pct) / 100 / 1024
// Write to DB.
db, closer, err := openDB()
if err != nil {
return err
}
defer closer()
repo := store.NewCapacityRepo(db)
c := &store.NodeCapacity{
NodeID: id,
CPUMillicores: cpuM,
MemoryMiB: memMib,
DiskMiB: diskMib,
}
if err := repo.Upsert(ctx, c); err != nil {
return err
}
if jsonOutput {
return printJSON(c)
}
fmt.Fprintf(cmd.OutOrStdout(), "Capacity auto-discovered for %s (%d%%): cpu=%dm, mem=%dMiB, disk=%dMiB\n", id, pct, cpuM, memMib, diskMib)
return nil
},
}
func init() {
nodeCapacitySetCmd.Flags().Int64Var(&capSetCPU, "cpu", 0, "CPU capacity in millicores (1000 = 1 vCPU)")
nodeCapacitySetCmd.Flags().Int64Var(&capSetMem, "memory", 0, "Memory capacity in MiB")
@@ -144,6 +253,7 @@ func init() {
nodeCapacitySetCmd.Flags().StringVar(&capNodeID, "node", "", "node id (defaults to 'self')")
nodeCapacityShowCmd.Flags().StringVar(&capNodeID, "node", "", "node id (defaults to 'self')")
nodeCapacityCmd.AddCommand(nodeCapacityShowCmd, nodeCapacitySetCmd, nodeCapacityListCmd)
nodeCapacityAutoCmd.Flags().StringVar(&capNodeID, "node", "", "node name or ID to auto-discover capacity for")
nodeCapacityCmd.AddCommand(nodeCapacityShowCmd, nodeCapacitySetCmd, nodeCapacityListCmd, nodeCapacityAutoCmd)
nodeCmd.AddCommand(nodeCapacityCmd)
}
+5 -3
View File
@@ -10,16 +10,18 @@ import (
"git.cloudinit.dev/coreci/orca/internal/store"
)
func TestNodeCapacitySetMissingArgs(t *testing.T) {
func TestNodeCapacitySetPartialUpdate(t *testing.T) {
_, cleanup := initTestEnv(t)
defer cleanup()
resetRootFlags(t)
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
// REQ-168: partial updates are now allowed. Setting only --cpu
// should succeed (memory/disk default to 0 or existing values).
rootCmd.SetArgs([]string{"node", "capacity", "set", "--cpu", "1000"})
if err := rootCmd.Execute(); err == nil {
t.Fatal("expected error for capacity set missing memory/disk, got nil")
if err := rootCmd.Execute(); err != nil {
t.Fatalf("expected success for partial capacity set, got: %v", err)
}
}
+9 -4
View File
@@ -1,11 +1,16 @@
package cli
import (
"context"
"git.cloudinit.dev/coreci/orca/internal/traefik"
)
var traefikVersion = traefik.DefaultVersion
func installTraefikLocal() error {
return traefik.InstallLocal(traefikVersion)
// ensureTraefikContainerLocal ensures the orca-traefik podman container
// is running on the local host (R-024). Replaces the v0.13
// installTraefikLocal binary+systemd installer.
func ensureTraefikContainerLocal() error {
ctx, cancel := context.WithTimeout(context.Background(), 120_000_000_000) // 2min for pull
defer cancel()
return traefik.EnsureTraefikContainerLocal(ctx, version)
}
+12 -11
View File
@@ -29,7 +29,7 @@ import (
var (
upgradeTo string
upgradeImportCA bool
upgradeForce bool
upgradeForce bool
upgradeDryRun bool
)
@@ -84,12 +84,12 @@ type cutoverFS interface {
// realCutoverFS is the production cutoverFS backed by the real os.
type realCutoverFS struct{}
func (realCutoverFS) ReadFile(path string) ([]byte, error) { return os.ReadFile(path) }
func (realCutoverFS) ReadFile(path string) ([]byte, error) { return os.ReadFile(path) }
func (realCutoverFS) WriteFile(path string, content []byte, mode os.FileMode) error {
return os.WriteFile(path, content, mode)
}
func (realCutoverFS) Rename(old, new string) error { return os.Rename(old, new) }
func (realCutoverFS) Remove(path string) error { return os.Remove(path) }
func (realCutoverFS) Rename(old, new string) error { return os.Rename(old, new) }
func (realCutoverFS) Remove(path string) error { return os.Remove(path) }
func (realCutoverFS) Stat(path string) (os.FileInfo, error) { return os.Stat(path) }
// cutoverFSOverride is the package-level test seam for the cutover
@@ -160,9 +160,9 @@ func acquireUpgradeLock() (func(), error) {
// UpgradeResult is the JSON-serializable summary of an upgrade run.
type UpgradeResult struct {
TargetVersion string `json:"target_version"`
CurrentVersion string `json:"current_version"`
DryRun bool `json:"dry_run"`
TargetVersion string `json:"target_version"`
CurrentVersion string `json:"current_version"`
DryRun bool `json:"dry_run"`
MigratedV08 bool `json:"migrated_v08"`
CutoverNeeded bool `json:"cutover_needed"`
CutoverOK bool `json:"cutover_ok,omitempty"`
@@ -379,10 +379,11 @@ func performCutover(ctx context.Context, runner commandRunner, out interface{ Wr
return false, fmt.Errorf("cutover: atomic rename %s → %s: %w", tmpPath, traefikYml, err)
}
if _, err := runner.Run(ctx, "systemctl", "restart", "traefik"); err != nil {
// Restart failed — restore from backup.
_ = cfs.Rename(backupPath, traefikYml)
return false, fmt.Errorf("cutover: restart traefik: %w", err)
if _, err := runner.Run(ctx, "bash", "-c", "systemctl stop orca-traefik.service 2>/dev/null; systemctl disable orca-traefik.service 2>/dev/null; rm -f /etc/systemd/system/orca-traefik.service /usr/local/bin/traefik; systemctl daemon-reload; true"); err != nil {
slog.Warn("cutover: legacy systemd unit removal failed (non-fatal if already removed)", "err", err)
}
if err := ensureTraefikContainerLocal(); err != nil {
slog.Warn("cutover: podman traefik container ensure failed", "err", err)
}
nftCmd := `nft add table inet orca_redirect; nft 'add chain inet orca_redirect prerouting { type nat hook prerouting priority -100; }'; nft add rule inet orca_redirect prerouting tcp dport 443 dnat to 127.0.0.1:8443`
if _, err := runner.Run(ctx, "bash", "-c", nftCmd); err != nil {
+63 -4
View File
@@ -53,6 +53,16 @@ type NftClusterConfig struct {
// RateBurst is the per-source burst (packets) for the meter.
// Defaults to 200.
RateBurst int
// DNATTarget is the destination IP for DNAT rules. Defaults to
// "127.0.0.1" (hybrid R-017 model — traefik on loopback). For
// Proxmox native mode where traefik runs inside an LXC, set this
// to the LXC's bridge IP so the PVE host DNATs to the LXC.
// Must be a valid IPv4 address (C-51: injection guard).
DNATTarget string
// EnableSNAT controls whether the postrouting masquerade chain
// is rendered. Defaults to true (R-024: SNAT/MASQUERADE for the
// DNAT return path). Set to false to omit the postrouting chain.
EnableSNAT bool
}
// withDefaults returns a copy of c with zero values replaced by the
@@ -68,6 +78,33 @@ func (c NftClusterConfig) withDefaults() NftClusterConfig {
if out.RateBurst <= 0 {
out.RateBurst = 200
}
if out.DNATTarget == "" {
out.DNATTarget = "127.0.0.1"
}
// EnableSNAT defaults to true — use a sentinel: if the field was
// not explicitly set (false) and DNATTarget is the default, enable
// it. This is a Go zero-value compromise; callers who want to
// disable SNAT must set it to false explicitly after construction.
// Actually, since we want SNAT on by default, we flip it here:
// the zero value is false, but we want true. So we always set true
// unless the caller explicitly set it to a non-zero sentinel.
// Simpler: treat EnableSNAT as "opt-out" — default true, set false
// to disable. Since Go zero-value is false, we invert: use
// DisableSNAT instead. But the plan says EnableSNAT. To keep the
// plan naming and have default-true, we check if it's the zero
// value and set true:
// NOTE: since bool zero value is false, we can't distinguish "not
// set" from "set to false". So we use a pointer or invert. The
// simplest fix: the field is "EnableSNAT" and defaults to true via
// this logic: if the caller didn't set DNATTarget (still ""),
// they used a zero-value config, so enable SNAT. If they set
// DNATTarget explicitly, they should also set EnableSNAT.
// For now: always enable SNAT unless the caller sets it to false
// AND sets a non-default DNATTarget. This is pragmatic:
if !out.EnableSNAT && out.DNATTarget == "127.0.0.1" {
// Zero-value config (both fields unset) → enable SNAT.
out.EnableSNAT = true
}
return out
}
@@ -94,6 +131,12 @@ func (NftEmitter) RenderNftConfig(clusterConfig NftClusterConfig) ([]File, error
if err != nil {
return nil, err
}
// C-51: validate DNATTarget as a valid IP before rendering. An
// unvalidated DNATTarget is an nft-syntax injection vector (same
// risk as TrustedProbes — the value is written raw into `dnat to`).
if net.ParseIP(cfg.DNATTarget) == nil {
return nil, fmt.Errorf("emitter/nft: DNATTarget %q is not a valid IP (C-51: ruleset injection guard)", cfg.DNATTarget)
}
content := renderNftRuleset(cfg, v4, v6)
return []File{{Path: nftConfigPath, Content: content, Mode: "0644"}}, nil
}
@@ -171,19 +214,35 @@ func renderNftRuleset(cfg NftClusterConfig, v4, v6 []string) string {
b.WriteString(" }\n")
b.WriteString("\t}\n\n")
// Research Topic 2: shift input/forward priority from `filter`
// (=0) to -10 to avoid same-priority undefined evaluation order
// with pve-firewall's iptables chains (also at priority 0). This
// ensures orca's SYN-flood filter runs deterministically before
// pve-firewall on Proxmox hosts.
b.WriteString("\tchain input {\n")
b.WriteString("\t\ttype filter hook input priority filter; policy accept;\n")
b.WriteString("\t\ttype filter hook input priority -10; policy accept;\n")
b.WriteString("\t\tct state invalid drop\n")
b.WriteString("\t\tct state established,related accept\n")
b.WriteString("\t\ttcp dport 443 tcp-flags != syn,rst,ack,fin notrack drop\n")
b.WriteString("\t}\n\n")
b.WriteString("\tchain prerouting {\n")
b.WriteString("\t\ttype nat hook prerouting priority -100; policy accept;\n")
b.WriteString("\t\ttcp dport 443 dnat to 127.0.0.1:8443\n")
b.WriteString("\t\ttcp dport 80 dnat to 127.0.0.1:8080\n")
b.WriteString(fmt.Sprintf("\t\ttcp dport 443 dnat to %s:8443\n", cfg.DNATTarget))
b.WriteString(fmt.Sprintf("\t\ttcp dport 80 dnat to %s:8080\n", cfg.DNATTarget))
b.WriteString("\t}\n\n")
// Research Topic 1: postrouting masquerade for the DNAT return
// path. Scoped to `ip saddr 127.0.0.0/8 oifname != "lo"` so only
// loopback-DNAT'd traffic is masqueraded (not all egress). This is
// the canonical "hairpin NAT" / "loopback DNAT return path" rule.
// Priority 100 = NF_IP_PRI_SRCNAT (standard srcnat priority).
if cfg.EnableSNAT {
b.WriteString("\tchain postrouting {\n")
b.WriteString("\t\ttype nat hook postrouting priority 100; policy accept;\n")
b.WriteString("\t\tip saddr 127.0.0.0/8 oifname != \"lo\" masquerade\n")
b.WriteString("\t}\n\n")
}
b.WriteString("\tchain forward {\n")
b.WriteString("\t\ttype filter hook forward priority filter; policy accept;\n")
b.WriteString("\t\ttype filter hook forward priority -10; policy accept;\n")
b.WriteString(fmt.Sprintf("\t\ttcp dport 443 ct state new meter { ora_rl { rate %d/second burst %d packets } } accept\n", cfg.RateLimit, cfg.RateBurst))
b.WriteString("\t}\n")
b.WriteString("}\n")
+90
View File
@@ -30,10 +30,13 @@ func TestNftEmitter_RenderBasic(t *testing.T) {
"127.0.0.1",
"::1",
"chain input",
"priority -10; policy accept;", // research Topic 2: pve-firewall coexistence
"tcp dport 443 tcp-flags != syn,rst,ack,fin notrack drop",
"chain prerouting",
"tcp dport 443 dnat to 127.0.0.1:8443",
"tcp dport 80 dnat to 127.0.0.1:8080",
"chain postrouting", // research Topic 1: SNAT masquerade
"ip saddr 127.0.0.0/8 oifname != \"lo\" masquerade", // scoped to loopback DNAT return
"chain forward",
"rate 100/second burst 200 packets",
"ora_rl",
@@ -135,3 +138,90 @@ func TestNftEmitter_TrustedProbesSplitV4V6(t *testing.T) {
t.Errorf("missing ::1 in v6 set:\n%s", c)
}
}
// TestNftEmitter_CustomDNATTarget verifies the DNATTarget field
// substitutes into the dnat rules (C-51, D-262 — proxmox native mode
// DNATs to the LXC bridge IP instead of 127.0.0.1).
func TestNftEmitter_CustomDNATTarget(t *testing.T) {
files, err := (NftEmitter{}).RenderNftConfig(NftClusterConfig{DNATTarget: "10.99.0.10"})
if err != nil {
t.Fatalf("Render: %v", err)
}
c := files[0].Content
if !strings.Contains(c, "dnat to 10.99.0.10:8443") {
t.Errorf("missing custom DNAT target :8443:\n%s", c)
}
if !strings.Contains(c, "dnat to 10.99.0.10:8080") {
t.Errorf("missing custom DNAT target :8080:\n%s", c)
}
if strings.Contains(c, "127.0.0.1:8443") {
t.Errorf("default 127.0.0.1:8443 should not be present when custom DNATTarget set:\n%s", c)
}
}
// TestNftEmitter_RejectsInvalidDNATTarget verifies that an invalid
// DNATTarget is rejected (C-51: nft-syntax injection guard).
func TestNftEmitter_RejectsInvalidDNATTarget(t *testing.T) {
bad := []string{
"not-an-ip",
"127.0.0.1; flush ruleset",
"$(whoami)",
"10.0.0.0/33",
"",
}
for _, b := range bad {
// Empty string gets defaulted to 127.0.0.1, so it won't error.
// Test only non-empty invalid values.
if b == "" {
continue
}
_, err := (NftEmitter{}).RenderNftConfig(NftClusterConfig{DNATTarget: b})
if err == nil {
t.Errorf("expected error for invalid DNATTarget %q, got nil", b)
}
}
}
// TestNftEmitter_DisableSNAT verifies that EnableSNAT=false omits the
// postrouting chain entirely.
func TestNftEmitter_DisableSNAT(t *testing.T) {
// To explicitly disable SNAT, set DNATTarget to a non-default
// value AND EnableSNAT to false. The withDefaults logic only
// auto-enables SNAT for the zero-value config.
files, err := (NftEmitter{}).RenderNftConfig(NftClusterConfig{
DNATTarget: "10.99.0.10",
EnableSNAT: false,
})
if err != nil {
t.Fatalf("Render: %v", err)
}
c := files[0].Content
if strings.Contains(c, "chain postrouting") {
t.Errorf("postrouting chain should be absent when EnableSNAT=false:\n%s", c)
}
if strings.Contains(c, "masquerade") {
t.Errorf("masquerade rule should be absent when EnableSNAT=false:\n%s", c)
}
}
// TestNftEmitter_PriorityMinus10 verifies the input and forward chains
// use priority -10 (research Topic 2: pve-firewall coexistence — avoids
// same-priority undefined evaluation order with pve-firewall's
// iptables chains at priority 0).
func TestNftEmitter_PriorityMinus10(t *testing.T) {
files, _ := (NftEmitter{}).RenderNftConfig(NftClusterConfig{})
c := files[0].Content
if !strings.Contains(c, "hook input priority -10;") {
t.Errorf("input chain should use priority -10:\n%s", c)
}
if !strings.Contains(c, "hook forward priority -10;") {
t.Errorf("forward chain should use priority -10:\n%s", c)
}
// Nat chains should stay at standard priorities.
if !strings.Contains(c, "hook prerouting priority -100;") {
t.Errorf("prerouting chain should use priority -100:\n%s", c)
}
if !strings.Contains(c, "hook postrouting priority 100;") {
t.Errorf("postrouting chain should use priority 100:\n%s", c)
}
}
+14 -13
View File
@@ -46,16 +46,21 @@ type TraefikEmitter struct{}
// /etc/traefik/dynamic/orca-<spec.Name>.yaml.
const traefikDynamicDir = "/etc/traefik/dynamic"
// traefikRouterTLSCertResolver is the Traefik cert-resolver name that
// the orca step-ca integration configures on the Traefik static config
// (P10 / v0.10 wires the step-ca root into this resolver). The
// dynamic-config file references it by name.
// traefikRouterTLSCertResolver is the Traefik cert-resolver name from
// v0.11. As of v0.14 (RESEARCH_v0.14 Topic 4), traefik v3.3 only
// supports acme/tailscale certResolvers — CA-file-based resolvers do
// not exist. The dynamic config now emits `tls: {}` instead. Real
// mTLS via dynamic tls.certificates + clientAuth.caFiles is deferred
// to v0.15. This constant is retained for documentation.
//
// Deprecated: v0.14 removed certResolver from the dynamic config.
const traefikRouterTLSCertResolver = "orca"
// defaultTrustDomain is the SPIFFE trust domain used in the rendered
// TLS stanza when the spec does not carry an explicit trust domain.
// The step-ca provisioner (P10) overrides this at render time via the
// node argument; for P02 the emitter renders the placeholder.
// defaultTrustDomain is the SPIFFE trust domain. v0.14 removed the
// TLS domains stanza from the dynamic config (replaced with tls: {}).
// Retained for documentation; will be used by v0.15 mTLS.
//
// Deprecated: v0.14 removed TLS domains from the dynamic config.
const defaultTrustDomain = "cluster.orca.local"
// Render renders the Traefik dynamic-config YAML for a Service
@@ -175,17 +180,13 @@ func renderTraefikYAMLWeighted(spec *jobspec.WorkloadSpec, node *Node, drain boo
routerName := "orca-" + spec.Name
serviceName := "orca-" + spec.Name
rule := fmt.Sprintf("PathPrefix(\"/%s\")", spec.Name)
trustDomain := defaultTrustDomain
b.WriteString("http:\n")
b.WriteString(" routers:\n")
b.WriteString(fmt.Sprintf(" %s:\n", routerName))
b.WriteString(fmt.Sprintf(" rule: %s\n", rule))
b.WriteString(fmt.Sprintf(" service: %s\n", serviceName))
b.WriteString(" tls:\n")
b.WriteString(fmt.Sprintf(" certResolver: %s\n", traefikRouterTLSCertResolver))
b.WriteString(" domains:\n")
b.WriteString(fmt.Sprintf(" - main: %q\n", trustDomain))
b.WriteString(" tls: {}\n")
b.WriteString(" services:\n")
b.WriteString(fmt.Sprintf(" %s:\n", serviceName))
b.WriteString(" loadBalancer:\n")
+2 -5
View File
@@ -53,11 +53,8 @@ func TestTraefikEmitter_RenderBasic(t *testing.T) {
if !strings.Contains(c, "unix:///run/orca/alloc-node-1/port-http.sock") {
t.Errorf("content missing socket server URL\n%s", c)
}
if !strings.Contains(c, "certResolver: orca") {
t.Errorf("content missing 'certResolver: orca'\n%s", c)
}
if !strings.Contains(c, "domains:") {
t.Errorf("content missing TLS domains\n%s", c)
if !strings.Contains(c, "tls: {}") {
t.Errorf("content missing 'tls: {}' (v0.14: certResolver dropped, tls empty stanza)\n%s", c)
}
if !strings.Contains(c, "healthCheck:") {
t.Errorf("content missing 'healthCheck:'\n%s", c)
+240
View File
@@ -0,0 +1,240 @@
// Package ingress implements the R-024 ingress bootstrap: nft DNAT
// + SNAT/MASQUERADE + traefik podman container + step-ca root CA on
// every orca-managed node. The bootstrap is idempotent and non-fatal
// on each step (offline host tolerance — same as v0.13 traefik install).
//
// BootstrapLocalIngress runs on the lead (during `orca init`).
// BootstrapRemoteIngress runs on workers (during `orca node join`).
package ingress
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"git.cloudinit.dev/coreci/orca/internal/certpaths"
"git.cloudinit.dev/coreci/orca/internal/emitter"
"git.cloudinit.dev/coreci/orca/internal/traefik"
)
// BootstrapLocalIngress ensures the complete ingress stack is running
// on the local host (lead node). It is called from `orca init` after
// EnsureTraefikContainerLocal.
//
// Steps (each non-fatal — logs a warning and continues):
// 1. mkdir -p /etc/traefik/dynamic /etc/orca
// 2. Push cluster root CA to /etc/orca/step-ca-root.crt (C-60:
// certpaths.CACertPath(), not CAPath)
// 3. Render static config via emitter.RenderTraefikStaticConfig to
// /etc/traefik/traefik.yml (preserves traefik-on-public-ip opt-out,
// C-58)
// 4. Render orca.nft via emitter.NftEmitter.RenderNftConfig + write
// to /etc/nftables.d/orca.nft
// 5. Pre-create nft table (C-55: avoids flush-table error on first
// apply)
// 6. Apply: nft -f /etc/nftables.d/orca.nft
func BootstrapLocalIngress(ctx context.Context, version string) error {
var errs []error
log := slog.Default()
// Step 1: ensure directories.
for _, dir := range []string{"/etc/traefik/dynamic", "/etc/orca"} {
if err := os.MkdirAll(dir, 0o755); err != nil {
log.Warn("ingress: mkdir failed", "dir", dir, "err", err)
errs = append(errs, fmt.Errorf("mkdir %s: %w", dir, err))
}
}
// Step 2: push cluster root CA (C-60: CACertPath, not CAPath).
caPath := certpaths.CACertPath()
if caData, err := os.ReadFile(caPath); err == nil {
if err := os.WriteFile("/etc/orca/step-ca-root.crt", caData, 0o644); err != nil {
log.Warn("ingress: step-ca root CA write failed", "err", err)
errs = append(errs, fmt.Errorf("write step-ca-root.crt: %w", err))
}
} else {
// CA may not exist yet (fresh init before step-ca). Write a
// placeholder so the podman volume mount doesn't fail.
_ = os.WriteFile("/etc/orca/step-ca-root.crt", []byte{}, 0o644)
log.Warn("ingress: step-ca root CA not found, wrote placeholder", "path", caPath)
}
// Step 3: render + write static config (C-58).
staticFiles, err := emitter.TraefikEmitter{}.RenderTraefikStaticConfig(emitter.TraefikStaticOpts{})
if err != nil {
log.Warn("ingress: render traefik static config failed", "err", err)
errs = append(errs, fmt.Errorf("render traefik static: %w", err))
} else {
for _, f := range staticFiles {
if err := os.WriteFile(f.Path, []byte(f.Content), 0o644); err != nil {
log.Warn("ingress: write traefik static config failed", "path", f.Path, "err", err)
errs = append(errs, fmt.Errorf("write %s: %w", f.Path, err))
}
}
}
// Step 4: render + write nft ruleset.
nftFiles, err := emitter.NftEmitter{}.RenderNftConfig(emitter.NftClusterConfig{})
if err != nil {
log.Warn("ingress: render nft config failed", "err", err)
errs = append(errs, fmt.Errorf("render nft: %w", err))
} else {
for _, f := range nftFiles {
dir := filepath.Dir(f.Path)
_ = os.MkdirAll(dir, 0o755)
if err := os.WriteFile(f.Path, []byte(f.Content), 0o644); err != nil {
log.Warn("ingress: write nft config failed", "path", f.Path, "err", err)
errs = append(errs, fmt.Errorf("write %s: %w", f.Path, err))
}
}
// Step 5: pre-create nft table (C-55: flush table on non-existent
// table errors — pre-create avoids the first-apply failure).
_ = exec.CommandContext(ctx, "nft", "add", "table", "inet", "orca-ingress").Run()
// Step 6: apply nft ruleset.
if out, err := exec.CommandContext(ctx, "nft", "-f", nftConfigPath).CombinedOutput(); err != nil {
log.Warn("ingress: nft apply failed", "err", err, "output", string(out))
errs = append(errs, fmt.Errorf("nft -f: %w (output: %s)", err, string(out)))
}
}
// Ensure the podman traefik container is running (Step 7 — C-50:
// installs podman if absent).
if err := traefik.EnsureTraefikContainerLocal(ctx, version); err != nil {
log.Warn("ingress: ensure traefik container failed", "err", err)
errs = append(errs, fmt.Errorf("ensure traefik container: %w", err))
}
if len(errs) > 0 {
return fmt.Errorf("ingress bootstrap: %d errors (first: %w)", len(errs), errs[0])
}
return nil
}
// nftConfigPath mirrors the emitter constant.
const nftConfigPath = "/etc/nftables.d/orca.nft"
// RemoteExecFunc runs a command on a remote host and returns combined
// output. Same signature as traefik.RemoteExecFunc.
type RemoteExecFunc func(cmd string) ([]byte, error)
// BootstrapRemoteIngress ensures the complete ingress stack is running
// on a remote host (linux worker). It is called from
// `orca node join --type linux` after the user setup.
//
// Steps (each non-fatal — logs a warning and continues):
// 1. mkdir -p /etc/traefik/dynamic /etc/orca (remote)
// 2. Push step-ca root CA to remote /etc/orca/step-ca-root.crt
// (C-60: certpaths.CACertPath)
// 3. Render + write static config to remote /etc/traefik/traefik.yml
// (C-58: preserves traefik-on-public-ip opt-out)
// 4. Render + write nft ruleset to remote /etc/nftables.d/orca.nft
// 5. Pre-create nft table (C-55: avoids first-apply flush-table error)
// 6. Apply: nft -f (remote)
// 7. Ensure podman traefik container running (remote)
func BootstrapRemoteIngress(ctx context.Context, version string, execFn RemoteExecFunc) error {
var errs []error
log := slog.Default()
// Step 1: ensure directories.
if _, err := execFn("mkdir -p /etc/traefik/dynamic /etc/orca"); err != nil {
log.Warn("ingress: remote mkdir failed", "err", err)
errs = append(errs, fmt.Errorf("remote mkdir: %w", err))
}
// Step 2: push step-ca root CA (C-60: CACertPath, not CAPath).
if caData, err := os.ReadFile(certpaths.CACertPath()); err == nil {
if err := remoteWriteFile(execFn, "/etc/orca/step-ca-root.crt", caData, "0644"); err != nil {
log.Warn("ingress: remote step-ca CA write failed", "err", err)
errs = append(errs, fmt.Errorf("remote write step-ca-root.crt: %w", err))
}
} else {
// Write a placeholder so the podman volume mount doesn't fail.
_ = remoteWriteFile(execFn, "/etc/orca/step-ca-root.crt", []byte{}, "0644")
log.Warn("ingress: step-ca root CA not found locally, wrote remote placeholder")
}
// Step 3: render + write static config (C-58).
staticFiles, err := emitter.TraefikEmitter{}.RenderTraefikStaticConfig(emitter.TraefikStaticOpts{})
if err != nil {
log.Warn("ingress: render traefik static config failed", "err", err)
errs = append(errs, fmt.Errorf("render traefik static: %w", err))
} else {
for _, f := range staticFiles {
_, _ = execFn(fmt.Sprintf("mkdir -p %s", filepath.Dir(f.Path)))
if err := remoteWriteFile(execFn, f.Path, []byte(f.Content), f.Mode); err != nil {
log.Warn("ingress: remote write traefik static config failed", "path", f.Path, "err", err)
errs = append(errs, fmt.Errorf("remote write %s: %w", f.Path, err))
}
}
}
// Step 4: render + write nft ruleset.
nftFiles, err := emitter.NftEmitter{}.RenderNftConfig(emitter.NftClusterConfig{})
if err != nil {
log.Warn("ingress: render nft config failed", "err", err)
errs = append(errs, fmt.Errorf("render nft: %w", err))
} else {
for _, f := range nftFiles {
_, _ = execFn(fmt.Sprintf("mkdir -p %s", filepath.Dir(f.Path)))
if err := remoteWriteFile(execFn, f.Path, []byte(f.Content), f.Mode); err != nil {
log.Warn("ingress: remote write nft config failed", "path", f.Path, "err", err)
errs = append(errs, fmt.Errorf("remote write %s: %w", f.Path, err))
}
}
// Step 5: pre-create nft table (C-55).
_, _ = execFn("nft add table inet orca-ingress 2>/dev/null || true")
// Step 6: apply nft ruleset.
if out, err := execFn("nft -f /etc/nftables.d/orca.nft 2>&1"); err != nil {
log.Warn("ingress: remote nft apply failed", "err", err, "output", string(out))
errs = append(errs, fmt.Errorf("remote nft -f: %w (output: %s)", err, string(out)))
}
}
// Step 7: ensure podman traefik container (C-50).
traefikExecFn := traefik.RemoteExecFunc(execFn)
if err := traefik.EnsureTraefikContainerRemote(ctx, version, traefikExecFn); err != nil {
log.Warn("ingress: remote ensure traefik container failed", "err", err)
errs = append(errs, fmt.Errorf("remote ensure traefik container: %w", err))
}
if len(errs) > 0 {
return fmt.Errorf("remote ingress bootstrap: %d errors (first: %w)", len(errs), errs[0])
}
return nil
}
// remoteWriteFile writes content to a remote path via a heredoc
// (same pattern as sshpush.idempotency.writeFile). The heredoc
// delimiter is a random hex string verified absent from the content
// (F9 injection guard).
func remoteWriteFile(execFn RemoteExecFunc, path string, content []byte, mode string) error {
// Generate a random delimiter unlikely to be in the content.
delim := "EOF_"
for {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
return fmt.Errorf("rand: %w", err)
}
delim = "EOF_" + hex.EncodeToString(b)
if !strings.Contains(string(content), delim) {
break
}
}
dir := filepath.Dir(path)
cmd := fmt.Sprintf("mkdir -p %s && cat > %s <<'%s'\n%s\n%s\nchmod %s %s",
dir, path, delim, string(content), delim, mode, path)
if out, err := execFn(cmd); err != nil {
return fmt.Errorf("remote write %s: %w (output: %s)", path, err, string(out))
}
return nil
}
+17 -14
View File
@@ -32,9 +32,9 @@ import (
"golang.org/x/crypto/ssh"
"git.cloudinit.dev/coreci/orca/internal/certpaths"
"git.cloudinit.dev/coreci/orca/internal/ingress"
"git.cloudinit.dev/coreci/orca/internal/proxmox"
"git.cloudinit.dev/coreci/orca/internal/security"
"git.cloudinit.dev/coreci/orca/internal/traefik"
)
// DefaultSSHUser is the default SSH username for the initial connection.
@@ -48,13 +48,13 @@ const DefaultSSHPort = 22
// Options configures a Linux worker bootstrap run.
type Options struct {
Host string
SSHUser string
SSHKeyPath string
OrcaUser string
SSHPort int
HostKeyFingerprint string
Logger *slog.Logger
Host string
SSHUser string
SSHKeyPath string
OrcaUser string
SSHPort int
HostKeyFingerprint string
Logger *slog.Logger
}
// Result is the outcome of a successful bootstrap.
@@ -157,8 +157,10 @@ func BootstrapLinux(ctx context.Context, opts Options) (*Result, error) {
}
opts.Logger.Info("linux bootstrap: user created", "user", opts.OrcaUser)
// Step 4d: Install Traefik on the remote host (REQ-165, Phase B).
// Traefik is the data-plane ingress; SSH is control plane only.
// Step 4d: Ensure complete ingress stack on the remote host (R-024).
// Renders+applies nft DNAT/SNAT, pushes step-ca root CA, renders+
// writes traefik static config, ensures podman container running.
// All non-fatal (offline host tolerance).
sshExecFn := func(cmd string) ([]byte, error) {
session, err := client.NewSession()
if err != nil {
@@ -167,8 +169,10 @@ func BootstrapLinux(ctx context.Context, opts Options) (*Result, error) {
defer session.Close()
return session.CombinedOutput(cmd)
}
if err := traefik.InstallRemote("", sshExecFn); err != nil {
opts.Logger.Warn("linux bootstrap: traefik install failed", "err", err)
ctx, cancelIngress := context.WithTimeout(ctx, 120*time.Second)
defer cancelIngress()
if err := ingress.BootstrapRemoteIngress(ctx, "", ingress.RemoteExecFunc(sshExecFn)); err != nil {
opts.Logger.Warn("linux bootstrap: ingress bootstrap failed", "err", err)
}
// Step 5: Create the drift-events directory.
@@ -188,7 +192,7 @@ func BootstrapLinux(ctx context.Context, opts Options) (*Result, error) {
return &Result{
NodeName: opts.Host,
NodeAddress: fmt.Sprintf("%s:8443", opts.Host),
NodeAddress: fmt.Sprintf("%s:8443", opts.Host),
HostKeyFingerprint: hostKeyFP,
}, nil
}
@@ -226,5 +230,4 @@ func pinnedHostKeyCallback(expectedSHA256Base64 string, capturedKey *ssh.PublicK
return cb, nil
}
var _ = security.WriteAtomic
+5
View File
@@ -39,4 +39,9 @@ type Node struct {
// OS is the auto-detected OS identifier from /etc/os-release ID=
// (ubuntu|debian|alpine|pve|linux). Empty for pre-0006 rows.
OS string `json:"os,omitempty"`
// IngressMode is the ingress configuration for the node (R-024,
// v0.14). Values: "" (legacy/default for linux/localhost),
// "native" (proxmox native — traefik in LXC), "floating-ip"
// (proxmox floating-IP — ingress LXC owns the floating IP).
IngressMode string `json:"ingress_mode,omitempty"`
}
+141 -16
View File
@@ -37,6 +37,7 @@ import (
"golang.org/x/crypto/ssh/knownhosts"
"git.cloudinit.dev/coreci/orca/internal/certpaths"
"git.cloudinit.dev/coreci/orca/internal/emitter"
"git.cloudinit.dev/coreci/orca/internal/security"
"git.cloudinit.dev/coreci/orca/internal/traefik"
)
@@ -86,6 +87,13 @@ type Options struct {
// LXCTemplate is the LXC template to download during bootstrap
// (default "ubuntu-24.04"; alternatives: "alpine-3.20", "debian-12").
LXCTemplate string
// IngressMode is the proxmox ingress mode (R-024, v0.14).
// "native" (default): traefik runs in an unprivileged LXC with
// nesting=1,keyctl=1,fuse=1 on the PVE host. nft on the PVE host
// DNATs to the LXC bridge IP.
// "floating-ip": a separate ingress LXC owns the floating IP;
// nft runs inside that LXC. See ProvisionIngressLXC (P6).
IngressMode string
}
// Result is the outcome of a successful bootstrap.
@@ -247,21 +255,24 @@ func BootstrapProxmox(ctx context.Context, opts Options) (*Result, error) {
return nil, fmt.Errorf("validate sudoers: %w", err)
}
// Step 9a: Install Traefik on the Proxmox host (REQ-165, Phase B).
// Traefik runs on the PVE OS as the data-plane ingress; SSH is
// control plane only. Idempotent: skips if binary already exists.
if err := traefik.InstallRemote("", runRemote); err != nil {
log.Warn("proxmox.traefik_install_failed", "err", err)
}
// Step 9b: Download default LXC template (REQ-167, Phase C).
// Default: ubuntu-24.04. Configurable via --lxc-template.
// Step 9a: Proxmox native ingress mode (R-024, REQ-175).
// Create an unprivileged LXC with nesting=1,keyctl=1,fuse=1 (research
// Topic 3), install podman inside it, and run the orca-traefik
// container. nft on the PVE host DNATs to the LXC bridge IP.
// Default mode is "native"; floating-ip mode is handled separately
// (P6 — ProvisionIngressLXC).
template := opts.LXCTemplate
if template == "" {
template = "ubuntu-24.04"
}
_, _ = runRemote(fmt.Sprintf("pveam download local %s 2>/dev/null || true", shellQuote(template)))
if opts.IngressMode != "floating-ip" {
if err := provisionNativeIngressLXC(ctx, runRemote, template, log); err != nil {
log.Warn("proxmox.native_ingress_lxc_failed", "err", err)
}
}
log.Info("proxmox.bootstrap_ok",
slog.String("event", "proxmox.bootstrap_ok"),
slog.String("host", opts.Host),
@@ -276,6 +287,119 @@ func BootstrapProxmox(ctx context.Context, opts Options) (*Result, error) {
}, nil
}
// provisionNativeIngressLXC creates an unprivileged LXC with
// nesting=1,keyctl=1,fuse=1 (research Topic 3), installs podman inside
// it, runs the orca-traefik container, and applies nft DNAT on the PVE
// host targeting the LXC's bridge IP (R-024, REQ-175).
//
// The LXC is named "orca-traefik" and uses a deterministic VMID derived
// from the host. It is idempotent: if the LXC already exists, it is
// not re-created (C-53: apt-get install is skipped if podman present).
func provisionNativeIngressLXC(ctx context.Context, runRemote func(string) ([]byte, error), template string, log *slog.Logger) error {
// Deterministic VMID for the native ingress LXC.
// Use a fixed VMID in the 200-299 range (Proxmox convention for CTs).
const vmid = "200"
const lxcName = "orca-traefik"
// Check if the LXC already exists.
existOut, _ := runRemote(fmt.Sprintf("pct status %s 2>/dev/null || echo absent", vmid))
existStr := strings.TrimSpace(string(existOut))
if existStr == "absent" {
// Create the LXC (research Topic 3: nesting=1,keyctl=1,fuse=1).
log.Info("proxmox.creating_native_ingress_lxc", "vmid", vmid, "name", lxcName)
createCmd := fmt.Sprintf(
"pct create %s local:vztmpl/%s --hostname %s --unprivileged 1 --features nesting=1,keyctl=1,fuse=1 --onboot 1 --memory 2048 --swap 0 --rootfs local:8 2>&1",
vmid, shellQuote(template), lxcName,
)
if out, err := runRemote(createCmd); err != nil {
return fmt.Errorf("pct create native ingress LXC: %w (output: %s)", err, string(out))
}
if out, err := runRemote(fmt.Sprintf("pct start %s", vmid)); err != nil {
return fmt.Errorf("pct start native ingress LXC: %w (output: %s)", err, string(out))
}
}
// Wait for LXC network (retry for up to 60s).
lxcIP := ""
for i := 0; i < 12; i++ {
ipOut, _ := runRemote(fmt.Sprintf("pct exec %s -- hostname -I 2>/dev/null", vmid))
ipStr := strings.TrimSpace(string(ipOut))
if ipStr != "" {
fields := strings.Fields(ipStr)
if len(fields) > 0 {
lxcIP = fields[0]
break
}
}
time.Sleep(5 * time.Second)
}
if lxcIP == "" {
return fmt.Errorf("native ingress LXC: could not discover IP after 60s")
}
log.Info("proxmox.native_ingress_lxc_ip", "vmid", vmid, "ip", lxcIP)
// Install podman inside the LXC (C-53: idempotent — check first).
_, _ = runRemote(fmt.Sprintf(
"pct exec %s -- bash -c 'command -v podman >/dev/null 2>&1 || (apt-get update -qq && apt-get install -y -qq podman conmon crun fuse-overlayfs nftables 2>&1)' 2>&1",
vmid,
))
// Enable podman-restart.service inside the LXC (research Topic 6).
_, _ = runRemote(fmt.Sprintf("pct exec %s -- systemctl enable --now podman-restart.service 2>/dev/null", vmid))
// Push step-ca root CA into the LXC (placeholder if absent locally).
caPath := certpaths.CACertPath()
caData, caErr := os.ReadFile(caPath)
if caErr != nil {
caData = []byte{}
}
// Write CA via pct exec heredoc.
caDelim := "EOF_CA"
_, _ = runRemote(fmt.Sprintf(
"pct exec %s -- bash -c 'mkdir -p /etc/orca && cat > /etc/orca/step-ca-root.crt <<%s\\n%s\\n%s'",
vmid, caDelim, string(caData), caDelim,
))
// Render + write traefik static config inside the LXC.
staticFiles, err := emitter.TraefikEmitter{}.RenderTraefikStaticConfig(emitter.TraefikStaticOpts{})
if err == nil {
for _, f := range staticFiles {
delim := "EOF_TF"
_, _ = runRemote(fmt.Sprintf(
"pct exec %s -- bash -c 'mkdir -p /etc/traefik/dynamic && cat > %s <<%s\\n%s\\n%s'",
vmid, f.Path, delim, f.Content, delim,
))
}
}
// Ensure podman orca-traefik container inside the LXC.
traefikExecFn := func(cmd string) ([]byte, error) {
return runRemote(fmt.Sprintf("pct exec %s -- bash -c %s 2>&1", vmid, shellQuote(cmd)))
}
if err := traefik.EnsureTraefikContainerRemote(ctx, "", traefikExecFn); err != nil {
log.Warn("proxmox.native_ingress_lxc_traefik_failed", "err", err)
}
// Render + apply nft on the PVE host with DNATTarget = LXC IP.
nftFiles, err := emitter.NftEmitter{}.RenderNftConfig(emitter.NftClusterConfig{
DNATTarget: lxcIP,
})
if err == nil {
for _, f := range nftFiles {
nftDelim := "EOF_NF"
_, _ = runRemote(fmt.Sprintf("mkdir -p /etc/nftables.d && cat > %s <<%s\\n%s\\n%s",
f.Path, nftDelim, f.Content, nftDelim))
}
_, _ = runRemote("nft add table inet orca-ingress 2>/dev/null || true")
if out, err := runRemote("nft -f /etc/nftables.d/orca.nft 2>&1"); err != nil {
log.Warn("proxmox.native_ingress_nft_apply_failed", "err", err, "output", string(out))
}
}
log.Info("proxmox.native_ingress_lxc_ok", "vmid", vmid, "ip", lxcIP)
return nil
}
// sshDialer is the dialer used by BootstrapProxmox. It's a package-level
// variable so tests can override it with a fake SSH server.
var sshDialer sshDialerType = defaultSSHDialer{}
@@ -467,10 +591,12 @@ func createLinuxUser(user string) error {
// createPVERole creates the OrcaOperator PVE role if it doesn't exist.
// Idempotent: probes `pveum role list` before `pveum role add`.
func createPVERole(role string) error {
// F10c: shellQuote the role (validated upstream, but defense-in-depth).
// Idempotent: check if the role already exists before creating.
// Use pveum role list with grep -qF (fixed string, not regex) to
// avoid shell-quoting issues with single quotes inside the pattern.
cmd := fmt.Sprintf(
"pveum role list 2>/dev/null | grep -q '^%s' || pveum role add %s --privs '%s'",
shellQuote(role), shellQuote(role), OrcaOperatorPrivileges,
"pveum role list 2>/dev/null | grep -qF %s && exit 0 || pveum role add %s --privs '%s' 2>/dev/null || pveum role mod %s --privs '%s'",
shellQuote(role), shellQuote(role), OrcaOperatorPrivileges, shellQuote(role), OrcaOperatorPrivileges,
)
if _, err := runRemote(cmd); err != nil {
return err
@@ -483,11 +609,10 @@ func createPVERole(role string) error {
// Uses @pam realm (AD-019) since orca creates a Linux system user.
func createPVEUser(user string) error {
pveUserID := user + "@pam"
// F10c: shellQuote the PVE user id (validated upstream, but
// defense-in-depth).
// Idempotent: check if user exists, create if not, update comment if exists.
cmd := fmt.Sprintf(
"pveum user list 2>/dev/null | grep -q %s || pveum user add %s -comment 'Orca automation user'",
shellQuote(pveUserID), shellQuote(pveUserID),
"pveum user list 2>/dev/null | grep -qF %s && exit 0 || pveum user add %s -comment 'Orca automation user' 2>/dev/null || pveum user mod %s -comment 'Orca automation user'",
shellQuote(pveUserID), shellQuote(pveUserID), shellQuote(pveUserID),
)
if _, err := runRemote(cmd); err != nil {
return err
+171
View File
@@ -0,0 +1,171 @@
package proxmox
import (
"context"
"crypto/rand"
"fmt"
"log/slog"
"os"
"strings"
"time"
"git.cloudinit.dev/coreci/orca/internal/certpaths"
"git.cloudinit.dev/coreci/orca/internal/emitter"
"git.cloudinit.dev/coreci/orca/internal/traefik"
)
// FloatingIPOptions carries the parameters for provisioning a
// floating-IP ingress LXC (R-024, REQ-176).
type FloatingIPOptions struct {
// FloatingIP is the public IP assigned to the LXC's eth0.
FloatingIP string
// Gateway is the default gateway for the LXC.
Gateway string
// MAC is the MAC address for the LXC's net0 interface.
MAC string
// NetPrefix is the CIDR prefix for the floating IP (8-32).
NetPrefix int
// LXCTemplate is the LXC template (default "ubuntu-24.04").
LXCTemplate string
// VMID is the LXC container ID (default "201" for the ingress LXC).
VMID string
}
// ProvisionIngressLXC creates an Ubuntu LXC named "ingress" that owns
// the floating IP, installs podman + orca-traefik inside it, applies nft
// DNAT+SNAT inside the LXC, and returns the LXC's IP for node
// registration (R-024, REQ-176).
//
// The LXC is created with:
//
// --unprivileged 1 --features nesting=1,keyctl=1,fuse=1
// --net0 name=eth0,bridge=vmbr0,hwaddr=<mac>,ip=<floating-ip>/<prefix>,gw=<gateway>
// --onboot 1
//
// Inside the LXC, the complete ingress stack is set up: podman
// installed, traefik static config written, nft DNAT:443→127.0.0.1:8443
// + postrouting masquerade applied, orca-traefik podman container
// running with --network host.
//
// Idempotent: if the LXC already exists, it is not re-created (C-53).
func ProvisionIngressLXC(ctx context.Context, runRemote func(string) ([]byte, error), opts FloatingIPOptions, log *slog.Logger) error {
template := opts.LXCTemplate
if template == "" {
template = "ubuntu-24.04"
}
vmid := opts.VMID
if vmid == "" {
vmid = "201"
}
if opts.NetPrefix == 0 {
opts.NetPrefix = 24
}
// Validate required fields.
if opts.FloatingIP == "" || opts.Gateway == "" || opts.MAC == "" {
return fmt.Errorf("ingress_lxc: floating-ip, gateway, and mac are required")
}
// Ensure the template is downloaded.
_, _ = runRemote(fmt.Sprintf("pveam download local %s 2>/dev/null || true", shellQuote(template)))
// Check if the LXC already exists (idempotent — C-53).
existOut, _ := runRemote(fmt.Sprintf("pct status %s 2>/dev/null || echo absent", vmid))
existStr := strings.TrimSpace(string(existOut))
if existStr == "absent" {
log.Info("ingress_lxc.creating", "vmid", vmid, "hostname", "ingress", "ip", opts.FloatingIP)
net0 := fmt.Sprintf("name=eth0,bridge=vmbr0,hwaddr=%s,ip=%s/%d,gw=%s",
opts.MAC, opts.FloatingIP, opts.NetPrefix, opts.Gateway)
createCmd := fmt.Sprintf(
"pct create %s local:vztmpl/%s --hostname ingress --unprivileged 1 --features nesting=1,keyctl=1,fuse=1 --net0 %s --onboot 1 --memory 2048 --swap 0 --rootfs local:8 2>&1",
vmid, shellQuote(template), net0,
)
if out, err := runRemote(createCmd); err != nil {
return fmt.Errorf("pct create ingress LXC: %w (output: %s)", err, string(out))
}
if out, err := runRemote(fmt.Sprintf("pct start %s", vmid)); err != nil {
return fmt.Errorf("pct start ingress LXC: %w (output: %s)", err, string(out))
}
}
// Wait for LXC network (retry for up to 60s).
for i := 0; i < 12; i++ {
ipOut, _ := runRemote(fmt.Sprintf("pct exec %s -- hostname -I 2>/dev/null", vmid))
ipStr := strings.TrimSpace(string(ipOut))
if ipStr != "" {
break
}
time.Sleep(5 * time.Second)
}
log.Info("ingress_lxc.network_ready", "vmid", vmid, "ip", opts.FloatingIP)
// Install podman inside the LXC (C-53: idempotent).
_, _ = runRemote(fmt.Sprintf(
"pct exec %s -- bash -c 'command -v podman >/dev/null 2>&1 || (apt-get update -qq && apt-get install -y -qq podman conmon crun fuse-overlayfs nftables 2>&1)' 2>&1",
vmid,
))
// Enable podman-restart.service inside the LXC (research Topic 6).
_, _ = runRemote(fmt.Sprintf("pct exec %s -- systemctl enable --now podman-restart.service 2>/dev/null", vmid))
// Push step-ca root CA into the LXC (C-60: CACertPath).
caPath := certpaths.CACertPath()
caData, caErr := os.ReadFile(caPath)
if caErr != nil {
caData = []byte{}
}
caDelim := "EOF_CA"
_, _ = runRemote(fmt.Sprintf(
"pct exec %s -- bash -c 'mkdir -p /etc/orca && cat > /etc/orca/step-ca-root.crt <<%s\\n%s\\n%s'",
vmid, caDelim, string(caData), caDelim,
))
// Render + write traefik static config inside the LXC (C-58).
staticFiles, err := emitter.TraefikEmitter{}.RenderTraefikStaticConfig(emitter.TraefikStaticOpts{})
if err == nil {
for _, f := range staticFiles {
delim := "EOF_TF"
_, _ = runRemote(fmt.Sprintf(
"pct exec %s -- bash -c 'mkdir -p /etc/traefik/dynamic && cat > %s <<%s\\n%s\\n%s'",
vmid, f.Path, delim, f.Content, delim,
))
}
}
// Render + apply nft DNAT+SNAT INSIDE the LXC (DNATTarget =
// 127.0.0.1 — traefik runs with --network host inside the LXC).
nftFiles, err := emitter.NftEmitter{}.RenderNftConfig(emitter.NftClusterConfig{})
if err == nil {
for _, f := range nftFiles {
delim := "EOF_NF"
_, _ = runRemote(fmt.Sprintf(
"pct exec %s -- bash -c 'mkdir -p /etc/nftables.d && cat > %s <<%s\\n%s\\n%s'",
vmid, f.Path, delim, f.Content, delim,
))
}
_, _ = runRemote(fmt.Sprintf("pct exec %s -- nft add table inet orca-ingress 2>/dev/null || true", vmid))
_, _ = runRemote(fmt.Sprintf("pct exec %s -- nft -f /etc/nftables.d/orca.nft 2>&1", vmid))
}
// Ensure podman orca-traefik container inside the LXC.
traefikExecFn := func(cmd string) ([]byte, error) {
return runRemote(fmt.Sprintf("pct exec %s -- bash -c %s 2>&1", vmid, shellQuote(cmd)))
}
if err := traefik.EnsureTraefikContainerRemote(ctx, "", traefikExecFn); err != nil {
log.Warn("ingress_lxc.traefik_failed", "err", err)
}
log.Info("ingress_lxc.provisioned", "vmid", vmid, "ip", opts.FloatingIP)
return nil
}
// GenerateRandomMAC generates a random locally-administered MAC address
// (02:XX:XX:XX:XX:XX) for use as the LXC net0 hardware address when the
// operator does not provide one (D-261).
func GenerateRandomMAC() (string, error) {
b := make([]byte, 5)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generate MAC: %w", err)
}
return fmt.Sprintf("02:%02x:%02x:%02x:%02x:%02x", b[0], b[1], b[2], b[3], b[4]), nil
}
@@ -0,0 +1,6 @@
-- REQ-175 / R-024: add ingress_mode column to nodes.
-- Values: '' (legacy/default), 'native' (proxmox native — traefik
-- in LXC), 'floating-ip' (proxmox floating-IP — ingress LXC owns
-- the floating IP). Defaults to empty string for backward
-- compatibility with pre-v0.14 nodes.
ALTER TABLE nodes ADD COLUMN ingress_mode TEXT NOT NULL DEFAULT '';
+202 -52
View File
@@ -1,78 +1,228 @@
package traefik
import (
"context"
"fmt"
"os/exec"
"strings"
)
// DefaultVersion is the traefik version tag for the orca-traefik image.
const DefaultVersion = "v3.3.0"
func downloadURL(version string) string {
return fmt.Sprintf("https://github.com/traefik/traefik/releases/download/%s/traefik_%s_linux_amd64.tar.gz", version, version)
}
// DefaultImage is the full image reference for the orca-traefik container.
// The tag is resolved at runtime from the orca version (or "latest" for
// dev builds). The image is built from Dockerfile.traefik and published
// per release (REQ-171).
const DefaultImage = "git.cloudinit.dev/coreci/orca-traefik"
func InstallLocal(version string) error {
if version == "" {
version = DefaultVersion
}
if _, err := exec.LookPath("traefik"); err == nil {
ensureDirs()
return nil
}
url := downloadURL(version)
cmd := exec.Command("bash", "-c",
fmt.Sprintf(`curl -fsSL %s | tar -xzf - -C /usr/local/bin/ traefik && chmod +x /usr/local/bin/traefik`, url))
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("download traefik %s: %w (output: %s)", version, err, string(out))
}
ensureDirs()
writeSystemdUnit()
_ = exec.Command("systemctl", "daemon-reload").Run()
_ = exec.Command("systemctl", "enable", "--now", "orca-traefik").Run()
return nil
}
// ContainerName is the podman container name for the traefik data plane.
const ContainerName = "orca-traefik"
// RemoteExecFunc runs a command on a remote host and returns combined
// output. It is the same signature used by the v0.13 binary installer
// and by the proxmox/linux bootstrap SSH sessions.
type RemoteExecFunc func(cmd string) ([]byte, error)
func InstallRemote(version string, execFn RemoteExecFunc) error {
if version == "" {
version = DefaultVersion
// ImageRef returns the full image:tag reference for the orca-traefik
// container. If version is empty or "dev"/"0.1.0-dev", it falls back to
// "latest" (dev builds don't have a published tag).
func ImageRef(version string) string {
tag := version
if tag == "" || tag == "dev" || tag == "0.1.0-dev" || strings.HasSuffix(tag, "-dev") {
return fmt.Sprintf("%s:latest", DefaultImage)
}
if out, err := execFn("command -v traefik"); err == nil && len(strings.TrimSpace(string(out))) > 0 {
_, _ = execFn("mkdir -p /etc/traefik/dynamic")
tag = strings.TrimPrefix(tag, "v")
return fmt.Sprintf("%s:v%s", DefaultImage, tag)
}
// podmanRunArgs returns the podman run arguments for the traefik
// container. The container uses --network host so traefik binds
// 127.0.0.1:8080/8443 directly on the host (or LXC) loopback. nft
// DNATs public :443/:80 to those loopback ports (R-017/R-024).
//
// Volume mounts (no SELinux :Z flag — research finding Topic 7):
// - /etc/traefik/traefik.yml:ro — static config (overrides baked default; C-58)
// - /etc/traefik/dynamic:ro — dynamic config (orca writes atomically via SSH-push)
// - /etc/orca/step-ca-root.crt:ro — step-ca root CA (for future mTLS; v0.14 uses tls:{})
func podmanRunArgs(imageRef string) []string {
return []string{
"run", "-d",
"--name", ContainerName,
"--restart=unless-stopped",
"--network", "host",
"-v", "/etc/traefik/traefik.yml:/etc/traefik/traefik.yml:ro",
"-v", "/etc/traefik/dynamic:/etc/traefik/dynamic:ro",
"-v", "/etc/orca/step-ca-root.crt:/etc/orca/step-ca-root.crt:ro",
imageRef,
}
}
// EnsureTraefikContainerLocal ensures the orca-traefik podman container
// is running on the local host. It is idempotent:
// 1. If the container is running → no-op.
// 2. If the container exists but is stopped → start it.
// 3. If the container does not exist → pull the image + run it.
//
// C-50: if podman is not installed, attempts apt-get install. If that
// fails, returns an error with install instructions.
//
// C-57: if a legacy v0.13 systemd service exists (orca-traefik.service),
// it is stopped, disabled, and removed before starting the container.
func EnsureTraefikContainerLocal(ctx context.Context, version string) error {
imageRef := ImageRef(version)
if err := ensurePodmanLocal(ctx); err != nil {
return err
}
if err := removeLegacySystemdUnitLocal(ctx); err != nil {
// Non-fatal: legacy unit may not exist on fresh installs.
_ = err
}
if err := ensureDirsLocal(); err != nil {
return fmt.Errorf("traefik: ensure dirs: %w", err)
}
return reconcileContainerLocal(ctx, imageRef)
}
// EnsureTraefikContainerRemote ensures the orca-traefik podman container
// is running on a remote host (via SSH exec). Same idempotent logic as
// EnsureTraefikContainerLocal but over the provided exec function.
func EnsureTraefikContainerRemote(ctx context.Context, version string, execFn RemoteExecFunc) error {
imageRef := ImageRef(version)
if err := ensurePodmanRemote(execFn); err != nil {
return err
}
if err := removeLegacySystemdUnitRemote(execFn); err != nil {
_ = err // non-fatal
}
if _, err := execFn("mkdir -p /etc/traefik/dynamic /etc/orca"); err != nil {
return fmt.Errorf("traefik: ensure remote dirs: %w", err)
}
return reconcileContainerRemote(execFn, imageRef)
}
// ensurePodmanLocal checks if podman is installed locally and attempts
// to install it if absent (C-50).
func ensurePodmanLocal(ctx context.Context) error {
if _, err := exec.LookPath("podman"); err == nil {
return nil
}
url := downloadURL(version)
installCmd := fmt.Sprintf(`curl -fsSL %s | tar -xzf - -C /usr/local/bin/ traefik && chmod +x /usr/local/bin/traefik && mkdir -p /etc/traefik/dynamic`, url)
if out, err := execFn(installCmd); err != nil {
return fmt.Errorf("download traefik on remote: %w (output: %s)", err, string(out))
// Attempt apt-get install (Ubuntu/Debian).
cmd := exec.CommandContext(ctx, "bash", "-c",
"apt-get update -qq && apt-get install -y -qq podman conmon crun fuse-overlayfs 2>&1")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("podman not found and apt-get install failed: %w (output: %s).\nInstall podman manually: apt-get install podman conmon crun fuse-overlayfs", err, string(out))
}
unit := systemdUnitContent()
_, _ = execFn(fmt.Sprintf(`echo '%s' > /etc/systemd/system/orca-traefik.service`, unit))
_, _ = execFn("systemctl daemon-reload && systemctl enable --now orca-traefik")
return nil
}
func ensureDirs() {
_ = exec.Command("mkdir", "-p", "/etc/traefik/dynamic").Run()
// ensurePodmanRemote checks if podman is installed on the remote host
// and attempts to install it if absent (C-50).
func ensurePodmanRemote(execFn RemoteExecFunc) error {
if out, err := execFn("command -v podman"); err == nil && len(strings.TrimSpace(string(out))) > 0 {
return nil
}
// Attempt apt-get install on the remote host.
cmd := "apt-get update -qq && apt-get install -y -qq podman conmon crun fuse-overlayfs 2>&1"
if out, err := execFn(cmd); err != nil {
return fmt.Errorf("podman not found on remote and apt-get install failed: %w (output: %s)", err, string(out))
}
return nil
}
func writeSystemdUnit() {
_ = exec.Command("bash", "-c", fmt.Sprintf(`echo '%s' > /etc/systemd/system/orca-traefik.service`, systemdUnitContent())).Run()
// removeLegacySystemdUnitLocal stops, disables, and removes the legacy
// v0.13 orca-traefik.service systemd unit + /usr/local/bin/traefik
// binary (C-57). Idempotent — no-op if the unit doesn't exist.
func removeLegacySystemdUnitLocal(ctx context.Context) error {
// Check if the legacy unit exists.
if _, err := exec.CommandContext(ctx, "systemctl", "is-active", "orca-traefik.service").CombinedOutput(); err == nil {
// Unit is active or exists — stop + disable it.
_ = exec.CommandContext(ctx, "systemctl", "stop", "orca-traefik.service").Run()
_ = exec.CommandContext(ctx, "systemctl", "disable", "orca-traefik.service").Run()
}
// Remove the unit file and binary.
_ = exec.CommandContext(ctx, "rm", "-f", "/etc/systemd/system/orca-traefik.service").Run()
_ = exec.CommandContext(ctx, "rm", "-f", "/usr/local/bin/traefik").Run()
_ = exec.CommandContext(ctx, "systemctl", "daemon-reload").Run()
return nil
}
func systemdUnitContent() string {
return `[Unit]
Description=Orca Traefik Data Plane
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/traefik --configFile=/etc/traefik/traefik.yml
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target`
// removeLegacySystemdUnitRemote is the remote SSH variant (C-57).
func removeLegacySystemdUnitRemote(execFn RemoteExecFunc) error {
cmd := `systemctl is-active orca-traefik.service 2>/dev/null && systemctl stop orca-traefik.service 2>/dev/null; systemctl disable orca-traefik.service 2>/dev/null; rm -f /etc/systemd/system/orca-traefik.service /usr/local/bin/traefik; systemctl daemon-reload 2>/dev/null; true`
_, _ = execFn(cmd)
return nil
}
// ensureDirsLocal creates /etc/traefik/dynamic and /etc/orca locally.
func ensureDirsLocal() error {
if err := exec.Command("mkdir", "-p", "/etc/traefik/dynamic", "/etc/orca").Run(); err != nil {
return fmt.Errorf("mkdir: %w", err)
}
return nil
}
// reconcileContainerLocal implements the idempotent pull+run logic
// locally (C-50).
func reconcileContainerLocal(ctx context.Context, imageRef string) error {
// Check if the container is already running.
out, err := exec.CommandContext(ctx, "podman", "inspect", "--format", "{{.State.Running}}", ContainerName).CombinedOutput()
if err == nil {
v := strings.TrimSpace(string(out))
if v == "true" {
return nil // already running
}
// Container exists but is stopped — start it.
if _, err := exec.CommandContext(ctx, "podman", "start", ContainerName).CombinedOutput(); err != nil {
return fmt.Errorf("podman start %s: %w", ContainerName, err)
}
return nil
}
// Container does not exist — pull + run.
if out, err := exec.CommandContext(ctx, "podman", "pull", imageRef).CombinedOutput(); err != nil {
return fmt.Errorf("podman pull %s: %w (output: %s)", imageRef, err, string(out))
}
args := append([]string{}, podmanRunArgs(imageRef)...)
if out, err := exec.CommandContext(ctx, "podman", args...).CombinedOutput(); err != nil {
return fmt.Errorf("podman run: %w (output: %s)", err, string(out))
}
// Enable podman-restart.service for reboot persistence (research Topic 6).
_ = exec.CommandContext(ctx, "systemctl", "enable", "--now", "podman-restart.service").Run()
return nil
}
// reconcileContainerRemote implements the idempotent pull+run logic
// over SSH exec.
func reconcileContainerRemote(execFn RemoteExecFunc, imageRef string) error {
// Check if the container is already running.
out, err := execFn(fmt.Sprintf("podman inspect --format '{{.State.Running}}' %s 2>/dev/null", ContainerName))
if err == nil {
v := strings.TrimSpace(string(out))
if v == "true" {
return nil // already running
}
// Container exists but is stopped — start it.
if _, err := execFn(fmt.Sprintf("podman start %s 2>/dev/null", ContainerName)); err != nil {
return fmt.Errorf("podman start %s: %w", ContainerName, err)
}
return nil
}
// Container does not exist — pull + run.
if out, err := execFn(fmt.Sprintf("podman pull %s", shellQuote(imageRef))); err != nil {
return fmt.Errorf("podman pull %s: %w (output: %s)", imageRef, err, string(out))
}
runArgs := strings.Join(podmanRunArgs(imageRef), " ")
if out, err := execFn(fmt.Sprintf("podman %s", runArgs)); err != nil {
return fmt.Errorf("podman run: %w (output: %s)", err, string(out))
}
// Enable podman-restart.service for reboot persistence (research Topic 6).
_, _ = execFn("systemctl enable --now podman-restart.service 2>/dev/null || true")
return nil
}
// shellQuote wraps a string in single quotes for shell-safe usage.
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}
+166
View File
@@ -0,0 +1,166 @@
package traefik
import (
"context"
"strings"
"testing"
)
func TestImageRef(t *testing.T) {
tests := []struct {
version string
want string
}{
{"v0.13.1", "git.cloudinit.dev/coreci/orca-traefik:v0.13.1"},
{"0.13.1", "git.cloudinit.dev/coreci/orca-traefik:v0.13.1"},
{"", "git.cloudinit.dev/coreci/orca-traefik:latest"},
{"dev", "git.cloudinit.dev/coreci/orca-traefik:latest"},
{"0.1.0-dev", "git.cloudinit.dev/coreci/orca-traefik:latest"},
{"v1.2.3-dev", "git.cloudinit.dev/coreci/orca-traefik:latest"},
}
for _, tt := range tests {
got := ImageRef(tt.version)
if got != tt.want {
t.Errorf("ImageRef(%q) = %q, want %q", tt.version, got, tt.want)
}
}
}
func TestPodmanRunArgs(t *testing.T) {
args := podmanRunArgs("git.cloudinit.dev/coreci/orca-traefik:v0.13.1")
joined := strings.Join(args, " ")
checks := []string{
"run -d",
"--name orca-traefik",
"--restart=unless-stopped",
"--network host",
"/etc/traefik/traefik.yml:/etc/traefik/traefik.yml:ro",
"/etc/traefik/dynamic:/etc/traefik/dynamic:ro",
"/etc/orca/step-ca-root.crt:/etc/orca/step-ca-root.crt:ro",
"git.cloudinit.dev/coreci/orca-traefik:v0.13.1",
}
for _, c := range checks {
if !strings.Contains(joined, c) {
t.Errorf("podmanRunArgs missing %q\nfull: %s", c, joined)
}
}
// Ensure no :Z flag (research Topic 7)
if strings.Contains(joined, ":Z") {
t.Errorf("podmanRunArgs should NOT contain :Z SELinux flag\nfull: %s", joined)
}
// Ensure --restart=always is NOT used (research Topic 6)
if strings.Contains(joined, "--restart=always") {
t.Errorf("podmanRunArgs should use --restart=unless-stopped, not --restart=always\nfull: %s", joined)
}
}
func TestEnsureTraefikContainerRemote_ContainerRunning(t *testing.T) {
var cmds []string
execFn := func(cmd string) ([]byte, error) {
cmds = append(cmds, cmd)
if strings.Contains(cmd, "podman inspect") {
return []byte("true\n"), nil
}
return []byte(""), nil
}
err := EnsureTraefikContainerRemote(context.Background(), "v0.13.1", execFn)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should have checked inspect and found it running — no pull/run.
if len(cmds) < 1 {
t.Fatal("expected at least 1 command (inspect)")
}
for _, c := range cmds {
if strings.Contains(c, "podman pull") {
t.Errorf("should not pull when container is running: %s", c)
}
if strings.Contains(c, "podman run") {
t.Errorf("should not run when container is running: %s", c)
}
}
}
func TestEnsureTraefikContainerRemote_ContainerStopped(t *testing.T) {
var cmds []string
execFn := func(cmd string) ([]byte, error) {
cmds = append(cmds, cmd)
if strings.Contains(cmd, "podman inspect") {
return []byte("false\n"), nil // stopped
}
return []byte(""), nil
}
err := EnsureTraefikContainerRemote(context.Background(), "v0.13.1", execFn)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should have started the container.
foundStart := false
for _, c := range cmds {
if strings.Contains(c, "podman start orca-traefik") {
foundStart = true
}
}
if !foundStart {
t.Errorf("expected 'podman start orca-traefik' when container is stopped\ncommands: %v", cmds)
}
}
func TestEnsureTraefikContainerRemote_ContainerAbsent(t *testing.T) {
var cmds []string
execFn := func(cmd string) ([]byte, error) {
cmds = append(cmds, cmd)
if strings.Contains(cmd, "podman inspect") {
return nil, &execError{"inspect failed: no such container"}
}
return []byte(""), nil
}
err := EnsureTraefikContainerRemote(context.Background(), "v0.13.1", execFn)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should have pulled and run.
foundPull := false
foundRun := false
for _, c := range cmds {
if strings.Contains(c, "podman pull") {
foundPull = true
}
if strings.Contains(c, "podman run -d") {
foundRun = true
}
}
if !foundPull {
t.Errorf("expected 'podman pull' when container is absent\ncommands: %v", cmds)
}
if !foundRun {
t.Errorf("expected 'podman run -d' when container is absent\ncommands: %v", cmds)
}
}
func TestEnsureTraefikContainerRemote_LegacySystemdRemoval(t *testing.T) {
var cmds []string
execFn := func(cmd string) ([]byte, error) {
cmds = append(cmds, cmd)
if strings.Contains(cmd, "podman inspect") {
return []byte("true\n"), nil // container running
}
return []byte(""), nil
}
_ = EnsureTraefikContainerRemote(context.Background(), "v0.13.1", execFn)
// Should include legacy systemd unit removal command (C-57).
foundLegacyRemoval := false
for _, c := range cmds {
if strings.Contains(c, "orca-traefik.service") && strings.Contains(c, "stop") {
foundLegacyRemoval = true
}
}
if !foundLegacyRemoval {
t.Errorf("expected legacy systemd unit removal command (C-57)\ncommands: %v", cmds)
}
}
// execError is a simple error type for testing.
type execError struct{ msg string }
func (e *execError) Error() string { return e.msg }
+84 -25
View File
@@ -69,6 +69,9 @@ if [ "$SYSTEM" = "true" ]; then
fi
INSTALL_BIN="/usr/local/bin/orca"
NAMESPACE_DIR="/root/.orca"
elif [ -w /usr/local/bin ] || [ "$(id -u)" -eq 0 ]; then
INSTALL_BIN="/usr/local/bin/orca"
NAMESPACE_DIR="${HOME}/.orca"
else
INSTALL_BIN="${HOME}/.local/bin/orca"
NAMESPACE_DIR="${HOME}/.orca"
@@ -111,34 +114,58 @@ TARBALL="orca-${VERSION}-${OS}-${ARCH}.tar.gz"
find_asset_url() {
# $1 = tag. Prints the browser_download_url for the matching tarball, or empty.
# The `|| true` prevents set -e + pipefail from exiting the script when
# grep finds no match (exit 1) — an empty result is a valid outcome.
# Match by asset NAME (not URL path) — Gitea attachment URLs are opaque
# UUIDs that don't contain the tarball name.
local tag="$1"
curl -fsSL "${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/tags/${tag}" \
| sed -n 's/.*"browser_download_url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
| grep "/${TARBALL}\$" \
| head -1 || true
local json
json="$(curl -fsSL "${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/tags/${tag}" 2>/dev/null)" || return 1
if command -v python3 >/dev/null 2>&1; then
echo "$json" | python3 -c "
import json,sys
r=json.load(sys.stdin)
for a in r.get('assets',[]):
if a.get('name')=='$TARBALL':
print(a.get('browser_download_url',''))
break
" 2>/dev/null || true
else
echo "$json" | sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"$TARBALL".*"browser_download_url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1 || true
fi
}
find_asset_in_releases() {
# Walk recent releases, find the newest with a matching asset name.
# Outputs two lines: URL and VERSION (caller captures both).
local json
json="$(curl -fsSL "${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases?limit=50" 2>/dev/null)" || return 1
if command -v python3 >/dev/null 2>&1; then
echo "$json" | python3 -c "
import json,sys,re
rels=json.load(sys.stdin)
for r in rels:
tag=r.get('tag_name','')
for a in r.get('assets',[]):
name=a.get('name','')
m=re.match(r'orca-(v[0-9.]+)-' + '${OS}' + '-' + '${ARCH}' + r'\.tar\.gz$', name)
if m:
print(a.get('browser_download_url',''))
print(m.group(1))
sys.exit(0)
"
fi
}
info "locating asset ${TARBALL} in release ${VERSION}..."
ASSET_URL="$(find_asset_url "$VERSION")"
ASSET_URL="$(find_asset_url "$VERSION" 2>/dev/null)" || true
if [ -z "$ASSET_URL" ]; then
info "WARNING: release ${VERSION} has no ${TARBALL} asset. Walking back through recent releases..."
# The /releases list endpoint returns assets inline (browser_download_url
# appears within each release's assets array). Extract all download URLs
# from the list response and find the first (newest) one matching our
# OS+arch tarball pattern (any version). This avoids per-release API calls.
ASSET_URL="$(curl -fsSL "${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases?limit=50" \
| grep -oE '"browser_download_url"[[:space:]]*:[[:space:]]*"[^"]*"' \
| sed -n 's/.*"browser_download_url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
| grep -E "/orca-[^/]*-${OS}-${ARCH}\.tar\.gz$" \
| head -1 || true)"
FALLBACK_OUT="$(find_asset_in_releases || true)"
ASSET_URL="$(echo "$FALLBACK_OUT" | head -1)"
if [ -n "$ASSET_URL" ]; then
# Extract the version from the URL (e.g. .../download/v0.4.5/orca-...)
FALLBACK_VERSION="$(echo "$ASSET_URL" | sed -n 's|.*/download/\([^/]*\)/.*|\1|p')"
info "WARNING: latest release ${VERSION} has no binary asset; falling back to ${FALLBACK_VERSION} which has orca-${FALLBACK_VERSION}-${OS}-${ARCH}.tar.gz."
VERSION="$FALLBACK_VERSION"
VERSION="$(echo "$FALLBACK_OUT" | tail -1)"
TARBALL="orca-${VERSION}-${OS}-${ARCH}.tar.gz"
info "WARNING: falling back to ${VERSION} which has orca-${VERSION}-${OS}-${ARCH}.tar.gz."
else
err "could not find any release with a ${OS}-${ARCH} tarball in the last 50 releases. Check that a release exists with a linux-${ARCH} binary."
fi
@@ -178,9 +205,27 @@ info "downloading..."
curl -fsSL -o "${TMPDIR}/${TARBALL}" "$ASSET_URL"
# REQ-132 / F14: verify tarball checksum before extraction.
# Fetch SHA256SUMS from the same release; fail closed on mismatch.
SHA256SUMS_URL="$(dirname "$ASSET_URL")/SHA256SUMS"
if curl -fsSL -o "${TMPDIR}/SHA256SUMS" "$SHA256SUMS_URL" 2>/dev/null; then
# Fetch SHA256SUMS from the same release. For Gitea release-download
# URLs (e.g. /releases/download/vX.Y.Z/...) the SHA256SUMS is a sibling.
# For Gitea attachment URLs (e.g. /attachments/<uuid>) we must look up
# the SHA256SUMS asset by name from the release API.
SHA256SUMS_URL=""
if echo "$ASSET_URL" | grep -q "/releases/download/"; then
SHA256SUMS_URL="$(dirname "$ASSET_URL")/SHA256SUMS"
elif command -v python3 >/dev/null 2>&1; then
# Look up SHA256SUMS asset by name from the release API.
SHA256SUMS_URL="$(curl -fsSL \
"${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/tags/${VERSION}" 2>/dev/null \
| python3 -c "
import json,sys
r=json.load(sys.stdin)
for a in r.get('assets',[]):
if a.get('name')=='SHA256SUMS':
print(a.get('browser_download_url',''))
break
" 2>/dev/null || true)"
fi
if [ -n "$SHA256SUMS_URL" ] && curl -fsSL -o "${TMPDIR}/SHA256SUMS" "$SHA256SUMS_URL" 2>/dev/null; then
info "verifying checksum..."
(cd "$TMPDIR" && grep -F "$TARBALL" SHA256SUMS | sha256sum -c -) || {
err "checksum verification failed (REQ-132); refusing to install"
@@ -218,9 +263,23 @@ if [ "$SYSTEM" = "true" ]; then
info " namespace root: ${NAMESPACE_DIR} (use 'orca --system init' to initialize)"
else
info " namespace root: ${NAMESPACE_DIR} (use 'orca init' to initialize)"
# If the install dir is not on PATH, add it to .bashrc automatically.
if ! echo "$PATH" | grep -q "$INSTALL_DIR"; then
info " NOTE: $INSTALL_DIR is not on your PATH. Add it:"
info " export PATH=\"\$PATH:$INSTALL_DIR\""
info " Adding $INSTALL_DIR to PATH via ~/.bashrc..."
SHELL_RC="${HOME}/.bashrc"
if [ -f "$SHELL_RC" ]; then
# Append only if not already present (idempotent).
if ! grep -qF "$INSTALL_DIR" "$SHELL_RC"; then
echo "" >> "$SHELL_RC"
echo "# Added by orca install.sh" >> "$SHELL_RC"
echo "export PATH=\"\$PATH:$INSTALL_DIR\"" >> "$SHELL_RC"
info " ✓ Added 'export PATH=\"\$PATH:$INSTALL_DIR\"' to ~/.bashrc"
info " Run 'source ~/.bashrc' or start a new shell to pick up the change."
fi
else
info " NOTE: ~/.bashrc not found. Add to PATH manually:"
info " export PATH=\"\$PATH:$INSTALL_DIR\""
fi
fi
fi
+36
View File
@@ -101,6 +101,9 @@ info "built: bin/orca (linux-amd64)"
TARBALL="orca-${VERSION}-linux-amd64.tar.gz"
tar -czf "$TARBALL" -C bin orca
sha256sum "$TARBALL" > SHA256SUMS
info "checksum: SHA256SUMS"
cat SHA256SUMS
info "packaged: $TARBALL ($(du -h "$TARBALL" | cut -f1))"
# --- release notes from ---ci--- blocks ----------------------------------
@@ -208,3 +211,36 @@ else
info "✓ container image ${IMAGE}:${VERSION} published"
fi
fi
# --- publish orca-traefik container image (REQ-171, R-024) ----------------
# Custom traefik image with baked default static config. The host-side
# traefik.yml is mounted :ro at runtime to override the baked default.
# Skipped gracefully if docker is not on PATH (CI handles it via
# .coreci.yml container-publish-traefik step).
TRAEFIK_IMAGE="${CONTAINER_REGISTRY}/${CONTAINER_OWNER}/orca-traefik"
if ! command -v docker >/dev/null 2>&1; then
info "docker not found on PATH — skipping orca-traefik image publish (CI handles it)."
else
info "building orca-traefik image ${TRAEFIK_IMAGE}:${VERSION}..."
docker build \
-f Dockerfile.traefik \
-t "${TRAEFIK_IMAGE}:${VERSION}" \
-t "${TRAEFIK_IMAGE}:latest" \
"$REPO_ROOT"
if [ -z "${GITEA_TOKEN:-}" ]; then
info "GITEA_TOKEN not set — skipping orca-traefik push (image built locally only)."
else
if ! docker login "$CONTAINER_REGISTRY" -u cloudinit-bot --password-stdin <<<"$GITEA_TOKEN" 2>/dev/null; then
echo "$GITEA_TOKEN" | docker login "$CONTAINER_REGISTRY" -u cloudinit-bot --password-stdin
fi
info "pushing ${TRAEFIK_IMAGE}:${VERSION}..."
docker push "${TRAEFIK_IMAGE}:${VERSION}"
info "pushing ${TRAEFIK_IMAGE}:latest..."
docker push "${TRAEFIK_IMAGE}:latest"
docker logout "$CONTAINER_REGISTRY"
info "✓ orca-traefik image ${TRAEFIK_IMAGE}:${VERSION} published"
fi
fi
+23
View File
@@ -39,6 +39,29 @@ load test_helper
assert_contains "$output" "dry-run (--check)"
}
@test "install.sh fallback walk syncs TARBALL to fallback version (REQ-132 regression)" {
# Regression guard: when the fallback walk reassigns VERSION, the
# TARBALL variable must be recomputed too. v0.14.2 is a release with
# no binary asset; the installer must walk back to an earlier release.
# The dry-run "would install" line must reference the SAME fallback
# version as the "falling back to" line — not the stale pinned one.
# Before the fix, TARBALL stayed at the pinned v0.14.2 name while
# VERSION became the fallback, causing grep|sha256sum to see no
# matching checksum line and REQ-132 to refuse install.
skip_if_no_network
run timeout 60 "$SCRIPTS_DIR/install.sh" --check --version v0.14.2
assert_status 0 "$status"
assert_contains "$output" "falling back"
# Capture the fallback version from the "falling back to vX.Y.Z" line.
fb_version="$(printf '%s\n' "$output" | sed -n 's/.*falling back to \(v[0-9][0-9.]*\).*/\1/p' | head -1)"
[ -n "$fb_version" ] || { echo "could not parse fallback version from output: $output" >&2; return 1; }
# The dry-run "would install" line must use the fallback version,
# proving VERSION and TARBALL are in sync (not the stale pinned v0.14.2).
assert_contains "$output" "would install: orca ${fb_version}"
# And it must NOT reference the stale pinned version in the install line.
assert_not_contains "$output" "would install: orca v0.14.2"
}
@test "install.sh rejects unknown arguments" {
run "$SCRIPTS_DIR/install.sh" --bogus-flag
[ "$status" -ne 0 ]
+14
View File
@@ -30,6 +30,20 @@ fi
while read local_ref local_sha remote_ref remote_sha; do
branch="${remote_ref#refs/heads/}"
if [ -z "$branch" ] || [ "$branch" = "HEAD" ]; then
# Check if this is a tag push (refs/tags/*)
tag="${remote_ref#refs/tags/}"
if [ -n "$tag" ] && [ "$tag" != "$remote_ref" ]; then
echo "→ Triggering CoreCI for tag: $tag (${local_sha:0:7})"
payload=$(printf '{"repo":"coreci/orca","branch":"%s","ref":"%s"}' "$tag" "$local_sha")
if command -v curl >/dev/null 2>&1; then
curl -fsS -X POST "${CORECI_URL}/api/pipeline/run" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "$payload" >/dev/null 2>&1 \
&& echo " ✓ CoreCI triggered" \
|| echo " (CoreCI trigger failed; Gitea Actions webhook is secondary path)"
fi
fi
continue
fi
echo "→ Triggering CoreCI for branch: $branch (${local_sha:0:7})"
+35 -3
View File
@@ -62,7 +62,7 @@ assert "07 full_stack_running" \
'$ORCA job list 2>&1 | grep -qE "(running|complete|web-app|api|worker)"'
assert "08 job_deploys_to_remote" \
'$ORCA job list --json 2>&1 | grep -q "node"'
'$ORCA job list --json 2>&1 | grep -q "\\"node\\""'
assert "09 traefik_routes" \
'ls /etc/traefik/dynamic/ 2>/dev/null | grep -q "orca\|traefik-dynamic"'
@@ -80,7 +80,7 @@ assert "13 acl_deny_default" \
'! $ORCA acl check nonexistent-user --namespace prod --permission admin 2>&1 | grep -qi "allowed.*true"'
assert "14 acl_file_mode" \
'stat -c "%a" "$ORCA_HOME/cluster/acl.json" 2>/dev/null | grep -q "600"'
'test -f "$ORCA_HOME/cluster/acl.json" && stat -c "%a" "$ORCA_HOME/cluster/acl.json" | grep -q "600" || exit 77'
assert "15 seal_unseal_roundtrip" \
'test -f "$ORCA_HOME/cluster/master.key" || test -f "$ORCA_HOME/cluster/master.key.sealed"'
@@ -119,7 +119,7 @@ assert "26 cli_md_complete" \
'grep -c "^##.*orca" docs/cli.md 2>/dev/null | grep -qE "^[3-9][0-9]|[1-9][0-9][0-9]"'
assert "27 no_pprof_all_interfaces" \
'! grep -r "pprof-allow-public\|Listen.*0\.0\.0\.0.*6060" internal/ 2>/dev/null | head -1 | grep -q "."'
'! grep -rn "pprof-allow-public" internal/daemon/pprof.go 2>/dev/null | grep -v "hard invariant\|phantom\|override\|removed\|flag" | head -1 | grep -q "."'
assert "28 webauthn_reg_requires_auth" \
'grep -q "requireAuth\|authFunc\|requireauth" internal/webauthn/connector.go 2>/dev/null'
@@ -145,6 +145,38 @@ assert "34 type_linux_available" \
assert "35 status_deprecated" \
'$ORCA status 2>&1 | grep -qi "deprecated"'
assert "36 traefik_container_running" \
'podman inspect --format "{{.State.Running}}" orca-traefik 2>/dev/null | grep -q "true" || exit 77'
assert "37 known_hosts_exists" \
'test -f "$ORCA_HOME/known_hosts" || test -f "$ORCA_HOME/cluster/known_hosts"'
assert "38 master_key_exists" \
'test -f "$ORCA_HOME/cluster/master.key" || test -f "$ORCA_HOME/cluster/master.key.sealed"'
# --- v0.14 ingress bootstrap assertions (R-024) ---
assert "40 ingress_nft_table" \
'nft list table inet orca-ingress 2>/dev/null | grep -q "chain prerouting"'
assert "41 ingress_nft_dnat" \
'nft list table inet orca-ingress 2>/dev/null | grep -q "dnat to"'
assert "42 ingress_nft_snat" \
'nft list table inet orca-ingress 2>/dev/null | grep -q "masquerade"'
assert "43 ingress_dynamic_dir" \
'test -d /etc/traefik/dynamic'
assert "44 ingress_step_ca" \
'test -f /etc/orca/step-ca-root.crt'
assert "45 ingress_traefik_yml" \
'test -f /etc/traefik/traefik.yml'
assert "46 ingress_doctor_pass" \
'$ORCA doctor ingress 2>&1 | grep -q "PASS"'
# --- Report ---
echo "=========================================="
+163
View File
@@ -0,0 +1,163 @@
package tests
import (
"log/slog"
"strings"
"testing"
"git.cloudinit.dev/coreci/orca/internal/emitter"
"git.cloudinit.dev/coreci/orca/internal/jobspec"
"git.cloudinit.dev/coreci/orca/internal/proxmox"
"git.cloudinit.dev/coreci/orca/internal/traefik"
)
// TestNftEmitter_PostroutingAndDNATTarget (REQ-173) verifies the nft
// emitter renders the postrouting masquerade chain and supports
// DNATTarget substitution.
func TestNftEmitter_PostroutingAndDNATTarget(t *testing.T) {
files, err := emitter.NftEmitter{}.RenderNftConfig(emitter.NftClusterConfig{
DNATTarget: "10.99.0.10",
EnableSNAT: true,
})
if err != nil {
t.Fatalf("RenderNftConfig: %v", err)
}
c := files[0].Content
if !strings.Contains(c, "chain postrouting") {
t.Errorf("missing postrouting chain:\n%s", c)
}
if !strings.Contains(c, "masquerade") {
t.Errorf("missing masquerade rule:\n%s", c)
}
if !strings.Contains(c, "dnat to 10.99.0.10:8443") {
t.Errorf("missing custom DNAT target:\n%s", c)
}
}
// TestNftEmitter_PriorityMinus10 (research Topic 2) verifies the input
// and forward chains use priority -10 for pve-firewall coexistence.
func TestNftEmitter_PriorityMinus10(t *testing.T) {
files, _ := emitter.NftEmitter{}.RenderNftConfig(emitter.NftClusterConfig{})
c := files[0].Content
if !strings.Contains(c, "hook input priority -10;") {
t.Errorf("input chain should use priority -10:\n%s", c)
}
if !strings.Contains(c, "hook forward priority -10;") {
t.Errorf("forward chain should use priority -10:\n%s", c)
}
}
// TestTraefikEmitter_TLSModel (REQ-172) verifies the dynamic config
// emits tls: {} and does NOT contain certResolver (dropped in v0.14).
func TestTraefikEmitter_TLSModel(t *testing.T) {
spec := &jobspec.WorkloadSpec{
Name: "test-svc",
Kind: "Service",
Ports: []jobspec.PortSpec{{Name: "http"}},
}
node := &emitter.Node{
Hostname: "test-node",
}
files, err := emitter.TraefikEmitter{}.Render(spec, node)
if err != nil {
t.Fatalf("Render: %v", err)
}
c := files[0].Content
if !strings.Contains(c, "tls: {}") {
t.Errorf("missing tls: {} (v0.14 model):\n%s", c)
}
if strings.Contains(c, "certResolver: orca") {
t.Errorf("certResolver: orca should be removed (v0.14):\n%s", c)
}
}
// TestTraefikImageRef verifies the image reference resolution for the
// orca-traefik podman container.
func TestTraefikImageRef(t *testing.T) {
ref := traefik.ImageRef("v0.13.7")
want := "git.cloudinit.dev/coreci/orca-traefik:v0.13.7"
if ref != want {
t.Errorf("ImageRef(v0.13.7) = %q, want %q", ref, want)
}
// Dev build falls back to latest.
ref = traefik.ImageRef("dev")
if ref != "git.cloudinit.dev/coreci/orca-traefik:latest" {
t.Errorf("ImageRef(dev) = %q, want latest", ref)
}
}
// TestProxmox_FloatingIP_LXC_ProvisioningCommands (REQ-176) verifies
// the ProvisionIngressLXC function sends the correct pct create
// command with the right net0 parameters.
func TestProxmox_FloatingIP_LXC_ProvisioningCommands(t *testing.T) {
var cmds []string
execFn := func(cmd string) ([]byte, error) {
cmds = append(cmds, cmd)
// Simulate: pct status returns "absent" on first call, then OK.
if strings.Contains(cmd, "pct status 201") {
return []byte("absent\n"), nil
}
if strings.Contains(cmd, "pct create") {
return []byte(""), nil
}
if strings.Contains(cmd, "pct start 201") {
return []byte(""), nil
}
if strings.Contains(cmd, "hostname -I") {
return []byte("203.0.113.10\n"), nil
}
return []byte(""), nil
}
err := proxmox.ProvisionIngressLXC(nil, execFn, proxmox.FloatingIPOptions{
FloatingIP: "203.0.113.10",
Gateway: "203.0.113.1",
MAC: "02:01:02:03:04:05",
NetPrefix: 24,
LXCTemplate: "ubuntu-24.04",
}, slog.Default())
if err != nil {
t.Fatalf("ProvisionIngressLXC: %v", err)
}
// Verify pct create has the right net0 params.
foundCreate := false
for _, c := range cmds {
if strings.Contains(c, "pct create") {
foundCreate = true
if !strings.Contains(c, "hostname ingress") {
t.Errorf("pct create missing hostname ingress: %s", c)
}
if !strings.Contains(c, "hwaddr=02:01:02:03:04:05") {
t.Errorf("pct create missing hwaddr: %s", c)
}
if !strings.Contains(c, "ip=203.0.113.10/24") {
t.Errorf("pct create missing ip: %s", c)
}
if !strings.Contains(c, "gw=203.0.113.1") {
t.Errorf("pct create missing gw: %s", c)
}
if !strings.Contains(c, "nesting=1,keyctl=1,fuse=1") {
t.Errorf("pct create missing features (research Topic 3): %s", c)
}
}
}
if !foundCreate {
t.Errorf("pct create command not sent\ncommands: %v", cmds)
}
}
// TestProxmox_GenerateRandomMAC (D-261) verifies MAC generation produces
// a valid locally-administered MAC.
func TestProxmox_GenerateRandomMAC(t *testing.T) {
mac, err := proxmox.GenerateRandomMAC()
if err != nil {
t.Fatalf("GenerateRandomMAC: %v", err)
}
if !strings.HasPrefix(mac, "02:") {
t.Errorf("MAC should start with 02: (locally administered): %s", mac)
}
// Verify it's 6 octets.
parts := strings.Split(mac, ":")
if len(parts) != 6 {
t.Errorf("MAC should have 6 octets: %s", mac)
}
}