3 execution phases + final review (PLAN_v0.6.md): - P01 (Wave 1): orca init full bootstrap + schema 0006 (REQ-047/048/049) - data-engineer: migration 0006, Node.Kind/OS, NodeRepo extension - backend-engineer: init.go full bootstrap orchestration - cli-engineer: osdetect.go, init output UX - P02 (Wave 1, depends on P01): Proxmox SSH join (REQ-050/051) - security-engineer: sshkey.go (Ed25519), TOFU, sudoers, PVE role - backend-engineer: proxmox/bootstrap.go SSH session sequence - cli-engineer: --type/--host/--password flag wiring - P03 (Wave 2, depends on P01+P02): doctor extensions (REQ-052) - backend-engineer: doctor OS() + Proxmox() checks - cli-engineer: doctor os/proxmox subcommands - security-engineer: audit logging of bootstrap/join actions - P04 (Wave 3): final review + ship + audit (milestone release v0.5.4) Wave ordering: P01 -\u003e P02 -\u003e P03 -\u003e P04 (sequential, parallelization off). Tags: v0.5.0 (P0) .. v0.5.4 (P4 final = milestone release). ---ci--- project: orca phase: 0 milestone: v0.6 status: plan ---/ci---
16 KiB
Phase Plans: Orca v0.6 — Node Bootstrap & Proxmox
All 3 execution phases + final review with vertical-slice structure,
wave ordering, and REQ-ID mapping. v0.6 scope: Node Bootstrap &
Proxmox — orca init full bootstrap, Proxmox SSH join, doctor
extensions.
Branching: branches numbered from phase 12 onward (v0.1 used 01-07,
v0.2 used 08-11, v0.3 used 00+01-03, v0.5 used 00+01-04). v0.6 uses
phase/01-*..phase/04-* on the milestone/v0.6-node-bootstrap-proxmox
branch (numbering restarts per milestone per branch-strategy.md).
Phase 1: orca init Full Bootstrap + Schema 0006 (Wave 1)
Branch: phase/01-init-bootstrap
REQ Coverage: REQ-047, REQ-048, REQ-049
Persona leads: data-engineer (schema), backend-engineer (init orchestration), cli-engineer (output UX)
Must-Haves
data-engineer territory
internal/store/migrations/0006_node_kind_os.sql—ALTER TABLE nodes ADD COLUMN kind TEXT; ALTER TABLE nodes ADD COLUMN os TEXT;(nullable, backward-compatible)internal/model/node.go— addKind stringjson:"kind,omitempty"+ `OS string `json:"os,omitempty"fields; addNodeKindconstants (NodeKindLocalhost,NodeKindLinux,NodeKindProxmox)internal/store/node_repo.go— extendInsert/Get/List/Watch/scanNodeforkind, oscolumns (usesql.NullString, map NULL →""); addGetByName(ctx, name) (*Node, error)andUpdateLastSeenAndOS(ctx, id, os string) errorhelpersinternal/store/node_repo_test.go— extend tests for new columns + helpers; assert NULL →""mapping; assertGetByNamereturnsErrNotFoundfor missing; assertUpdateLastSeenAndOSrefresheslast_seen+oswithout changingid/joined_at
backend-engineer territory
internal/cli/init.go— full bootstrap sequence (replace current 35-line mkdir-only impl):- MkdirAll(certpaths.Dir(), 0o755) — keep
- store.Open(certpaths.DBPath()) — runs migrations 0001..0006
- security.CAInit(certpaths.Dir(), "orca-internal-ca") — idempotent (existing fast-path)
- if !exists(certpaths.ServerCertPath()): GenerateCSR("localhost", ["localhost","127.0.0.1"]) → ca.SignCSR → WriteCert + WriteKey
- detectOS() from /etc/os-release (see cli-engineer territory)
- localhost node upsert: GetByName("localhost") → if found UpdateLastSeenAndOS; else Insert with kind=localhost, os=, name="localhost", addr="localhost:8443"
- print summary (CA fp, server cert fp, os, node id, db path)
internal/cli/init_test.go— idempotency test: run init twice, assert no duplicate localhost node, last_seen refreshed, os unchanged; assert CA/cert not regenerated on re-run; assert doctor passes after init
cli-engineer territory
internal/cli/osdetect.go(NEW) —detectOS() string: read/etc/os-releasethen fall back to/usr/lib/os-release; parseKEY=VALUElines via bufio.Scanner + strings.SplitN; strip surrounding quotes; returnIDvalue or"linux"fallback. Map ubuntu/debian/alpine → verbatim; unknown values stored verbatim (not masked).internal/cli/osdetect_test.go— test parsing with sample os-release content (ubuntu, debian, alpine, missing file, missing ID=, unknown ID, quoted values)internal/cli/init.gooutput UX — multi-step progress lines: "✓ Namespace dir: ...", "✓ Database initialized: ...", "✓ CA provisioned: ... (fp=...)", "✓ Server cert provisioned: ... (fp=...)", "✓ OS detected: ubuntu", "✓ Localhost node registered: ";--jsonoutputs a single JSON summary object
Verification
go build ./...PASSgo test ./internal/store/... ./internal/cli/... ./internal/model/...PASSgo test -race ./...PASSorca initon a fresh namespace → creates dir, db, CA, server cert, localhost node;orca doctorpasses with zero FAILsorca initre-run → no duplicate localhost node, last_seen refreshed, CA/cert not regenerated (idempotent, D-036)orca init --json→ valid JSON summaryorca node listshows the localhost node with kind=localhost, os=- Migration 0006 applies cleanly on existing dbs (existing rows get NULL kind/os → scanned as
"")
Phase 2: Proxmox SSH Join (Wave 1)
Branch: phase/02-proxmox-join
REQ Coverage: REQ-050, REQ-051
Persona leads: security-engineer (SSH key, TOFU, sudoers, PVE role), backend-engineer (SSH session orchestration), cli-engineer (flag wiring)
Depends on: Phase 1 (migration 0006 + Node.Kind/OS fields)
Must-Haves
dependency + security-engineer territory
go.mod/go.sum— addgolang.org/x/crypto v0.54.0; bumpgolang.org/x/systo v0.47.0; addgolang.org/x/term v0.45.0(indirect). Rungo mod tidy.internal/certpaths/certpaths.go— addSSHKeyPath() → Dir()/orca_ssh_key,SSHPubPath() → Dir()/orca_ssh_key.pub,KnownHostsPath() → Dir()/known_hostsinternal/security/sshkey.go(NEW) —GenerateOrLoadSSHKey(dir string) (keyPEM, pubLine []byte, err error):- If
orca_ssh_key+.pubexist → load + return (idempotent) - Else:
ed25519.GenerateKey(rand.Reader)→x509.MarshalPKCS8PrivateKey→ PEM encode →writeAtomic(keyPath, 0600, keyPEM);ssh.NewPublicKey(pub)→ssh.MarshalAuthorizedKey→writeAtomic(pubPath, 0644, pubLine) - Return keyPEM (for
ssh.ParsePrivateKey) + pubLine (authorized_keys line)
- If
internal/security/sshkey_test.go— test generate → load round-trip; test idempotent re-load; test file modes (0600/0644); testssh.ParsePrivateKeyaccepts the PKCS8 PEM
backend-engineer territory (with security-engineer co-own)
internal/proxmox/bootstrap.go(NEW package) —BootstrapProxmox(ctx context.Context, opts Options) (*Result, error):- Options:
Host, SSHUser, Password, ProxmoxUser (default "orca"), ProxmoxRole (default "OrcaOperator"), Port (default 22),Logger *slog.Logger - Step 1:
security.GenerateOrLoadSSHKey(certpaths.Dir())→ keyPEM, pubLine - Step 2: Build
ssh.ClientConfigwithssh.Password(opts.Password)auth +knownhosts.New(certpaths.KnownHostsPath())HostKeyCallback (TOFU: captures on first connect, verifies on subsequent) - Step 3:
ssh.Dial("tcp", host:port, config)with 10s timeout - Step 4: Deploy pubkey —
session.CombinedOutput("mkdir -p ~orca/.ssh && touch ~orca/.ssh/authorized_keys && chmod 0700 ~orca/.ssh && chmod 0600 ~orca/.ssh/authorized_keys && grep -qF '<publine>' ~orca/.ssh/authorized_keys || echo '<publine>' >> ~orca/.ssh/authorized_keys")(idempotent append) - Step 5: Create orca system user —
session.CombinedOutput("id -u orca 2>/dev/null || useradd -m -s /bin/bash orca")(idempotent) - Step 6: Create PVE role —
session.CombinedOutput("pveum role list 2>/dev/null | grep -q '^OrcaOperator' || pveum role add OrcaOperator --privs 'VM.Audit Datastore.AllocateSpace SDN.Use'")(idempotent; use opts.ProxmoxRole for the name) - Step 7: Create PVE user —
session.CombinedOutput("pveum user list 2>/dev/null | grep -q 'orca@pam' || pveum user add orca@pam -comment 'Orca automation user'")(idempotent; use opts.ProxmoxUser) - Step 8: Assign ACL —
session.CombinedOutput("pveum acl modify / -user orca@pam -role OrcaOperator")(idempotent) - Step 9: Write sudoers — resolve binary paths via
command -v pctetc.; write/etc/sudoers.d/orca(mode 0440) with NOEXEC on pct/qm, no NOEXEC on apt-get/dpkg; exclude pvesh (AD-020) - Step 10: Validate sudoers —
session.CombinedOutput("visudo -cf /etc/sudoers.d/orca"); abort + cleanup if validation fails - Step 11: Audit log —
logger.Info("proxmox.bootstrap_ok", slog.String("host", opts.Host), slog.String("user", opts.ProxmoxUser), slog.String("role", opts.ProxmoxRole)) - Result:
Node{Kind: "proxmox", OS: "pve", Name: opts.Host, Address: opts.Host + ":8443"}
- Options:
internal/proxmox/bootstrap_test.go— unit tests with a mock SSH server (httptest-style ornet.Pipe+ manual SSH handshake) OR test the command-builder functions in isolation (probe commands, sudoers content, idempotency checks). Integration test against a real Proxmox host is out of scope for unit tests (flagged as// +build integration).
cli-engineer territory
internal/cli/node.go— extendnodeJoinCmd:- Add
--typeflag (values:localhostdefault,linux,proxmox) - Add
--host,--ssh-user(defaultroot),--password,--proxmox-user(defaultorca),--proxmox-role(defaultOrcaOperator),--ssh-port(default22) flags - When
--type proxmox: validate--host+ (--passwordor$ORCA_PROXMOX_PASSWORD) are set; callproxmox.BootstrapProxmox(ctx, opts); insert the returned node viaNodeRepo.Insert; print summary - When
--type localhost(default): existing flow (fingerprint check + registry.Join) - Password from
--passwordflag OR$ORCA_PROXMOX_PASSWORDenv var (prefer env var per D-031; never log the password; zero the byte slice after use)
- Add
internal/cli/node_test.go— test flag wiring; test--type proxmoxvalidation (missing host/password → error); test env var fallback
Verification
go build ./...PASSgo test ./internal/proxmox/... ./internal/security/... ./internal/cli/...PASSgo test -race ./...PASSgo mod tidyleaves no unused deps;go.sumhasgolang.org/x/crypto v0.54.0orca node join --type proxmox --host <pve-host> --password <pw>on a real Proxmox 8/9 host:- Creates orcaOperator role, orca@pam user, ACL, sudoers file
orca@pamcansudo pct list,sudo qm list,sudo apt-get updatewithout passwordorca@pamCANNOTsudo pvesh(not in sudoers)orca@pamCANNOTsudo bash(not in sudoers)visudo -cf /etc/sudoers.d/orcapasses- Re-running the join command is idempotent (no duplicate role/user/ACL/sudoers/key)
orca node listshows the proxmox node with kind=proxmox, os=pve- Audit log contains
proxmox.bootstrap_okentry with host, user, role ~/.orca/orca_ssh_keyis 0600,.pubis 0644,known_hostscontains the PVE host key
Phase 3: Doctor Extensions + Audit Logging (Wave 2)
Branch: phase/03-doctor-extensions
REQ Coverage: REQ-052
Persona leads: cli-engineer (subcommand wiring), backend-engineer (check logic), security-engineer (audit logging)
Depends on: Phase 1 (localhost node + os field), Phase 2 (proxmox nodes + SSH client)
Must-Haves
backend-engineer territory
internal/doctor/doctor.go— addOS()check:- Re-run
detectOS()(frominternal/cli/osdetect.go— extract to shared package or pass as param) - Load localhost node via
NodeRepo.GetByName("localhost") - Compare detected OS to stored
node.OS; drift → WARN ("OS drift: init=ubuntu, now=debian — re-runorca initto refresh"); match → PASS - Missing localhost node → FAIL ("no localhost node — run
orca init")
- Re-run
internal/doctor/doctor.go— addProxmox()check (cloneNetwork()pattern):- List nodes from
NodeRepo, filterkind == "proxmox" - Zero proxmox nodes → WARN ("no proxmox nodes registered (single-node?)")
- Per node: load orca SSH key, build
ssh.ClientConfigwithssh.PublicKeys(signer)+knownhosts.New, dial with 3s timeout, runpveversionvia session - PASS = reachable + pveversion exits 0; FAIL = unreachable or pveversion fails
- Accumulate per-node lines (clone
Network()'slines []stringpattern)
- List nodes from
internal/doctor/doctor.go— extendAll()to includeOS()andProxmox()internal/doctor/doctor_test.go— testOS()with mock node repo (drift, match, missing); testProxmox()with mock nodes (zero nodes → WARN, reachable → PASS, unreachable → FAIL)
cli-engineer territory
internal/cli/doctor.go— adddoctorOSCmd+doctorProxmoxCmdsubcommands wired todoctor.OS()/doctor.Proxmox(); add todoctorCmd.AddCommand(...)internal/cli/doctor.go—doctor osanddoctor proxmoxhonor--jsonflag (reuse existing pattern)
security-engineer territory
internal/audit/audit.go(extend) — emitproxmox.bootstrap_ok,proxmox.bootstrap_fail,node.os_driftevents with structured slog fields- Audit log entries for all bootstrap + join actions (REQ-052):
orca initemitsinit.bootstrap_ok(os, node_id, ca_fp);orca node join --type proxmoxemitsproxmox.bootstrap_ok(host, user, role);doctor osdrift emitsnode.os_drift(init_os, current_os)
Verification
go build ./...PASSgo test ./internal/doctor/... ./internal/cli/...PASSgo test -race ./...PASSorca doctor(afterorca init) → all checks PASS (cert, db, os, network=zero peers WARN, proxmox=zero nodes WARN)orca doctor os→ PASS (OS matches)orca doctor proxmox(no proxmox nodes) → WARN ("no proxmox nodes registered")orca doctor proxmox(after joining a PVE host) → PASS per nodeorca doctor proxmox(PVE host down) → FAIL per node with error message- Audit log contains
init.bootstrap_okandproxmox.bootstrap_okentries --jsonoutput fordoctor osanddoctor proxmoxis valid JSON
Phase 4: Final Review + Ship + Audit (Wave 3)
Branch: phase/04-final-review-ship
REQ Coverage: REQ-047, REQ-048, REQ-049, REQ-050, REQ-051, REQ-052 (all)
Persona leads: lead-developer (review + audit), all personas (post-hoc review)
Must-Haves
- Review (delegate to
ciagent-review): multi-persona code review across P01-P03- Auto-apply P0 fixes; flag P1+ for post-hoc review
- Review territory discipline (warn mode)
- Review test coverage for all 6 REQs
- Audit (delegate to
ciagent-audit):- Reconstruction test: git log matches
.ciagent/files - Branch hygiene: phase branches merged cleanly to milestone
- Commit discipline: all commits have
---ci---blocks - File discipline: no stale
.ciagent/files
- Reconstruction test: git log matches
- Ship (delegate to
ciagent-ship):- Merge
phase/04→milestone/v0.6 - Merge
milestone/v0.6→main(rebase-then-fast-forward per config.json) - Tag
v0.5.4(final phase patch = milestone release per feature-milestone promotion) - Create Gitea release with full milestone summary (all phases, all REQs)
- Merge
- Complete milestone:
- Update
.ciagent/REQUIREMENTS.md— mark REQ-047..052 as Complete - Update
.ciagent/ROADMAP.md— mark v0.6 as complete - Update
.ciagent/CHECKPOINT.json—milestone_complete: true - Commit:
docs(milestone): complete node-bootstrap-proxmox
- Update
Verification
git log --oneline main..milestone/v0.6shows all phase commits in ordergit tag --list v0.5.*shows v0.5.0..v0.5.4mainbranch contains all v0.6 work (fast-forward merge)orca init && orca doctoron a fresh checkout passes end-to-end- Gitea release
v0.5.4exists with milestone summary
Wave Ordering
- Wave 1 (Phases 1-2): Schema + init bootstrap (P01) is a hard
prerequisite for Proxmox join (P02) — P02 depends on the
Node.Kind/OSfields + migration 0006 from P01.parallelization.enabled=false→ sequential. - Wave 2 (Phase 3): Doctor extensions depend on both P01 (localhost
node + os field for
doctor os) and P02 (proxmox nodes + SSH client fordoctor proxmox). - Wave 3 (Phase 4): Final review + ship + audit — covers all execution phases.
For v0.6, parallelization.enabled=false — phases run sequentially.
Versioning
- Milestone type:
feature(P01/P02/P03 shipfeatphases) - Patch per phase:
v0.5.0(P0),v0.5.1(P01),v0.5.2(P02),v0.5.3(P03),v0.5.4(P04 final = milestone release) - Tags run on the previous minor's patch line (v0.5.x) per branch-strategy.md
- Milestone branch label:
milestone/v0.6-node-bootstrap-proxmox(uses milestone number, not tag line)
Requirement Coverage Matrix
| REQ | Phase | Persona lead | Must-haves |
|---|---|---|---|
| REQ-047 | P01 | backend-engineer | init.go full bootstrap (CA + cert + db + localhost node, idempotent) |
| REQ-048 | P01 | backend-engineer + cli-engineer | detectOS() from /etc/os-release + localhost node registration |
| REQ-049 | P01 | data-engineer | migration 0006 + Node.Kind/OS + NodeRepo schema extension |
| REQ-050 | P02 | security-engineer + backend-engineer | proxmox.BootstrapProxmox SSH dance + sshkey.go + certpaths SSH paths |
| REQ-051 | P02 | security-engineer | OrcaOperator PVE role + orca@pam user + sudoers NOEXEC design |
| REQ-052 | P03 | backend-engineer + security-engineer | doctor OS() + Proxmox() + audit logging of all bootstrap/join actions |