diff --git a/.ciagent/CHECKPOINT.json b/.ciagent/CHECKPOINT.json index 53ab7cf..a44bd24 100644 --- a/.ciagent/CHECKPOINT.json +++ b/.ciagent/CHECKPOINT.json @@ -1,11 +1,11 @@ { "phase": 0, - "stage": "research", + "stage": "plan", "milestone": "v0.6", "milestone_slug": "node-bootstrap-proxmox", "phase_role": "pre_execution", "attempts": 0, - "updated_at": "2026-08-03T20:30:00Z", + "updated_at": "2026-08-03T20:45:00Z", "milestone_complete": false, "next_milestone": null } \ No newline at end of file diff --git a/.ciagent/PLAN_v0.6.md b/.ciagent/PLAN_v0.6.md new file mode 100644 index 0000000..1e8ad2d --- /dev/null +++ b/.ciagent/PLAN_v0.6.md @@ -0,0 +1,236 @@ +# 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` — add `Kind string `json:"kind,omitempty"`` + `OS string `json:"os,omitempty"`` fields; add `NodeKind` constants (`NodeKindLocalhost`, `NodeKindLinux`, `NodeKindProxmox`) +- [ ] `internal/store/node_repo.go` — extend `Insert`/`Get`/`List`/`Watch`/`scanNode` for `kind, os` columns (use `sql.NullString`, map NULL → `""`); add `GetByName(ctx, name) (*Node, error)` and `UpdateLastSeenAndOS(ctx, id, os string) error` helpers +- [ ] `internal/store/node_repo_test.go` — extend tests for new columns + helpers; assert NULL → `""` mapping; assert `GetByName` returns `ErrNotFound` for missing; assert `UpdateLastSeenAndOS` refreshes `last_seen` + `os` without changing `id`/`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-release` then fall back to `/usr/lib/os-release`; parse `KEY=VALUE` lines via bufio.Scanner + strings.SplitN; strip surrounding quotes; return `ID` value 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.go` output UX — multi-step progress lines: "✓ Namespace dir: ...", "✓ Database initialized: ...", "✓ CA provisioned: ... (fp=...)", "✓ Server cert provisioned: ... (fp=...)", "✓ OS detected: ubuntu", "✓ Localhost node registered: "; `--json` outputs a single JSON summary object + +### Verification + +- `go build ./...` PASS +- `go test ./internal/store/... ./internal/cli/... ./internal/model/...` PASS +- `go test -race ./...` PASS +- `orca init` on a fresh namespace → creates dir, db, CA, server cert, localhost node; `orca doctor` passes with zero FAILs +- `orca init` re-run → no duplicate localhost node, last_seen refreshed, CA/cert not regenerated (idempotent, D-036) +- `orca init --json` → valid JSON summary +- `orca node list` shows 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` — add `golang.org/x/crypto v0.54.0`; bump `golang.org/x/sys` to v0.47.0; add `golang.org/x/term v0.45.0` (indirect). Run `go mod tidy`. +- [ ] `internal/certpaths/certpaths.go` — add `SSHKeyPath() → Dir()/orca_ssh_key`, `SSHPubPath() → Dir()/orca_ssh_key.pub`, `KnownHostsPath() → Dir()/known_hosts` +- [ ] `internal/security/sshkey.go` (NEW) — `GenerateOrLoadSSHKey(dir string) (keyPEM, pubLine []byte, err error)`: + - [ ] If `orca_ssh_key` + `.pub` exist → 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) +- [ ] `internal/security/sshkey_test.go` — test generate → load round-trip; test idempotent re-load; test file modes (0600/0644); test `ssh.ParsePrivateKey` accepts 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.ClientConfig` with `ssh.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 '' ~orca/.ssh/authorized_keys || echo '' >> ~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 pct` etc.; 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"}` +- [ ] `internal/proxmox/bootstrap_test.go` — unit tests with a mock SSH server (`httptest`-style or `net.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` — extend `nodeJoinCmd`: + - [ ] Add `--type` flag (values: `localhost` default, `linux`, `proxmox`) + - [ ] Add `--host`, `--ssh-user` (default `root`), `--password`, `--proxmox-user` (default `orca`), `--proxmox-role` (default `OrcaOperator`), `--ssh-port` (default `22`) flags + - [ ] When `--type proxmox`: validate `--host` + (`--password` or `$ORCA_PROXMOX_PASSWORD`) are set; call `proxmox.BootstrapProxmox(ctx, opts)`; insert the returned node via `NodeRepo.Insert`; print summary + - [ ] When `--type localhost` (default): existing flow (fingerprint check + registry.Join) + - [ ] Password from `--password` flag OR `$ORCA_PROXMOX_PASSWORD` env var (prefer env var per D-031; never log the password; zero the byte slice after use) +- [ ] `internal/cli/node_test.go` — test flag wiring; test `--type proxmox` validation (missing host/password → error); test env var fallback + +### Verification + +- `go build ./...` PASS +- `go test ./internal/proxmox/... ./internal/security/... ./internal/cli/...` PASS +- `go test -race ./...` PASS +- `go mod tidy` leaves no unused deps; `go.sum` has `golang.org/x/crypto v0.54.0` +- `orca node join --type proxmox --host --password ` on a real Proxmox 8/9 host: + - Creates orcaOperator role, orca@pam user, ACL, sudoers file + - `orca@pam` can `sudo pct list`, `sudo qm list`, `sudo apt-get update` without password + - `orca@pam` CANNOT `sudo pvesh` (not in sudoers) + - `orca@pam` CANNOT `sudo bash` (not in sudoers) + - `visudo -cf /etc/sudoers.d/orca` passes + - Re-running the join command is idempotent (no duplicate role/user/ACL/sudoers/key) +- `orca node list` shows the proxmox node with kind=proxmox, os=pve +- Audit log contains `proxmox.bootstrap_ok` entry with host, user, role +- `~/.orca/orca_ssh_key` is 0600, `.pub` is 0644, `known_hosts` contains 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` — add `OS()` check: + - Re-run `detectOS()` (from `internal/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-run `orca init` to refresh"); match → PASS + - Missing localhost node → FAIL ("no localhost node — run `orca init`") +- [ ] `internal/doctor/doctor.go` — add `Proxmox()` check (clone `Network()` pattern): + - List nodes from `NodeRepo`, filter `kind == "proxmox"` + - Zero proxmox nodes → WARN ("no proxmox nodes registered (single-node?)") + - Per node: load orca SSH key, build `ssh.ClientConfig` with `ssh.PublicKeys(signer)` + `knownhosts.New`, dial with 3s timeout, run `pveversion` via session + - PASS = reachable + pveversion exits 0; FAIL = unreachable or pveversion fails + - Accumulate per-node lines (clone `Network()`'s `lines []string` pattern) +- [ ] `internal/doctor/doctor.go` — extend `All()` to include `OS()` and `Proxmox()` +- [ ] `internal/doctor/doctor_test.go` — test `OS()` with mock node repo (drift, match, missing); test `Proxmox()` with mock nodes (zero nodes → WARN, reachable → PASS, unreachable → FAIL) + +#### cli-engineer territory +- [ ] `internal/cli/doctor.go` — add `doctorOSCmd` + `doctorProxmoxCmd` subcommands wired to `doctor.OS()` / `doctor.Proxmox()`; add to `doctorCmd.AddCommand(...)` +- [ ] `internal/cli/doctor.go` — `doctor os` and `doctor proxmox` honor `--json` flag (reuse existing pattern) + +#### security-engineer territory +- [ ] `internal/audit/audit.go` (extend) — emit `proxmox.bootstrap_ok`, `proxmox.bootstrap_fail`, `node.os_drift` events with structured slog fields +- [ ] Audit log entries for all bootstrap + join actions (REQ-052): `orca init` emits `init.bootstrap_ok` (os, node_id, ca_fp); `orca node join --type proxmox` emits `proxmox.bootstrap_ok` (host, user, role); `doctor os` drift emits `node.os_drift` (init_os, current_os) + +### Verification + +- `go build ./...` PASS +- `go test ./internal/doctor/... ./internal/cli/...` PASS +- `go test -race ./...` PASS +- `orca doctor` (after `orca 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 node +- `orca doctor proxmox` (PVE host down) → FAIL per node with error message +- Audit log contains `init.bootstrap_ok` and `proxmox.bootstrap_ok` entries +- `--json` output for `doctor os` and `doctor proxmox` is 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 +- [ ] **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) +- [ ] **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` + +### Verification + +- `git log --oneline main..milestone/v0.6` shows all phase commits in order +- `git tag --list v0.5.*` shows v0.5.0..v0.5.4 +- `main` branch contains all v0.6 work (fast-forward merge) +- `orca init && orca doctor` on a fresh checkout passes end-to-end +- Gitea release `v0.5.4` exists 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`/ + `OS` fields + 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 + for `doctor 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 ship `feat` phases) +- **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 | \ No newline at end of file