Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 561bf61317 | |||
| f7902dddda | |||
| f022ef5395 | |||
| 7c4b603811 | |||
| fc034218e3 | |||
| bd4a34daa2 | |||
| 55d4d699a3 | |||
| 7cfc4b7027 | |||
| f66472fd37 |
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"phase": 2,
|
"phase": 0,
|
||||||
"stage": "verify",
|
"stage": "plan",
|
||||||
"milestone": "v0.6",
|
"milestone": "v0.7",
|
||||||
"milestone_slug": "node-bootstrap-proxmox",
|
"milestone_slug": "hardening-completion",
|
||||||
"phase_role": "execution",
|
"phase_role": "pre_execution",
|
||||||
"attempts": 0,
|
"attempts": 0,
|
||||||
"updated_at": "2026-08-03T19:57:00Z",
|
"updated_at": "2026-08-03T20:55:00Z",
|
||||||
"milestone_complete": false,
|
"milestone_complete": false,
|
||||||
"next_milestone": null
|
"next_milestone": null
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# Ideation: Orca v0.7 — Hardening & Completion
|
||||||
|
|
||||||
|
Full autonomy mode: all ideas with confidence >= 0.60 are auto-accepted.
|
||||||
|
The RESEARCH stage (commit `7c4b603`) surfaced 5 codebase gaps which are
|
||||||
|
assessed below alongside 8 additional ideas generated by the 3-tier
|
||||||
|
ideation process.
|
||||||
|
|
||||||
|
Total generated: 13 ideas (5 Tier 1 + 5 Tier 2 + 3 Tier 3) plus 5
|
||||||
|
inherited research findings = 18 considered. 13 accepted (all >= 0.60),
|
||||||
|
0 skipped, 0 deferred. 4 of the accepted ideas are implementation
|
||||||
|
refinements with no new REQ; 4 map to the v0.7 REQs (REQ-053..056)
|
||||||
|
already declared in SPECIFY; the research findings confirmed the v0.7
|
||||||
|
scope.
|
||||||
|
|
||||||
|
## Tier 1: Mechanical Analysis (git + filesystem)
|
||||||
|
|
||||||
|
### 1.1 Git-Native Pattern Mining
|
||||||
|
|
||||||
|
- `git log --all --grep="lessons:"` — 1 lesson found (orch-engine P00
|
||||||
|
config.json schema reference). No repeated lessons in orca's own
|
||||||
|
history → no systemic process gap.
|
||||||
|
- `git log --all --grep="escalation:"` — 0 escalations. The pipeline
|
||||||
|
has run clean across v0.1–v0.6.
|
||||||
|
- `git log --all --grep="compound:"` — 0 compound learnings.
|
||||||
|
- Low-confidence decisions (confidence < 0.7): none in `---ci---`
|
||||||
|
blocks. The lowest-confidence v0.7 decision is D-040 (pprof) at 0.85,
|
||||||
|
above threshold.
|
||||||
|
|
||||||
|
### 1.2 Coverage Gap Analysis
|
||||||
|
|
||||||
|
| ID | Idea | Source | Confidence | Status | Maps to |
|
||||||
|
|----|------|--------|------------|--------|---------|
|
||||||
|
| I-401 | `orca cert` command tree is unreachable — `NewCommand` in `internal/cli/cert.go` is never AddCommand'd to `rootCmd` | research §1.1 + `grep -rn "rootCmd.AddCommand"` (cert absent) | 0.98 | Accepted | REQ-053 |
|
||||||
|
| I-402 | `internal/store/cert_repo.go` has no test file — every other repo has one | research §1.2 + `ls internal/store/*_test.go` | 0.95 | Accepted | REQ-053 (P01 companion) |
|
||||||
|
| I-403 | `internal/engine` coverage 8.3% — only `scheduler_test.go` exists; executor, dispatcher, peer untested | research §1.3 + `go test -cover` | 0.90 | Accepted | REQ-055 |
|
||||||
|
| I-404 | `internal/transport` coverage 26.3% — only `idempotency_test.go`; mtls, dispatch, handshake_log untested | research §1.3 | 0.90 | Accepted | REQ-055 |
|
||||||
|
| I-405 | `internal/audit` has no test files — Emit, EmitWithErr, LogHandshake* untested | research §1.3 + `ls internal/audit/*_test.go` | 0.88 | Accepted | REQ-055 |
|
||||||
|
|
||||||
|
### 1.3 Verification Layer Inversion (missing items)
|
||||||
|
|
||||||
|
- **Structural**: `internal/cli/cert.go` defines a command that is
|
||||||
|
never wired in — a "documented but unreachable" component (I-401).
|
||||||
|
- **Behavioral**: 4 packages below 50% coverage (I-403/404/405 + proxmox).
|
||||||
|
- **Security**: no STRIDE gap — v0.7 adds no new trust boundary (pprof
|
||||||
|
is operator-only, addr-gated; cert registration exposes existing
|
||||||
|
security code).
|
||||||
|
- **Quality**: no unresolved P1/P2 findings from v0.6 final review.
|
||||||
|
|
||||||
|
## Tier 2: Backend-Enriched Analysis
|
||||||
|
|
||||||
|
| ID | Idea | Source | Confidence | Status | Maps to |
|
||||||
|
|----|------|--------|------------|--------|---------|
|
||||||
|
| I-406 | HCL config file parser — `internal/config` package reusing `hclsimple.Decode` pattern from jobspec; D-009 promised it, never built | research §1.4 + D-009 | 0.92 | Accepted | REQ-054 |
|
||||||
|
| I-407 | `--pprof <addr>` opt-in on `orca daemon` — I-308 deferred since v0.2; stdlib only, separate mux | research §1.5 + I-308 | 0.82 | Accepted | REQ-056 |
|
||||||
|
| I-408 | Config precedence flag>env>file>default — table-driven test covering all 4 layers | backend-enriched (D-039) | 0.90 | Accepted | (refinement of REQ-054; no new REQ) |
|
||||||
|
| I-409 | pprof on separate `*http.Server` + `*http.ServeMux`, never on mTLS daemon listener | backend-enriched (AD-024) | 0.90 | Accepted | (refinement of REQ-056; no new REQ) |
|
||||||
|
| I-410 | CI coverage gate: `go test -cover ./internal/engine ./internal/transport ./internal/proxmox ./internal/audit` assert each ≥ 50% | backend-enriched (AD-025) | 0.85 | Accepted | (refinement of REQ-055; no new REQ) |
|
||||||
|
|
||||||
|
## Tier 3: Cross-Project Pattern Transfer
|
||||||
|
|
||||||
|
| ID | Idea | Source | Confidence | Status | Maps to |
|
||||||
|
|----|------|--------|------------|--------|---------|
|
||||||
|
| I-411 | `orca version --json` already outputs structured `{version, commit, go_version, build_time}` (I-307 accepted v0.2) — verify still works, no new REQ | cross-project (carry-forward from v0.2 I-307) | 0.80 | Accepted (verification only) | (no new REQ; confirm in P03) |
|
||||||
|
| I-412 | `orca cert` registration via `init()` co-located in `cert.go` — matches the self-registering pattern in `daemon.go`/`audit.go` | cross-project (orca's own convention) | 0.88 | Accepted | (refinement of REQ-053; no new REQ) |
|
||||||
|
| I-413 | No new direct dependencies in v0.7 — `net/http/pprof` (stdlib), `hashicorp/hcl/v2` (already direct) | cross-project (minimal-deps ethos) | 0.95 | Accepted | (constraint; no new REQ) |
|
||||||
|
|
||||||
|
## Research-stage findings (assessed)
|
||||||
|
|
||||||
|
| Finding | Verdict | Maps to |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| cert command unreachable (§1.1) | **Accepted** (I-401) | REQ-053 (P01) |
|
||||||
|
| cert_repo has no test (§1.2) | **Accepted** (I-402) | REQ-053 (P01) |
|
||||||
|
| low coverage: engine/transport/proxmox/audit (§1.3) | **Accepted** (I-403/404/405) | REQ-055 (P03) |
|
||||||
|
| no HCL config parser (§1.4) | **Accepted** (I-406) | REQ-054 (P02) |
|
||||||
|
| pprof deferred since v0.2 (§1.5) | **Accepted** (I-407) | REQ-056 (P04) |
|
||||||
|
|
||||||
|
All 5 findings map to the v0.7 REQs declared in SPECIFY. The IDEATE
|
||||||
|
stage confirms the scope and adds 8 implementation refinements
|
||||||
|
(I-408..I-413) that inform the PLAN stage.
|
||||||
|
|
||||||
|
## Dropped ideas (confidence < 0.60)
|
||||||
|
|
||||||
|
None. The lowest-confidence accepted idea is I-407 (pprof) at 0.82.
|
||||||
|
|
||||||
|
## Accepted Ideas (auto-accepted, full autonomy)
|
||||||
|
|
||||||
|
13 ideas accepted (5 Tier 1 + 5 Tier 2 + 3 Tier 3). 4 map to net-new
|
||||||
|
REQs (REQ-053..056, already declared in SPECIFY); 9 are implementation
|
||||||
|
refinements recorded for the PLAN stage's benefit.
|
||||||
|
|
||||||
|
## Resulting REQ additions
|
||||||
|
|
||||||
|
| New REQ | Title | Phase | Source ideas |
|
||||||
|
|---------|-------|-------|--------------|
|
||||||
|
| REQ-053 | `orca cert` command tree registered + cert_repo tests | P01 | I-401, I-402, I-412 |
|
||||||
|
| REQ-054 | HCL config file parsing (`internal/config`) | P02 | I-406, I-408 |
|
||||||
|
| REQ-055 | Test coverage uplift — engine/transport/proxmox/audit ≥ 50% | P03 | I-403, I-404, I-405, I-410 |
|
||||||
|
| REQ-056 | `--pprof <addr>` opt-in on `orca daemon` | P04 | I-407, I-409 |
|
||||||
|
|
||||||
|
**Total net-new REQs**: 4 (REQ-053..056). All declared in SPECIFY;
|
||||||
|
IDEATE confirms mapping and adds implementation refinements.
|
||||||
|
|
||||||
|
## Deferred (recorded but not v0.7)
|
||||||
|
|
||||||
|
None. I-308 (pprof) is no longer deferred — it is REQ-056 in P04.
|
||||||
|
|
||||||
|
## Followup notes for PLAN stage
|
||||||
|
|
||||||
|
- **P01** is the highest-impact, lowest-effort phase: a 1-line
|
||||||
|
`rootCmd.AddCommand` + a regression test + cert_repo_test.go. The
|
||||||
|
smoke test should run `cert ca-init` + `cert gen` + `cert show` +
|
||||||
|
`cert fingerprint` against a temp `ORCA_HOME` to catch any latent
|
||||||
|
bugs in the never-exercised cert subcommands.
|
||||||
|
- **P02** config package must be a pure function (`Load(paths) ->
|
||||||
|
*Config`) with no package-level state. The `--config` flag on root
|
||||||
|
command loads the file and passes the merged `*Config` down via
|
||||||
|
cobra's `cmd.SetContext` or a struct field on the command.
|
||||||
|
- **P03** coverage: target the interface seams (SSH dialer, peer
|
||||||
|
client) for mocks; use `httptest.NewTLSServer` for transport. Any
|
||||||
|
races uncovered by `-race` get fixed in P03, not deferred.
|
||||||
|
- **P04** pprof: keep the daemon's mTLS listener untouched; start a
|
||||||
|
second `http.Server` only when `--pprof` is non-empty. Log a WARN
|
||||||
|
that the endpoint is unauthenticated.
|
||||||
+16
-12
@@ -2,26 +2,30 @@
|
|||||||
active_personas:
|
active_personas:
|
||||||
- lead-developer
|
- lead-developer
|
||||||
- backend-engineer
|
- backend-engineer
|
||||||
- cli-engineer
|
|
||||||
- data-engineer
|
- data-engineer
|
||||||
- security-engineer
|
|
||||||
deactivated_personas:
|
deactivated_personas:
|
||||||
|
- cli-engineer
|
||||||
|
- security-engineer
|
||||||
- devops-engineer
|
- devops-engineer
|
||||||
- network-engineer
|
- network-engineer
|
||||||
- frontend-engineer
|
- frontend-engineer
|
||||||
phase_specific: []
|
phase_specific: []
|
||||||
reason: |
|
reason: |
|
||||||
Orca v0.6 is a bootstrap-ergonomics + heterogeneous-nodes milestone.
|
Orca v0.7 is an NFR hardening & completion milestone. The work is CLI
|
||||||
The work is schema (migration 0006), security (SSH keygen, TOFU,
|
registration (cert command), a new internal/config package, test
|
||||||
sudoers, PVE role), CLI (init full bootstrap, node join --type proxmox,
|
coverage uplift across engine/transport/proxmox/audit, and an opt-in
|
||||||
doctor os/proxmox), and backend orchestration (proxmox SSH bootstrap
|
pprof endpoint on the daemon. No schema changes, no new security
|
||||||
sequence). No devops (no install/docker/release), no network (no
|
surface, no packaging/distribution, no UI.
|
||||||
transport/mTLS), no frontend (no UI).
|
|
||||||
|
|
||||||
Roster changes vs v0.5:
|
Roster changes vs v0.6:
|
||||||
- data-engineer: REACTIVATED — owns migration 0006 + NodeRepo schema extension.
|
- data-engineer: RETAINED — owns cert_repo tests + store coverage.
|
||||||
- security-engineer: REACTIVATED — owns SSH keygen, TOFU host-key, sudoers, PVE role.
|
- security-engineer: DEACTIVATED — v0.7 adds no new security surface
|
||||||
- devops-engineer: DEACTIVATED — v0.6 has no packaging/distribution surface.
|
(pprof is operator-only, addr-gated; cert registration exposes
|
||||||
|
existing security code, does not add new).
|
||||||
|
- cli-engineer: DEACTIVATED — merged into lead-developer for v0.7
|
||||||
|
(the cert registration is a 1-line AddCommand; config --config flag
|
||||||
|
is root-command wiring, not a new CLI subsystem).
|
||||||
|
- devops-engineer: DEACTIVATED — no packaging/distribution in v0.7.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Personas: Orca
|
# Personas: Orca
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Phase 3 Verification — Orca v0.6 P03
|
||||||
|
|
||||||
|
**Phase**: P03 — Doctor Extensions + Audit Logging
|
||||||
|
**REQ Coverage**: REQ-052
|
||||||
|
**Verification date**: 2026-08-03
|
||||||
|
**Result**: ✅ PASS (all 4 layers)
|
||||||
|
|
||||||
|
## Structural Verification
|
||||||
|
|
||||||
|
- ✅ `go build ./...` — PASS
|
||||||
|
- ✅ `go vet ./...` — PASS
|
||||||
|
- ✅ `gofmt -l .` — PASS
|
||||||
|
- ✅ `make lint` — PASS
|
||||||
|
- ✅ `internal/osdetect` new shared package (extracted from cli to avoid import cycle)
|
||||||
|
- ✅ `doctor.OS()` and `doctor.Proxmox()` follow existing check pattern (Check struct, Result, Run func)
|
||||||
|
- ✅ `doctor.All()` extended with OS + Proxmox in logical order
|
||||||
|
|
||||||
|
## Behavioral Verification
|
||||||
|
|
||||||
|
### REQ-052: doctor os + doctor proxmox + audit logging
|
||||||
|
- ✅ `TestOSCheck_MissingLocalhostNode`: no localhost node → FAIL with clear message
|
||||||
|
- ✅ `TestOSCheck_Match`: stored os matches detected → PASS
|
||||||
|
- ✅ `TestOSCheck_Drift`: stored os differs from detected → WARN ("OS drift: init=debian, now=ubuntu")
|
||||||
|
- ✅ `TestProxmoxCheck_NoProxmoxNodes`: zero proxmox nodes → WARN ("no proxmox nodes registered")
|
||||||
|
- ✅ `TestProxmoxCheck_UnreachableNode`: unreachable proxmox node → FAIL with node name
|
||||||
|
- ✅ E2E: `orca doctor os` → PASS (os=ubuntu matches)
|
||||||
|
- ✅ E2E: `orca doctor proxmox` → WARN (no proxmox nodes)
|
||||||
|
- ✅ E2E: `orca doctor os --json` → valid JSON
|
||||||
|
- ✅ E2E: `orca doctor` (full) → 6 PASS / 1 WARN / 1 FAIL (network=daemon not running, expected)
|
||||||
|
- ✅ osdetect package: 11 tests (ubuntu/debian/alpine/pve parsing, quoted/unquoted, missing ID, comments, fallback)
|
||||||
|
- ✅ Audit logging: proxmox.BootstrapProxmox emits `proxmox.bootstrap_ok` (P02); doctor checks are read-only
|
||||||
|
|
||||||
|
## Security Verification
|
||||||
|
|
||||||
|
- ✅ Doctor checks are strictly read-only (no state changes)
|
||||||
|
- ✅ SSH probe uses orca SSH key (not password) — no password in doctor flow
|
||||||
|
- ✅ TOFU host-key verification via knownhosts.New (D-035)
|
||||||
|
- ✅ 3s timeout per proxmox probe (D-038 bounded-probe-timeout pattern)
|
||||||
|
- ✅ No secrets in doctor output (fingerprints only, never private keys)
|
||||||
|
|
||||||
|
## Quality Verification
|
||||||
|
|
||||||
|
- ✅ `go test -race -count=1 ./...` — all PASS (13 packages)
|
||||||
|
- ✅ Test coverage: osdetect (11 tests), doctor OS (3 tests), doctor Proxmox (2 tests)
|
||||||
|
- ✅ Error wrapping with `fmt.Errorf("...: %w", err)` (REQ-018)
|
||||||
|
- ✅ `context.Context` propagation (REQ-017)
|
||||||
|
- ✅ No goroutine leaks (netDialer cleans up on ctx cancellation)
|
||||||
|
- ✅ D-036: doctor os handles pre-0006 rows (empty os field → WARN)
|
||||||
|
|
||||||
|
## Must-Have Checklist
|
||||||
|
|
||||||
|
- [x] `internal/osdetect/osdetect.go` — Detect + ParseID (shared package)
|
||||||
|
- [x] `internal/osdetect/osdetect_test.go` — 11 tests
|
||||||
|
- [x] `internal/cli/osdetect.go` — thin wrapper
|
||||||
|
- [x] `internal/cli/osdetect_test.go` — delegation test
|
||||||
|
- [x] `internal/doctor/doctor.go` — OS() + Proxmox() checks, All() extended
|
||||||
|
- [x] `internal/doctor/doctor_test.go` — 5 new tests
|
||||||
|
- [x] `internal/cli/doctor.go` — doctor os + doctor proxmox subcommands
|
||||||
|
|
||||||
|
## Escalations
|
||||||
|
|
||||||
|
None.
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
# Phase Plans: Orca v0.7 — Hardening & Completion
|
||||||
|
|
||||||
|
All 4 execution phases + final review with vertical-slice structure, wave
|
||||||
|
ordering, and REQ-ID mapping. v0.7 scope: **Hardening & Completion** —
|
||||||
|
register the unreachable `orca cert` command, add HCL config file parsing,
|
||||||
|
uplift test coverage in core packages, and add the long-deferred pprof
|
||||||
|
endpoint.
|
||||||
|
|
||||||
|
Branching: `phase/01-cert-register`..`phase/05-final-review-ship` on the
|
||||||
|
`milestone/v0.7-hardening-completion` branch (numbering restarts per
|
||||||
|
milestone per branch-strategy.md).
|
||||||
|
|
||||||
|
Milestone type: **NFR** (all phases are fix/test/chore; no `feat` phases).
|
||||||
|
Tags run on the v0.6.x patch line: `v0.6.0` (P0) … `v0.6.5` (P05 =
|
||||||
|
milestone release).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Register `orca cert` Command Tree + cert_repo Tests (Wave 1)
|
||||||
|
|
||||||
|
**Branch**: `phase/01-cert-register`
|
||||||
|
**REQ Coverage**: REQ-053
|
||||||
|
**Persona leads**: lead-developer (cert registration + smoke test), data-engineer (cert_repo tests)
|
||||||
|
**Source ideas**: I-401, I-402, I-412
|
||||||
|
|
||||||
|
### Must-Haves
|
||||||
|
|
||||||
|
#### lead-developer territory
|
||||||
|
- [ ] `internal/cli/cert.go` — add `init()` that calls `rootCmd.AddCommand(NewCommand(slog.Default()))`. This is the one-line fix that makes the entire `cert ca-init | gen | show | renew | fingerprint` tree reachable. (AD-022)
|
||||||
|
- [ ] `internal/cli/cert_test.go` (NEW) — regression test asserting `rootCmd.Commands()` contains a child whose `Use == "cert"`; assert each subcommand (`ca-init`, `gen`, `show`, `renew`, `fingerprint`) is present on the cert child.
|
||||||
|
- [ ] `internal/cli/cert_smoke_test.go` (NEW) — end-to-end smoke test against a temp `ORCA_HOME`:
|
||||||
|
- [ ] `orca cert ca-init --cn test-ca` → succeeds, `ca.crt` + `ca.key` exist with modes 0644/0600
|
||||||
|
- [ ] `orca cert gen --cn test-server --san localhost --san 127.0.0.1` → succeeds, `server.crt` + `server.key` exist with modes 0644/0600
|
||||||
|
- [ ] `orca cert show` → outputs PEM with no `PRIVATE KEY` blocks (REQ-035 redaction)
|
||||||
|
- [ ] `orca cert fingerprint --which ca` → outputs a 64-char hex SHA-256
|
||||||
|
- [ ] `orca cert fingerprint --which server` → outputs a 64-char hex SHA-256
|
||||||
|
- [ ] `orca cert renew` → succeeds, server cert file mtime updates
|
||||||
|
- [ ] `internal/cli/root_test.go` — extend the existing root test to assert `orca cert` is in the command tree (belt-and-suspenders with cert_test.go)
|
||||||
|
|
||||||
|
#### data-engineer territory
|
||||||
|
- [ ] `internal/store/cert_repo_test.go` (NEW) — table-driven tests for `CertRepo`:
|
||||||
|
- [ ] `Insert` a cert row → `Get` by serial returns matching row
|
||||||
|
- [ ] `Insert` duplicate `serial_hex` → returns error (UNIQUE constraint, I-107)
|
||||||
|
- [ ] `List` returns certs ordered by `issued_at desc`
|
||||||
|
- [ ] Rotation history: Insert 4 certs for the same node → only last N=3 retained (REQ-025); oldest is pruned
|
||||||
|
- [ ] `GetActive` returns the most-recent cert for a node
|
||||||
|
- [ ] `Delete` removes a cert by serial
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
- `go build ./...` PASS
|
||||||
|
- `go vet ./...` PASS
|
||||||
|
- `go test ./internal/cli/... ./internal/store/...` PASS
|
||||||
|
- `go test -race ./...` PASS
|
||||||
|
- `./bin/orca cert` → prints help (no longer "unknown command")
|
||||||
|
- `./bin/orca cert ca-init` on a temp `ORCA_HOME` → succeeds
|
||||||
|
- `./bin/orca cert show` → no private key material in output (REQ-035)
|
||||||
|
- cert_repo_test.go covers Insert/Get/List/rotation-prune/duplicate-serial
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: HCL Config File Parsing (Wave 1)
|
||||||
|
|
||||||
|
**Branch**: `phase/02-config-parser`
|
||||||
|
**REQ Coverage**: REQ-054
|
||||||
|
**Persona leads**: backend-engineer (config package), lead-developer (root command --config flag wiring)
|
||||||
|
**Source ideas**: I-406, I-408
|
||||||
|
**Depends on**: Phase 1 (cert registration lands first so the CLI surface is complete before config extends it)
|
||||||
|
|
||||||
|
### Must-Haves
|
||||||
|
|
||||||
|
#### backend-engineer territory
|
||||||
|
- [ ] `internal/config/config.go` (NEW package) — `Config` struct with HCL tags:
|
||||||
|
- [ ] `DBPath string `hcl:"db_path,optional"``
|
||||||
|
- [ ] `ListenAddr string `hcl:"listen_addr,optional"``
|
||||||
|
- [ ] `CAPath string `hcl:"ca_path,optional"``
|
||||||
|
- [ ] `ServerCertPath string `hcl:"server_cert_path,optional"``
|
||||||
|
- [ ] `ServerKeyPath string `hcl:"server_key_path,optional"``
|
||||||
|
- [ ] `NodeCapacity *CapacityConfig `hcl:"node_capacity,block"` (optional block)
|
||||||
|
- [ ] `Load(paths ...string) (*Config, error)` — loads the first existing file from `paths` via `hclsimple.Decode` (reuse the jobspec pattern, `internal/jobspec/spec.go:40`); returns a zero-value `Config` if no file exists (no error)
|
||||||
|
- [ ] `(*Config).MergeOverrides(flags Flags, env Environ) *Config` — applies precedence flag > env > file > default (D-039). Only non-zero flag values override; only set env vars override; file values are the base; missing fields fall back to `certpaths.*` defaults.
|
||||||
|
- [ ] No package-level state (AD-023). `Load` is a pure function.
|
||||||
|
- [ ] `internal/config/config_test.go` (NEW) — table-driven tests:
|
||||||
|
- [ ] Load from a valid HCL file → all fields populated
|
||||||
|
- [ ] Load from a missing file → zero Config, no error
|
||||||
|
- [ ] Load from a malformed HCL file → error
|
||||||
|
- [ ] MergeOverrides: flag wins over env wins over file wins over default (all 4 layers exercised)
|
||||||
|
- [ ] MergeOverrides: empty flag does NOT override a set env value
|
||||||
|
- [ ] MergeOverrides: empty env does NOT override a set file value
|
||||||
|
- [ ] Optional `node_capacity` block parsed correctly
|
||||||
|
|
||||||
|
#### lead-developer territory
|
||||||
|
- [ ] `internal/cli/root.go` — add `--config string` persistent flag (default `""`). In `PersistentPreRunE`, if `--config` is set, call `config.Load(flag)` and stash the `*Config` in `cmd.Context()` via a context key. If `--config` is empty, `config.Load` is not called (zero overhead; existing flag/env behavior unchanged).
|
||||||
|
- [ ] `internal/cli/daemon.go` — in the daemon command, if a `*Config` is present in the context, use `cfg.ListenAddr` as the default addr (flag still overrides per D-039).
|
||||||
|
- [ ] `internal/cli/root_test.go` — extend with `--config <tmpfile>` test: pass a config file, assert the merged values reach the daemon command.
|
||||||
|
- [ ] `testdata/config.hcl` (NEW) — example config file for tests:
|
||||||
|
```hcl
|
||||||
|
db_path = "/tmp/orca/test.db"
|
||||||
|
listen_addr = "127.0.0.1:9999"
|
||||||
|
ca_path = "/tmp/orca/ca.crt"
|
||||||
|
server_cert_path = "/tmp/orca/server.crt"
|
||||||
|
server_key_path = "/tmp/orca/server.key"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
- `go build ./...` PASS
|
||||||
|
- `go vet ./...` PASS
|
||||||
|
- `go test ./internal/config/... ./internal/cli/...` PASS
|
||||||
|
- `go test -race ./...` PASS
|
||||||
|
- `./bin/orca --config testdata/config.hcl daemon --help` → no error
|
||||||
|
- Precedence test: flag value overrides config file value for the same key
|
||||||
|
- No new direct deps (`hashicorp/hcl/v2` already in go.mod)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Test Coverage Uplift (Wave 1)
|
||||||
|
|
||||||
|
**Branch**: `phase/03-coverage-uplift`
|
||||||
|
**REQ Coverage**: REQ-055
|
||||||
|
**Persona leads**: lead-developer (engine/transport/audit tests), data-engineer (store coverage)
|
||||||
|
**Source ideas**: I-403, I-404, I-405, I-410
|
||||||
|
**Depends on**: Phase 1 + Phase 2 (tests build on the now-reachable cert tree + config package)
|
||||||
|
|
||||||
|
### Must-Haves
|
||||||
|
|
||||||
|
#### lead-developer territory — internal/engine
|
||||||
|
- [ ] `internal/engine/executor_test.go` (NEW) — test `Executor.Start`/`Wait` lifecycle:
|
||||||
|
- [ ] Start a command (`/bin/echo hello`) → Wait → exit code 0, stdout captured
|
||||||
|
- [ ] Start a failing command (`/bin/false`) → exit code non-zero
|
||||||
|
- [ ] Cancel via ctx → process killed, `WaitDelay` honored (REQ-021)
|
||||||
|
- [ ] Env propagation: `Env=["FOO=bar"]` → child process sees `FOO=bar`
|
||||||
|
- [ ] `internal/engine/dispatcher_test.go` (NEW) — test `Dispatcher.Submit`/`Dispatch`:
|
||||||
|
- [ ] Submit a job → dispatched to the correct peer (mock peer client)
|
||||||
|
- [ ] Idempotency key present → retry on transient failure (mock returns error twice then succeeds)
|
||||||
|
- [ ] Idempotency key absent → no retry (REQ-037)
|
||||||
|
- [ ] Bounded queue backpressure: fill the channel → Submit blocks (with timeout assertion)
|
||||||
|
- [ ] `internal/engine/peer_test.go` (NEW) — test the peer HTTP client:
|
||||||
|
- [ ] `httptest.NewTLSServer` mock → peer client POSTs a dispatch request
|
||||||
|
- [ ] TLS handshake failure → structured error with `peer` + `err` fields
|
||||||
|
|
||||||
|
#### lead-developer territory — internal/transport
|
||||||
|
- [ ] `internal/transport/mtls_test.go` (NEW) — test mTLS handshake:
|
||||||
|
- [ ] `httptest.NewTLSServer` with a test CA → client with valid cert handshakes OK
|
||||||
|
- [ ] Client with expired cert → handshake fails with `event=mtls.handshake` log assertion
|
||||||
|
- [ ] Client with wrong CA → handshake fails
|
||||||
|
- [ ] `internal/transport/dispatch_test.go` (NEW) — test `Dispatch` RPC:
|
||||||
|
- [ ] Successful dispatch → 200 OK
|
||||||
|
- [ ] Dispatch with `X-Orca-Idempotency-Key` → idempotent
|
||||||
|
- [ ] Dispatch without key → 400 (per REQ-037)
|
||||||
|
- [ ] `internal/transport/handshake_log_test.go` (NEW) — assert `LogHandshakeOK`/`LogHandshakeFailed` emit the correct slog fields (`event`, `peer`, `cert_fp`, `err`)
|
||||||
|
|
||||||
|
#### lead-developer territory — internal/audit
|
||||||
|
- [ ] `internal/audit/audit_test.go` (NEW) — test the `Audit` wrapper:
|
||||||
|
- [ ] `Emit` with `ActionCertIssued` + `ResultSuccess` → `engine.Record` called with correct args (mock `engine.Audit`)
|
||||||
|
- [ ] `EmitWithErr` → `engine.Record` called with `result=failure` + err in metadata
|
||||||
|
- [ ] `LogHandshakeOK` → slog output contains `event=mtls.handshake`, `result=ok`, `peer`, `cert_fp`
|
||||||
|
- [ ] `LogHandshakeFailed` → slog output contains `result=failed` + `err`
|
||||||
|
- [ ] Nil-safe: `(*Audit)(nil).Emit(...)` → no panic
|
||||||
|
|
||||||
|
#### data-engineer territory — internal/proxmox
|
||||||
|
- [ ] `internal/proxmox/bootstrap_test.go` — extend the existing test:
|
||||||
|
- [ ] Mock the `sshDialer` interface (already present at `bootstrap.go:211`) → assert the full bootstrap sequence calls the right shell commands in order (user create, role create, role assign, sudoers drop, pubkey deploy)
|
||||||
|
- [ ] Idempotent re-run: mock returns "already exists" for user create → bootstrap succeeds without re-creating
|
||||||
|
- [ ] SSH auth failure → bootstrap returns wrapped error
|
||||||
|
- [ ] Assert no password is logged (D-031)
|
||||||
|
|
||||||
|
#### CI gate (I-410)
|
||||||
|
- [ ] `.coreci.yml` — add a `coverage-gate` step in the `test` pipeline that runs `go test -cover ./internal/engine ./internal/transport ./internal/proxmox ./internal/audit` and fails if any package < 50% (AD-025). Use a small shell snippet + `awk`/`grep` to parse coverage percentages.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
- `go build ./...` PASS
|
||||||
|
- `go test -race ./...` PASS
|
||||||
|
- `go test -cover ./internal/engine` → ≥ 50% (was 8.3%)
|
||||||
|
- `go test -cover ./internal/transport` → ≥ 50% (was 26.3%)
|
||||||
|
- `go test -cover ./internal/proxmox` → ≥ 50% (was 5.1%)
|
||||||
|
- `go test -cover ./internal/audit` → ≥ 50% (was 0%)
|
||||||
|
- CI coverage gate step passes
|
||||||
|
- Any races uncovered by `-race` are fixed in this phase (not deferred)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: `--pprof` Opt-in on `orca daemon` (Wave 1)
|
||||||
|
|
||||||
|
**Branch**: `phase/04-pprof-daemon`
|
||||||
|
**REQ Coverage**: REQ-056
|
||||||
|
**Persona leads**: lead-developer (daemon flag + pprof server)
|
||||||
|
**Source ideas**: I-407, I-409
|
||||||
|
**Depends on**: Phase 3 (daemon tests exist; pprof adds a new daemon path)
|
||||||
|
|
||||||
|
### Must-Haves
|
||||||
|
|
||||||
|
#### lead-developer territory
|
||||||
|
- [ ] `internal/daemon/pprof.go` (NEW) — `StartPprof(addr string, log *slog.Logger) (*http.Server, error)`:
|
||||||
|
- [ ] Create a dedicated `*http.ServeMux` (NOT `http.DefaultServeMux`)
|
||||||
|
- [ ] `import _ "net/http/pprof"` → register `pprof.Index`, `pprof.Cmdline`, `pprof.Profile`, `pprof.Symbol`, `pprof.Trace`, `pprof.Handler` on the dedicated mux
|
||||||
|
- [ ] Return a `*http.Server` listening on `addr` with the dedicated mux
|
||||||
|
- [ ] Log a WARN: `pprof endpoint exposed unauthenticated on <addr> — operator-only, do not expose publicly`
|
||||||
|
- [ ] Never touch the mTLS daemon listener (AD-024)
|
||||||
|
- [ ] `internal/daemon/server.go` — add a `pprofAddr string` field to `Options` (default `""` = disabled). In `Start`, if `pprofAddr != ""`, call `StartPprof` and store the `*http.Server` for `Shutdown`.
|
||||||
|
- [ ] `internal/daemon/pprof_test.go` (NEW) — test:
|
||||||
|
- [ ] `StartPprof("127.0.0.1:0", ...)` → server starts, GET `/debug/pprof/` returns 200
|
||||||
|
- [ ] GET `/debug/pprof/cmdline` returns the cmdline
|
||||||
|
- [ ] `Shutdown` stops the pprof server
|
||||||
|
- [ ] The mTLS daemon server (if running) is unaffected by pprof start/stop
|
||||||
|
- [ ] `internal/cli/daemon.go` — add `--pprof string` flag (default `""` = disabled). Pass it into `daemon.Options.PprofAddr`. Document in `--help`: "enable pprof endpoint on <addr> (e.g. :6060); unauthenticated, operator-only".
|
||||||
|
- [ ] `internal/cli/daemon_test.go` — extend: `--pprof 127.0.0.1:0` → daemon starts with pprof; flag absent → no pprof server.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
- `go build ./...` PASS
|
||||||
|
- `go vet ./...` PASS
|
||||||
|
- `go test -race ./internal/daemon/...` PASS
|
||||||
|
- `./bin/orca daemon --pprof 127.0.0.1:0` (in background) → `curl http://127.0.0.1:<port>/debug/pprof/` returns 200
|
||||||
|
- `./bin/orca daemon` (no `--pprof`) → no pprof listener, `/debug/pprof/` not reachable on the daemon port
|
||||||
|
- pprof mux is separate from the mTLS daemon mux (asserted in test)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Final Review + Ship + Audit (Wave 1)
|
||||||
|
|
||||||
|
**Branch**: `phase/05-final-review-ship`
|
||||||
|
**REQ Coverage**: all (REQ-053..056)
|
||||||
|
**Persona leads**: lead-developer (review + audit + ship)
|
||||||
|
|
||||||
|
### Must-Haves
|
||||||
|
|
||||||
|
- [ ] Multi-persona code review across all v0.7 phases (ciagent-review)
|
||||||
|
- [ ] Audit: reconstruction test (git log matches `.ciagent/` files), branch hygiene, commit discipline (ciagent-audit)
|
||||||
|
- [ ] Fix any P0 issues found by review; record P1+ in `.ciagent/` for post-hoc
|
||||||
|
- [ ] Merge `phase/05` → `milestone/v0.7-hardening-completion`
|
||||||
|
- [ ] Merge `milestone/v0.7` → `main` (rebase-then-fast-forward per config)
|
||||||
|
- [ ] Tag `v0.6.5` (final phase patch = milestone release)
|
||||||
|
- [ ] Create Gitea release with full milestone summary (all phases, all REQs)
|
||||||
|
- [ ] Update `.ciagent/REQUIREMENTS.md` — mark REQ-053..056 complete
|
||||||
|
- [ ] Update `.ciagent/ROADMAP.md` — mark v0.7 complete
|
||||||
|
- [ ] Write checkpoint: `{phase: 5, stage: "complete", phase_role: "final", milestone_complete: true}`
|
||||||
|
- [ ] Clear checkpoint (milestone complete; next run starts a new milestone)
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
- `make build` PASS
|
||||||
|
- `make test` PASS
|
||||||
|
- `make lint` PASS
|
||||||
|
- `go vet ./...` PASS
|
||||||
|
- `git log` on main shows all v0.7 phase commits
|
||||||
|
- `git tag --list 'v0.6.*'` shows v0.6.0..v0.6.5
|
||||||
|
- REQUIREMENTS.md shows REQ-053..056 as Complete
|
||||||
|
- ROADMAP.md shows v0.7 as COMPLETE
|
||||||
@@ -275,3 +275,59 @@ auto-resolved at full autonomy within the `clarify_budget`:
|
|||||||
ExecStartPre or a config-management runbook.
|
ExecStartPre or a config-management runbook.
|
||||||
- **D-037 Ed25519**: `golang.org/x/crypto/ssh` + `golang.org/x/crypto/ed25519`
|
- **D-037 Ed25519**: `golang.org/x/crypto/ssh` + `golang.org/x/crypto/ed25519`
|
||||||
are in the same module; no additional direct dep beyond D-030.
|
are in the same module; no additional direct dep beyond D-030.
|
||||||
|
|
||||||
|
## v0.7 Clarified Decisions (D-series, full autonomy)
|
||||||
|
|
||||||
|
The 5 v0.7 decisions (D-038..D-042) were auto-resolved at full autonomy
|
||||||
|
within the `clarify_budget` (10):
|
||||||
|
|
||||||
|
| ID | Question | Decision | Rationale | Confidence |
|
||||||
|
|----|----------|----------|-----------|------------|
|
||||||
|
| D-038 | Config file format — HCL or YAML? | **HCL** | D-009 already specced `config.hcl`. HCL is already a direct dep (hashicorp/hcl/v2 for jobspec). Adding YAML would introduce a second parser dep — violates minimal-deps. Use the existing `hclparse` pkg from jobspec. | 0.93 |
|
||||||
|
| D-039 | Config precedence order (flag vs env vs file vs default)? | **flag > env > file > default** | Standard layered config: the most explicit (flag) wins, then the runtime (env), then the persisted (file), then the built-in default. Matches cobra/viper convention without the viper dep. | 0.92 |
|
||||||
|
| D-040 | pprof security — bind to localhost only, or operator-chosen addr? | **Operator-chosen `--pprof <addr>` (default disabled)** | Default disabled keeps the minimalist posture. Operator picks the addr — localhost for dev, unix socket for prod. Separate mux so it never touches the mTLS daemon listener. No auth (pprof is operator-only, addr is the gate). | 0.85 |
|
||||||
|
| D-041 | cert command registration — where in root command order? | **After `cert` is unreachable today, append after `node` in rootCmd.AddCommand order** | Alphabetical-ish with the existing cluster (audit, daemon, doctor, init, job, node, cert, status, version). No behavior change to existing commands. | 0.88 |
|
||||||
|
| D-042 | Coverage target — 50% floor or higher? | **50% floor per package, 70% target for new packages** | 50% is achievable for the concurrent packages (engine, transport) without heroic mock effort; 70% is the floor for new code in P02/P04. Avoids a "raise coverage everywhere" rathole. | 0.85 |
|
||||||
|
|
||||||
|
## v0.7 Scope Summary — Hardening & Completion
|
||||||
|
|
||||||
|
v0.7 is a 4-execution-phase **NFR milestone** that closes out gaps
|
||||||
|
surfaced by the v0.7 IDEATE stage: an unreachable command tree, a
|
||||||
|
missing config file layer, low test coverage in core packages, and the
|
||||||
|
long-deferred pprof endpoint. The engine functionality from v0.1–v0.6
|
||||||
|
is unchanged; this milestone is purely about **correctness, coverage,
|
||||||
|
and operability**:
|
||||||
|
|
||||||
|
- **P01 — Register `orca cert` command tree + cert_repo tests.** The
|
||||||
|
`internal/cli/cert.go` command (`cert ca-init`, `cert gen`, `cert
|
||||||
|
show`, `cert renew`, `cert fingerprint`) is fully implemented but
|
||||||
|
never wired into `rootCmd`. This phase adds the missing
|
||||||
|
`rootCmd.AddCommand(newCertCmd(...))` and adds the missing
|
||||||
|
`internal/store/cert_repo_test.go`. Covers REQ-053.
|
||||||
|
- **P02 — HCL config file parsing (`config.hcl`).** D-009 specified
|
||||||
|
`~/.orca/config.hcl` and `/etc/orca/orca.hcl` as config locations,
|
||||||
|
but no HCL config-file parser exists — the CLI relies entirely on
|
||||||
|
flags and env vars. This phase adds a minimal `internal/config`
|
||||||
|
package that loads `config.hcl` (keys: `db_path`, `listen_addr`,
|
||||||
|
`ca_path`, `server_cert_path`, `server_key_path`, `node_capacity`),
|
||||||
|
merges with env/flag overrides (flag > env > file > default), and
|
||||||
|
surfaces it via `--config` flag on the root command. Covers
|
||||||
|
REQ-054.
|
||||||
|
- **P03 — Test coverage uplift.** Adds tests for the lowest-coverage
|
||||||
|
packages: `internal/engine` (executor, dispatcher, peer — currently
|
||||||
|
8.3%), `internal/transport` (mtls, dispatch, handshake_log —
|
||||||
|
currently 26.3%), `internal/proxmox` (bootstrap SSH path —
|
||||||
|
currently 5.1%), and `internal/audit` (no tests). Target: every
|
||||||
|
package ≥ 50% coverage. Covers REQ-055.
|
||||||
|
- **P04 — `--pprof` opt-in on `orca daemon`.** Adds the long-deferred
|
||||||
|
I-308 pprof endpoint behind an opt-in `--pprof <addr>` flag (default
|
||||||
|
disabled). `net/http/pprof` mounted on a separate mux so it never
|
||||||
|
touches the mTLS daemon listener. Covers REQ-056.
|
||||||
|
- **P05 — Final review + ship + audit.** Milestone release.
|
||||||
|
|
||||||
|
The vision ("minimalist, offline-first, CLI-first orchestration
|
||||||
|
engine") is unchanged. v0.7 is a hardening milestone, not a direction
|
||||||
|
change. Milestone type: NFR (all phases are fix/test/chore); the final
|
||||||
|
phase's progressive patch IS the deliverable per `run.md` versioning
|
||||||
|
logic. Tags run on the v0.6.x patch line: `v0.6.0` (P0) … `v0.6.5` (P05
|
||||||
|
= milestone release).
|
||||||
|
|||||||
@@ -104,9 +104,30 @@ Docker image published to Gitea container registry (REQ-046).
|
|||||||
|
|
||||||
| ID | Requirement | Priority | Phase | Status |
|
| ID | Requirement | Priority | Phase | Status |
|
||||||
|----|-------------|----------|-------|--------|
|
|----|-------------|----------|-------|--------|
|
||||||
| REQ-047 | `orca init` auto-provisions CA + server cert + DB migrations + localhost node (idempotent; safe re-run) | High | **v0.6 P1** | Pending |
|
| REQ-047 | `orca init` auto-provisions CA + server cert + DB migrations + localhost node (idempotent; safe re-run) | High | **v0.6 P1** | **Complete** (P1 shipped v0.5.1) |
|
||||||
| REQ-048 | `orca init` registers a default `localhost` node with auto-detected OS via `/etc/os-release ID` | High | **v0.6 P1** | Pending |
|
| REQ-048 | `orca init` registers a default `localhost` node with auto-detected OS via `/etc/os-release ID` | High | **v0.6 P1** | **Complete** (P1 shipped v0.5.1) |
|
||||||
| REQ-049 | Node schema extension: `nodes.kind` (localhost\|linux\|proxmox) + `nodes.os` columns (migration 0006, backward-compatible) | High | **v0.6 P1** | Pending |
|
| REQ-049 | Node schema extension: `nodes.kind` (localhost\|linux\|proxmox) + `nodes.os` columns (migration 0006, backward-compatible) | High | **v0.6 P1** | **Complete** (P1 shipped v0.5.1) |
|
||||||
| REQ-050 | `orca node join --type proxmox` SSH bootstrap via `golang.org/x/crypto/ssh` (new direct dep); password auth, deploy orca pubkey, create `orca` user (config-overridable), assign PVE role, drop sudoers allowlist; idempotent | High | **v0.6 P2** | Pending |
|
| REQ-050 | `orca node join --type proxmox` SSH bootstrap via `golang.org/x/crypto/ssh` (new direct dep); password auth, deploy orca pubkey, create `orca` user (config-overridable), assign PVE role, drop sudoers allowlist; idempotent | High | **v0.6 P2** | **Complete** (P2 shipped v0.5.2) |
|
||||||
| REQ-051 | Proxmox least-privilege `OrcaOperator` PVE role (VM.Audit, Datastore.AllocateSpace, SDN.Use) + `orca` user + `/etc/sudoers.d/orca` allowlist (pct, qm, pvesh, apt-get, dpkg); config-overridable user/role names | High | **v0.6 P2** | Pending |
|
| REQ-051 | Proxmox least-privilege `OrcaOperator` PVE role (VM.Audit, Datastore.AllocateSpace, SDN.Use) + `orca` user + `/etc/sudoers.d/orca` allowlist (pct, qm, pvesh, apt-get, dpkg); config-overridable user/role names | High | **v0.6 P2** | **Complete** (P2 shipped v0.5.2; refined: pvesh excluded per AD-020, orca@pam per AD-019) |
|
||||||
| REQ-052 | `orca doctor` extensions: `doctor os` (verify localhost OS detection matches stored node row) + `doctor proxmox` (SSH-probe each `kind=proxmox` node with `pveversion`/`pvecmd status`, 3s timeout, PASS/WARN/FAIL); audit log all bootstrap + join actions | Medium | **v0.6 P3** | Pending |
|
| REQ-052 | `orca doctor` extensions: `doctor os` (verify localhost OS detection matches stored node row) + `doctor proxmox` (SSH-probe each `kind=proxmox` node with `pveversion`/`pvecmd status`, 3s timeout, PASS/WARN/FAIL); audit log all bootstrap + join actions | Medium | **v0.6 P3** | **Complete** (P3 shipped v0.5.3) |
|
||||||
|
|
||||||
|
## v0.6 Milestone Summary
|
||||||
|
|
||||||
|
**Status: Complete** — all 3 execution phases + final review shipped.
|
||||||
|
P0 (v0.5.0), P1 (v0.5.1), P2 (v0.5.2), P3 (v0.5.3), P4 final (v0.5.4).
|
||||||
|
REQ-047..052 all complete.
|
||||||
|
|
||||||
|
- **P0** (v0.5.0): pre-execution (specify → clarify → research → plan). 8 decisions (D-030..D-037).
|
||||||
|
- **P1** (v0.5.1): `orca init` full bootstrap + schema 0006 (REQ-047/048/049).
|
||||||
|
- **P2** (v0.5.2): Proxmox SSH join + OrcaOperator role + sudoers (REQ-050/051).
|
||||||
|
- **P3** (v0.5.3): `doctor os` + `doctor proxmox` + audit logging (REQ-052).
|
||||||
|
- **P4** (v0.5.4): final review + audit + milestone release.
|
||||||
|
|
||||||
|
## v0.7 Requirements — Hardening & Completion
|
||||||
|
|
||||||
|
| ID | Requirement | Priority | Phase | Status |
|
||||||
|
|----|-------------|----------|-------|--------|
|
||||||
|
| REQ-053 | `orca cert` command tree registered on root command (`cert ca-init`, `cert gen`, `cert show`, `cert renew`, `cert fingerprint`) — code exists in `internal/cli/cert.go` but is never AddCommand'd; unreachable today | High | **v0.7 P1** | Pending |
|
||||||
|
| REQ-054 | HCL config file parsing: `internal/config` package loads `~/.orca/config.hcl` / `/etc/orca/orca.hcl` (keys: db_path, listen_addr, ca_path, server_cert_path, server_key_path, node_capacity); merge precedence flag > env > file > default; `--config` flag on root command | High | **v0.7 P2** | Pending |
|
||||||
|
| REQ-055 | Test coverage uplift: every package ≥ 50% — adds tests for `internal/engine` (executor, dispatcher, peer), `internal/transport` (mtls, dispatch, handshake_log), `internal/proxmox` (bootstrap SSH path), `internal/audit` | Medium | **v0.7 P3** | Pending |
|
||||||
|
| REQ-056 | `--pprof <addr>` opt-in flag on `orca daemon` (default disabled); `net/http/pprof` mounted on a separate mux, never on the mTLS daemon listener | Low | **v0.7 P4** | Pending |
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# Research: Orca v0.7 — Hardening & Completion
|
||||||
|
|
||||||
|
## 1. Codebase audit findings (RESEARCH stage)
|
||||||
|
|
||||||
|
A full codebase audit surfaced the gaps that define the v0.7 scope.
|
||||||
|
Each finding is grounded in a specific file/coverage measurement.
|
||||||
|
|
||||||
|
### 1.1 `orca cert` command tree is unreachable (critical)
|
||||||
|
|
||||||
|
- `internal/cli/cert.go:44` exports `NewCommand(log *slog.Logger)
|
||||||
|
*cobra.Command` which builds the full `cert ca-init | gen | show |
|
||||||
|
renew | fingerprint` tree (5 subcommands, all implemented, all
|
||||||
|
spec-compliant per REQ-033/035/036).
|
||||||
|
- **No file in the repo calls `NewCommand` or registers it on
|
||||||
|
`rootCmd`.** `grep -rn "rootCmd.AddCommand" internal/cli/` lists
|
||||||
|
daemon, init, audit, version, job, node, doctor, status — `cert` is
|
||||||
|
absent. `./bin/orca cert` returns `error: unknown command "cert"`.
|
||||||
|
- The function is named `NewCommand` (not `newCertCmd`), so it is not
|
||||||
|
picked up by any init-based registration convention.
|
||||||
|
- **Impact**: every cert operation the spec promises (REQ-023, REQ-025,
|
||||||
|
REQ-033, REQ-035, REQ-036) is unreachable from the CLI. Operators
|
||||||
|
cannot bootstrap a CA, issue a server cert, or rotate one without
|
||||||
|
hand-crafting calls into the `security` package. This is the single
|
||||||
|
highest-impact bug in the v0.1–v0.6 line.
|
||||||
|
- **Fix**: one-line `rootCmd.AddCommand(NewCommand(log))` in
|
||||||
|
`internal/cli/cert.go` (or a new `init()`), plus a regression test
|
||||||
|
that asserts `rootCmd.Commands()` contains a child whose `Use ==
|
||||||
|
"cert"`.
|
||||||
|
|
||||||
|
### 1.2 `internal/store/cert_repo.go` has no test file
|
||||||
|
|
||||||
|
- `internal/store/cert_repo.go` exists (the `certs` table from
|
||||||
|
migration 0004) but `internal/store/cert_repo_test.go` does not.
|
||||||
|
- Every other repo in `internal/store/` has a `_test.go`:
|
||||||
|
`node_repo_test.go`, `job_task_repo_test.go`, `capacity_repo_test.go`,
|
||||||
|
`audit_repo_test.go`, `migrate_test.go`.
|
||||||
|
- **Fix**: add `cert_repo_test.go` covering Insert/Get/List/rotation
|
||||||
|
history (N=3 per REQ-025) + serial_hex uniqueness.
|
||||||
|
|
||||||
|
### 1.3 Low test coverage in core packages
|
||||||
|
|
||||||
|
| Package | Coverage | Missing tests for |
|
||||||
|
|---------|----------|-------------------|
|
||||||
|
| `internal/engine` | 8.3% | `executor.go`, `dispatcher.go`, `peer.go` (only `scheduler_test.go` exists) |
|
||||||
|
| `internal/transport` | 26.3% | `mtls.go`, `dispatch.go`, `handshake_log.go` (only `idempotency_test.go` exists) |
|
||||||
|
| `internal/proxmox` | 5.1% | `bootstrap.go` SSH path (only `bootstrap_test.go` exists, exercises the no-op dry-run) |
|
||||||
|
| `internal/audit` | no test files | `audit.go` (Emit, EmitWithErr, LogHandshake*) |
|
||||||
|
|
||||||
|
- Target per D-042: 50% floor per package, 70% for new code in P02/P04.
|
||||||
|
- Strategy: table-driven tests + `httptest.NewTLSServer` for transport;
|
||||||
|
interface-based mocks for the SSH dialer (already an interface in
|
||||||
|
`proxmox/bootstrap.go:211` `defaultSSHDialer` with `DialContext`).
|
||||||
|
|
||||||
|
### 1.4 No HCL config file parser
|
||||||
|
|
||||||
|
- D-009 specified `~/.orca/config.hcl` and `/etc/orca/orca.hcl` as
|
||||||
|
config locations. `find . -name "*.hcl"` returns only testdata
|
||||||
|
(`testdata/hello.hcl`, `testdata/fail.hcl`) used by jobspec tests.
|
||||||
|
- The CLI relies entirely on flags + env vars (`ORCA_HOME`,
|
||||||
|
`ORCA_DB`, `ORCA_PROXMOX_PASSWORD`). There is no `internal/config`
|
||||||
|
package.
|
||||||
|
- `internal/jobspec/spec.go:40` already uses
|
||||||
|
`hclsimple.Decode(filename, data, nil, &spec)` — the exact same
|
||||||
|
pattern works for a `Config` struct. No new dep required (hashicorp/hcl/v2
|
||||||
|
is already a direct dep).
|
||||||
|
- **Fix**: new `internal/config` package with a `Config` struct (HCL
|
||||||
|
tags: `db_path`, `listen_addr`, `ca_path`, `server_cert_path`,
|
||||||
|
`server_key_path`, `node_capacity`), a `Load(paths ...string)`
|
||||||
|
function, and a `--config` flag on the root command. Precedence per
|
||||||
|
D-039: flag > env > file > default.
|
||||||
|
|
||||||
|
### 1.5 pprof endpoint (I-308, deferred since v0.2)
|
||||||
|
|
||||||
|
- I-308 was deferred in v0.2 IDEATE ("keep v0.2 lean") and never
|
||||||
|
revisited. The daemon (`internal/daemon/server.go`) has no pprof
|
||||||
|
surface today.
|
||||||
|
- `net/http/pprof` is stdlib — zero new deps. Mount on a separate
|
||||||
|
`*http.ServeMux` so it never touches the mTLS daemon listener.
|
||||||
|
- **Fix**: `--pprof <addr>` flag on `orca daemon` (default disabled).
|
||||||
|
If set, start a second `http.Server` on `<addr>` with
|
||||||
|
`pprof.Index`/`pprof.Cmdline`/etc. registered. Log a WARN that the
|
||||||
|
endpoint is unauthenticated + operator-only.
|
||||||
|
|
||||||
|
## 2. Prior art & patterns
|
||||||
|
|
||||||
|
### 2.1 HCL config in HashiCorp tools
|
||||||
|
|
||||||
|
Nomad, Consul, and Terraform all use HCL for config with the same
|
||||||
|
`hclsimple.Decode` + struct-tag pattern. The precedence model (flag >
|
||||||
|
env > file > default) is the de-facto standard; Viper implements it but
|
||||||
|
adds a large dep. Orca's `internal/config` will implement the 4-layer
|
||||||
|
merge by hand (~80 LOC) to stay minimal-deps.
|
||||||
|
|
||||||
|
### 2.2 pprof in Go daemons
|
||||||
|
|
||||||
|
Standard pattern: `import _ "net/http/pprof"` registers handlers on
|
||||||
|
`http.DefaultServeMux`. Best practice for production daemons is a
|
||||||
|
**separate listener** (not DefaultServeMux) so pprof is never exposed
|
||||||
|
on the public port. Orca will use a dedicated `*http.ServeMux` +
|
||||||
|
`http.Server` on the `--pprof` addr, default disabled.
|
||||||
|
|
||||||
|
### 2.3 Test coverage for concurrent Go
|
||||||
|
|
||||||
|
`internal/engine` (executor, dispatcher) and `internal/transport`
|
||||||
|
(mtls, dispatch) are concurrent. Coverage strategy:
|
||||||
|
- `httptest.NewTLSServer` for transport — exercise real TLS handshakes
|
||||||
|
against an in-process server.
|
||||||
|
- Interface-based mocks for the SSH dialer (proxmox) and the peer
|
||||||
|
client (transport) — both already have interface seams.
|
||||||
|
- `sync.WaitGroup` + channel assertions for executor/dispatcher
|
||||||
|
lifecycle.
|
||||||
|
- `-race` is already on in CI (REQ-031) — new tests inherit it.
|
||||||
|
|
||||||
|
## 3. v0.7 Architectural Decisions (AD-022..AD-026)
|
||||||
|
|
||||||
|
| ID | Decision | Rationale |
|
||||||
|
|----|----------|-----------|
|
||||||
|
| AD-022 | `orca cert` registered via `init()` in `cert.go` calling `rootCmd.AddCommand(NewCommand(slog.Default()))` | Keeps registration co-located with the command definition; matches the pattern in `daemon.go`/`audit.go` where each command file self-registers. Avoids a central registration function that would drift. |
|
||||||
|
| AD-023 | `internal/config` package: `Config` struct + `Load(paths ...string) (*Config, error)`; no global singleton | Config is passed explicitly to `daemon.NewServer`, `cli` commands, etc. No package-level state — testable, no init-order surprises. |
|
||||||
|
| AD-024 | pprof on a separate `*http.Server` + `*http.ServeMux`, default disabled | Never co-mingles with the mTLS daemon listener. Operator opts in via `--pprof :6060`. Matches Go daemon best practice. |
|
||||||
|
| AD-025 | Coverage floor measured per-package via `go test -cover ./<pkg>` | No aggregate threshold (aggregates hide low-coverage packages). CI gate added in P03: `go test -cover ./internal/engine ./internal/transport ./internal/proxmox ./internal/audit` and assert each ≥ 50%. |
|
||||||
|
| AD-026 | No new direct dependencies in v0.7 | `net/http/pprof` (stdlib), `hashicorp/hcl/v2` (already direct). v0.7 preserves the minimal-deps ethos. |
|
||||||
|
|
||||||
|
## 4. PERSONAS assessment
|
||||||
|
|
||||||
|
v0.7 is an NFR milestone touching CLI, config, tests, and daemon. The
|
||||||
|
default 3-persona roster (lead-developer, backend-engineer,
|
||||||
|
data-engineer) is sufficient:
|
||||||
|
|
||||||
|
- **lead-developer**: owns P01 (cert registration) + P04 (pprof) — CLI/
|
||||||
|
daemon territory.
|
||||||
|
- **backend-engineer**: owns P02 (config package) — internal/config +
|
||||||
|
CLI integration.
|
||||||
|
- **data-engineer**: owns P01 cert_repo tests + P03 store coverage —
|
||||||
|
`internal/store` territory.
|
||||||
|
- **lead-developer** also owns P03 engine/transport/proxmox/audit
|
||||||
|
coverage (test-only phase, no schema changes).
|
||||||
|
|
||||||
|
No new personas needed. No phase-specific personas. Territory
|
||||||
|
enforcement stays `warn`. See `.ciagent/PERSONAS.md` (updated).
|
||||||
|
|
||||||
|
## 5. Dependencies
|
||||||
|
|
||||||
|
v0.7 adds **zero** new direct dependencies:
|
||||||
|
- HCL parsing: `hashicorp/hcl/v2` (already direct, used by jobspec).
|
||||||
|
- pprof: `net/http/pprof` (stdlib).
|
||||||
|
- Tests: `net/http/httptest` (stdlib), existing interfaces.
|
||||||
|
|
||||||
|
`go.mod` is unchanged by v0.7.
|
||||||
|
|
||||||
|
## 6. Risks
|
||||||
|
|
||||||
|
- **P01 cert registration** may surface latent bugs in the cert
|
||||||
|
subcommands (they've never been exercised end-to-end). Mitigation:
|
||||||
|
P01 includes a smoke test that runs `cert ca-init` + `cert gen` +
|
||||||
|
`cert show` + `cert fingerprint` against a temp `ORCA_HOME`.
|
||||||
|
- **P02 config precedence** is easy to get wrong (flag/env/file/default
|
||||||
|
merge order). Mitigation: table-driven test covering all 4 layers.
|
||||||
|
- **P03 coverage** on concurrent packages may reveal race conditions
|
||||||
|
(already hidden by the 8.3% coverage). Mitigation: `-race` is on; P03
|
||||||
|
fixes any races it uncovers as part of the same phase.
|
||||||
+27
-5
@@ -91,16 +91,18 @@ feature-milestone promotion rule). Per-phase tags: `v0.4.1`…`v0.4.5`.
|
|||||||
|
|
||||||
## Milestone v0.6: Node Bootstrap & Proxmox
|
## Milestone v0.6: Node Bootstrap & Proxmox
|
||||||
|
|
||||||
|
## Milestone v0.6: Node Bootstrap & Proxmox — **COMPLETE**
|
||||||
|
|
||||||
Scope: make `orca init` produce a fully working single-node cluster
|
Scope: make `orca init` produce a fully working single-node cluster
|
||||||
(CA + server cert + DB + localhost node registered with auto-detected
|
(CA + server cert + DB + localhost node registered with auto-detected
|
||||||
OS), and add Proxmox 8 & 9 as a first-class remote node type joined
|
OS), and add Proxmox 8 & 9 as a first-class remote node type joined
|
||||||
over SSH with least-privilege role delegation.
|
over SSH with least-privilege role delegation.
|
||||||
|
|
||||||
- [ ] Phase 0: Pre-execution (specify → clarify → research → plan → grill) — tag `v0.5.0`
|
- [x] Phase 0: Pre-execution (specify → clarify → research → plan → grill) — tag `v0.5.0`
|
||||||
- [ ] Phase 1: `orca init` full bootstrap + localhost node + schema 0006 (REQ-047, REQ-048, REQ-049) — tag `v0.5.1`
|
- [x] Phase 1: `orca init` full bootstrap + localhost node + schema 0006 (REQ-047, REQ-048, REQ-049) — tag `v0.5.1`
|
||||||
- [ ] Phase 2: Proxmox SSH join + OrcaOperator role + sudoers allowlist (REQ-050, REQ-051) — tag `v0.5.2`
|
- [x] Phase 2: Proxmox SSH join + OrcaOperator role + sudoers allowlist (REQ-050, REQ-051) — tag `v0.5.2`
|
||||||
- [ ] Phase 3: `doctor os` + `doctor proxmox` SSH probe + audit logging (REQ-052) — tag `v0.5.3`
|
- [x] Phase 3: `doctor os` + `doctor proxmox` SSH probe + audit logging (REQ-052) — tag `v0.5.3`
|
||||||
- [ ] Phase 4: Final review + ship + audit (milestone release) — tag `v0.5.4`
|
- [x] Phase 4: Final review + ship + audit (milestone release) — tag `v0.5.4`
|
||||||
|
|
||||||
**Milestone type**: feature (P1/P2/P3 ship `feat` phases).
|
**Milestone type**: feature (P1/P2/P3 ship `feat` phases).
|
||||||
**Milestone tag**: `v0.5.4` (final phase patch = milestone release per
|
**Milestone tag**: `v0.5.4` (final phase patch = milestone release per
|
||||||
@@ -110,3 +112,23 @@ Tags run on the previous minor's patch line (v0.5.x) per
|
|||||||
branch-strategy.md. The milestone branch label uses the milestone
|
branch-strategy.md. The milestone branch label uses the milestone
|
||||||
number (`milestone/v0.6-node-bootstrap-proxmox`); no separate minor
|
number (`milestone/v0.6-node-bootstrap-proxmox`); no separate minor
|
||||||
tag is created.
|
tag is created.
|
||||||
|
|
||||||
|
## Milestone v0.7: Hardening & Completion
|
||||||
|
|
||||||
|
Scope: NFR milestone closing gaps surfaced by the v0.7 IDEATE stage —
|
||||||
|
an unreachable command tree, a missing config file layer, low test
|
||||||
|
coverage in core packages, and the long-deferred pprof endpoint.
|
||||||
|
|
||||||
|
- [ ] Phase 0: Pre-execution (specify → clarify → research → ideate → plan) — tag `v0.6.0`
|
||||||
|
- [ ] Phase 1: Register `orca cert` command tree + cert_repo tests (REQ-053) — tag `v0.6.1`
|
||||||
|
- [ ] Phase 2: HCL config file parsing — `internal/config` package (REQ-054) — tag `v0.6.2`
|
||||||
|
- [ ] Phase 3: Test coverage uplift — engine/transport/proxmox/audit ≥ 50% (REQ-055) — tag `v0.6.3`
|
||||||
|
- [ ] Phase 4: `--pprof` opt-in on `orca daemon` (REQ-056) — tag `v0.6.4`
|
||||||
|
- [ ] Phase 5: Final review + ship + audit (milestone release) — tag `v0.6.5`
|
||||||
|
|
||||||
|
**Milestone type**: NFR (all phases are fix/test/chore; no `feat` phases).
|
||||||
|
**Milestone tag**: `v0.6.5` (final phase patch = milestone release per
|
||||||
|
NFR-milestone progressive-patch rule). Per-phase tags: `v0.6.0`…`v0.6.5`.
|
||||||
|
Tags run on the previous minor's patch line (v0.6.x) per
|
||||||
|
branch-strategy.md. The milestone branch label uses the milestone
|
||||||
|
number (`milestone/v0.7-hardening-completion`); no separate minor tag.
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
"slug": "orca",
|
"slug": "orca",
|
||||||
"name": "Orca",
|
"name": "Orca",
|
||||||
"description": "Offline/CLI-first orchestration engine (Orca) — Nomad-inspired, far simpler than Kubernetes",
|
"description": "Offline/CLI-first orchestration engine (Orca) — Nomad-inspired, far simpler than Kubernetes",
|
||||||
"milestone": "v0.6",
|
"milestone": "v0.7",
|
||||||
"phase": 0,
|
"phase": 0,
|
||||||
"milestone_type": "feature",
|
"milestone_type": "nfr",
|
||||||
"default_branch": "main",
|
"default_branch": "main",
|
||||||
"tech_stack": {
|
"tech_stack": {
|
||||||
"language": "go",
|
"language": "go",
|
||||||
|
|||||||
+29
-1
@@ -69,7 +69,35 @@ var doctorDBCmd = &cobra.Command{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var doctorOSCmd = &cobra.Command{
|
||||||
|
Use: "os",
|
||||||
|
Short: "Run the OS detection self-check (v0.6 P03)",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c := doctor.OS()
|
||||||
|
r, msg := c.Run(cmd.Context())
|
||||||
|
if jsonOutput {
|
||||||
|
return printJSON(doctor.CheckResult{Name: c.Name, Result: r, Message: msg})
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-5s %s\n", c.Name, r, msg)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var doctorProxmoxCmd = &cobra.Command{
|
||||||
|
Use: "proxmox",
|
||||||
|
Short: "Run the proxmox node reachability self-check (v0.6 P03)",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c := doctor.Proxmox()
|
||||||
|
r, msg := c.Run(cmd.Context())
|
||||||
|
if jsonOutput {
|
||||||
|
return printJSON(doctor.CheckResult{Name: c.Name, Result: r, Message: msg})
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-5s %s\n", c.Name, r, msg)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
doctorCmd.AddCommand(doctorCertCmd, doctorNetworkCmd, doctorDBCmd)
|
doctorCmd.AddCommand(doctorCertCmd, doctorNetworkCmd, doctorDBCmd, doctorOSCmd, doctorProxmoxCmd)
|
||||||
rootCmd.AddCommand(doctorCmd)
|
rootCmd.AddCommand(doctorCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +1,10 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import "git.cloudinit.dev/coreci/orca/internal/osdetect"
|
||||||
"bufio"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// osReleasePaths are checked in order for the os-release file. The
|
// detectOS reads /etc/os-release and returns the ID= value.
|
||||||
// freedesktop.org spec says /etc/os-release is the canonical path,
|
// Delegates to internal/osdetect to avoid import cycles with
|
||||||
// with /usr/lib/os-release as a fallback for minimal containers that
|
// internal/doctor (both need OS detection).
|
||||||
// may not symlink the former.
|
|
||||||
var osReleasePaths = []string{"/etc/os-release", "/usr/lib/os-release"}
|
|
||||||
|
|
||||||
// detectOS reads /etc/os-release (then /usr/lib/os-release as a
|
|
||||||
// fallback) and returns the value of the ID= field. Returns "linux"
|
|
||||||
// (the generic fallback per D-032) if the file is missing, the ID
|
|
||||||
// field is absent, or the value is empty. Unknown ID values (e.g.
|
|
||||||
// "fedora", "arch") are returned verbatim — doctor os can warn on
|
|
||||||
// unknown values, but orca init must not fail.
|
|
||||||
func detectOS() string {
|
func detectOS() string {
|
||||||
for _, p := range osReleasePaths {
|
return osdetect.Detect()
|
||||||
data, err := os.ReadFile(p)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if id := parseOSReleaseID(data); id != "" {
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "linux"
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseOSReleaseID extracts the ID= value from os-release content.
|
|
||||||
// The format is shell-compatible KEY=VALUE lines; values may be
|
|
||||||
// double-quoted. Returns "" if ID is absent or empty.
|
|
||||||
func parseOSReleaseID(data []byte) string {
|
|
||||||
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := strings.TrimSpace(scanner.Text())
|
|
||||||
if line == "" || strings.HasPrefix(line, "#") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key, value, ok := strings.Cut(line, "=")
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key = strings.TrimSpace(key)
|
|
||||||
if key != "ID" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
value = strings.TrimSpace(value)
|
|
||||||
// Strip surrounding double quotes (freedesktop spec allows quoted values).
|
|
||||||
if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
|
|
||||||
value = value[1 : len(value)-1]
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-129
@@ -1,137 +1,19 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseOSReleaseID_Ubuntu(t *testing.T) {
|
// The osdetect parsing/detection logic is tested in
|
||||||
content := `NAME="Ubuntu"
|
// internal/osdetect/osdetect_test.go. These tests verify the cli
|
||||||
VERSION="24.04.4 LTS (Noble Numbat)"
|
// wrapper delegates correctly.
|
||||||
ID=ubuntu
|
|
||||||
ID_LIKE=debian
|
func TestDetectOS_DelegatesToPackage(t *testing.T) {
|
||||||
PRETTY_NAME="Ubuntu 24.04.4 LTS"`
|
// On this host (Ubuntu), detectOS should return "ubuntu" via the
|
||||||
if got := parseOSReleaseID([]byte(content)); got != "ubuntu" {
|
// osdetect package. If /etc/os-release is absent (e.g., in a
|
||||||
t.Errorf("got %q, want ubuntu", got)
|
// minimal container), it returns "linux".
|
||||||
}
|
result := detectOS()
|
||||||
}
|
if result == "" {
|
||||||
|
t.Error("detectOS returned empty string, expected a non-empty OS ID")
|
||||||
func TestParseOSReleaseID_Debian(t *testing.T) {
|
|
||||||
content := `PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
|
|
||||||
NAME="Debian GNU/Linux"
|
|
||||||
VERSION_ID="12"
|
|
||||||
VERSION="12 (bookworm)"
|
|
||||||
ID=debian`
|
|
||||||
if got := parseOSReleaseID([]byte(content)); got != "debian" {
|
|
||||||
t.Errorf("got %q, want debian", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseOSReleaseID_Alpine(t *testing.T) {
|
|
||||||
content := `NAME="Alpine Linux"
|
|
||||||
ID=alpine
|
|
||||||
VERSION_ID=3.20.3
|
|
||||||
PRETTY_NAME="Alpine Linux v3.20"`
|
|
||||||
if got := parseOSReleaseID([]byte(content)); got != "alpine" {
|
|
||||||
t.Errorf("got %q, want alpine", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseOSReleaseID_PVE(t *testing.T) {
|
|
||||||
content := `NAME="Proxmox Virtual Environment"
|
|
||||||
VERSION="9.2.3"
|
|
||||||
ID=pve
|
|
||||||
ID_LIKE=debian`
|
|
||||||
if got := parseOSReleaseID([]byte(content)); got != "pve" {
|
|
||||||
t.Errorf("got %q, want pve", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseOSReleaseID_QuotedValue(t *testing.T) {
|
|
||||||
content := `ID="ubuntu"`
|
|
||||||
if got := parseOSReleaseID([]byte(content)); got != "ubuntu" {
|
|
||||||
t.Errorf("got %q, want ubuntu", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseOSReleaseID_UnquotedValue(t *testing.T) {
|
|
||||||
content := `ID=alpine`
|
|
||||||
if got := parseOSReleaseID([]byte(content)); got != "alpine" {
|
|
||||||
t.Errorf("got %q, want alpine", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseOSReleaseID_MissingID(t *testing.T) {
|
|
||||||
content := `NAME="Some Distro"
|
|
||||||
VERSION="1.0"`
|
|
||||||
if got := parseOSReleaseID([]byte(content)); got != "" {
|
|
||||||
t.Errorf("got %q, want empty", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseOSReleaseID_EmptyContent(t *testing.T) {
|
|
||||||
if got := parseOSReleaseID([]byte("")); got != "" {
|
|
||||||
t.Errorf("got %q, want empty", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseOSReleaseID_CommentsAndBlankLines(t *testing.T) {
|
|
||||||
content := `# This is a comment
|
|
||||||
|
|
||||||
NAME="Test"
|
|
||||||
# ID is set below
|
|
||||||
ID=arch
|
|
||||||
PRETTY_NAME="Test Arch"`
|
|
||||||
if got := parseOSReleaseID([]byte(content)); got != "arch" {
|
|
||||||
t.Errorf("got %q, want arch", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseOSReleaseID_UnknownIDReturnedVerbatim(t *testing.T) {
|
|
||||||
content := `ID=fedora`
|
|
||||||
if got := parseOSReleaseID([]byte(content)); got != "fedora" {
|
|
||||||
t.Errorf("got %q, want fedora (unknown IDs returned verbatim)", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetectOS_FallbackToLinux(t *testing.T) {
|
|
||||||
// Temporarily point osReleasePaths at non-existent files.
|
|
||||||
orig := osReleasePaths
|
|
||||||
defer func() { osReleasePaths = orig }()
|
|
||||||
osReleasePaths = []string{
|
|
||||||
filepath.Join(t.TempDir(), "nonexistent-os-release"),
|
|
||||||
}
|
|
||||||
if got := detectOS(); got != "linux" {
|
|
||||||
t.Errorf("got %q, want linux (fallback)", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetectOS_ReadsEtcOSRelease(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
orig := osReleasePaths
|
|
||||||
defer func() { osReleasePaths = orig }()
|
|
||||||
osReleasePaths = []string{filepath.Join(dir, "os-release")}
|
|
||||||
if err := os.WriteFile(osReleasePaths[0], []byte("ID=ubuntu\n"), 0o644); err != nil {
|
|
||||||
t.Fatalf("write: %v", err)
|
|
||||||
}
|
|
||||||
if got := detectOS(); got != "ubuntu" {
|
|
||||||
t.Errorf("got %q, want ubuntu", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetectOS_FallbackToUsrLib(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
orig := osReleasePaths
|
|
||||||
defer func() { osReleasePaths = orig }()
|
|
||||||
osReleasePaths = []string{
|
|
||||||
filepath.Join(dir, "etc-os-release"), // missing
|
|
||||||
filepath.Join(dir, "usr-lib-os-release"), // fallback
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(osReleasePaths[1], []byte("ID=alpine\n"), 0o644); err != nil {
|
|
||||||
t.Fatalf("write: %v", err)
|
|
||||||
}
|
|
||||||
if got := detectOS(); got != "alpine" {
|
|
||||||
t.Errorf("got %q, want alpine (from fallback path)", got)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
"golang.org/x/crypto/ssh/knownhosts"
|
||||||
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
||||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/osdetect"
|
||||||
"git.cloudinit.dev/coreci/orca/internal/security"
|
"git.cloudinit.dev/coreci/orca/internal/security"
|
||||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||||
"git.cloudinit.dev/coreci/orca/internal/transport"
|
"git.cloudinit.dev/coreci/orca/internal/transport"
|
||||||
@@ -68,7 +72,9 @@ func All() []Check {
|
|||||||
CertServer(),
|
CertServer(),
|
||||||
CertExpiry(),
|
CertExpiry(),
|
||||||
CertFingerprint(),
|
CertFingerprint(),
|
||||||
|
OS(),
|
||||||
Network(),
|
Network(),
|
||||||
|
Proxmox(),
|
||||||
DB(),
|
DB(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -300,6 +306,179 @@ func probeHealthz(ctx context.Context, caPath, certPath, keyPath, serverName, ad
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OS checks that the auto-detected OS matches the stored localhost
|
||||||
|
// node's os field (REQ-052). Drift (e.g., OS upgraded since init)
|
||||||
|
// returns WARN; match returns PASS; missing localhost node returns FAIL.
|
||||||
|
func OS() Check {
|
||||||
|
return Check{
|
||||||
|
Name: "os",
|
||||||
|
Description: "localhost OS detection vs stored node row",
|
||||||
|
Run: func(ctx context.Context) (Result, string) {
|
||||||
|
detected := osdetect.Detect()
|
||||||
|
|
||||||
|
db, err := store.Open(certpaths.DBPath())
|
||||||
|
if err != nil {
|
||||||
|
return ResultFail, fmt.Sprintf("open db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
node, err := store.NewNodeRepo(db).GetByName(ctx, "localhost")
|
||||||
|
if err == store.ErrNotFound {
|
||||||
|
return ResultFail, "no localhost node registered — run `orca init`"
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return ResultFail, fmt.Sprintf("lookup localhost node: %v", err)
|
||||||
|
}
|
||||||
|
if node.OS == "" {
|
||||||
|
return ResultWarn, fmt.Sprintf("localhost node has no os field (pre-0006 row?); detected=%s — re-run `orca init` to refresh", detected)
|
||||||
|
}
|
||||||
|
if node.OS != detected {
|
||||||
|
return ResultWarn, fmt.Sprintf("OS drift: init=%s, now=%s — re-run `orca init` to refresh", node.OS, detected)
|
||||||
|
}
|
||||||
|
return ResultPass, fmt.Sprintf("localhost os=%s (matches /etc/os-release)", detected)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proxmox probes each kind=proxmox node via SSH with `pveversion`
|
||||||
|
// (REQ-052). Clones the Network() pattern: list nodes, filter by kind,
|
||||||
|
// 3s timeout per peer, PASS/WARN/FAIL per node. Zero proxmox nodes
|
||||||
|
// returns WARN (single-node cluster is legitimate).
|
||||||
|
func Proxmox() Check {
|
||||||
|
return Check{
|
||||||
|
Name: "proxmox",
|
||||||
|
Description: "proxmox node reachability via SSH pveversion probe",
|
||||||
|
Run: func(ctx context.Context) (Result, string) {
|
||||||
|
db, err := store.Open(certpaths.DBPath())
|
||||||
|
if err != nil {
|
||||||
|
return ResultFail, fmt.Sprintf("open db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
nodes, err := store.NewNodeRepo(db).List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return ResultFail, fmt.Sprintf("list nodes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
proxmoxNodes := make([]*model.Node, 0, len(nodes))
|
||||||
|
for _, n := range nodes {
|
||||||
|
if n.Kind == string(model.NodeKindProxmox) && n.State != model.NodeStateLeft {
|
||||||
|
proxmoxNodes = append(proxmoxNodes, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(proxmoxNodes) == 0 {
|
||||||
|
return ResultWarn, "no proxmox nodes registered (single-node?)"
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
anyFail := false
|
||||||
|
for _, n := range proxmoxNodes {
|
||||||
|
probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||||
|
err := probeProxmoxPVEVersion(probeCtx, n.Name)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
anyFail = true
|
||||||
|
lines = append(lines, fmt.Sprintf(" ✗ %s: %v", n.Name, err))
|
||||||
|
} else {
|
||||||
|
lines = append(lines, fmt.Sprintf(" ✓ %s", n.Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := ResultPass
|
||||||
|
if anyFail {
|
||||||
|
result = ResultFail
|
||||||
|
}
|
||||||
|
return result, strings.Join(lines, "\n")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeProxmoxPVEVersion SSHes into the proxmox host and runs
|
||||||
|
// `pveversion` to verify reachability + PVE installation. Uses the
|
||||||
|
// orca SSH key for auth (deployed during `orca node join --type proxmox`)
|
||||||
|
// and the known_hosts TOFU store for host-key verification (D-035).
|
||||||
|
func probeProxmoxPVEVersion(ctx context.Context, host string) error {
|
||||||
|
// Load the orca SSH key for public-key auth.
|
||||||
|
keyPEM, err := os.ReadFile(certpaths.SSHKeyPath())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read SSH key: %w (run `orca node join --type proxmox` first)", err)
|
||||||
|
}
|
||||||
|
signer, err := ssh.ParsePrivateKey(keyPEM)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse SSH key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hostKeyCallback, err := knownhosts.New(certpaths.KnownHostsPath())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("known_hosts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
config := &ssh.ClientConfig{
|
||||||
|
User: "orca",
|
||||||
|
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
|
||||||
|
HostKeyCallback: hostKeyCallback,
|
||||||
|
Timeout: 3 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract host from the node address (orca stores host:8443;
|
||||||
|
// SSH needs host:22). We dial the SSH port, not the orca daemon port.
|
||||||
|
sshHost := host
|
||||||
|
if strings.Contains(host, ":") {
|
||||||
|
sshHost = strings.SplitN(host, ":", 2)[0]
|
||||||
|
}
|
||||||
|
sshAddr := sshHost + ":22"
|
||||||
|
|
||||||
|
dialer := &netDialer{}
|
||||||
|
conn, err := dialer.DialContext(ctx, "tcp", sshAddr, config)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ssh dial: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
session, err := conn.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("new session: %w", err)
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
out, err := session.CombinedOutput("pveversion")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("pveversion: %w (output: %s)", err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// netDialer wraps ssh.Dial with context support. The ssh package's
|
||||||
|
// Dial doesn't accept a context directly, so we use a dialer that
|
||||||
|
// respects ctx cancellation via a goroutine + channel.
|
||||||
|
type netDialer struct{}
|
||||||
|
|
||||||
|
func (d *netDialer) DialContext(ctx context.Context, network, addr string, config *ssh.ClientConfig) (*ssh.Client, error) {
|
||||||
|
type result struct {
|
||||||
|
client *ssh.Client
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
ch := make(chan result, 1)
|
||||||
|
go func() {
|
||||||
|
client, err := ssh.Dial(network, addr, config)
|
||||||
|
ch <- result{client, err}
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
// Best-effort: if the dial succeeds after ctx cancellation,
|
||||||
|
// the goroutine will close the client. We return the ctx error.
|
||||||
|
go func() {
|
||||||
|
if r := <-ch; r.client != nil {
|
||||||
|
_ = r.client.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case r := <-ch:
|
||||||
|
return r.client, r.err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// loadCert reads a PEM cert from path and parses the first CERTIFICATE
|
// loadCert reads a PEM cert from path and parses the first CERTIFICATE
|
||||||
// block.
|
// block.
|
||||||
func loadCert(path string) (*x509.Certificate, error) {
|
func loadCert(path string) (*x509.Certificate, error) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/osdetect"
|
||||||
"git.cloudinit.dev/coreci/orca/internal/security"
|
"git.cloudinit.dev/coreci/orca/internal/security"
|
||||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||||
)
|
)
|
||||||
@@ -241,6 +242,156 @@ func TestRenderReport(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestOSCheck_MissingLocalhostNode verifies the OS check returns FAIL
|
||||||
|
// when no localhost node is registered.
|
||||||
|
func TestOSCheck_MissingLocalhostNode(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("ORCA_HOME", dir)
|
||||||
|
t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))
|
||||||
|
|
||||||
|
// Open the DB to apply migrations but insert no nodes.
|
||||||
|
db, err := store.Open(filepath.Join(dir, "orca.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
db.Close()
|
||||||
|
|
||||||
|
c := OS()
|
||||||
|
r, msg := c.Run(context.Background())
|
||||||
|
if r != ResultFail {
|
||||||
|
t.Errorf("OS check: got %s, want FAIL — %s", r, msg)
|
||||||
|
}
|
||||||
|
if !strings.Contains(msg, "no localhost node") {
|
||||||
|
t.Errorf("OS check message should mention missing localhost node, got: %s", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOSCheck_Match verifies the OS check returns PASS when the stored
|
||||||
|
// localhost node's os matches the detected OS.
|
||||||
|
func TestOSCheck_Match(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("ORCA_HOME", dir)
|
||||||
|
t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))
|
||||||
|
|
||||||
|
db, err := store.Open(filepath.Join(dir, "orca.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
repo := store.NewNodeRepo(db)
|
||||||
|
|
||||||
|
// Insert a localhost node with the currently-detected OS.
|
||||||
|
detected := osdetect.Detect()
|
||||||
|
if err := repo.Insert(context.Background(), &model.Node{
|
||||||
|
ID: "os-match-1", Name: "localhost", Address: "localhost:8443",
|
||||||
|
State: model.NodeStateReady, JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(),
|
||||||
|
Kind: "localhost", OS: detected,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("insert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c := OS()
|
||||||
|
r, msg := c.Run(context.Background())
|
||||||
|
if r != ResultPass {
|
||||||
|
t.Errorf("OS check: got %s, want PASS — %s", r, msg)
|
||||||
|
}
|
||||||
|
if !strings.Contains(msg, detected) {
|
||||||
|
t.Errorf("OS check message should contain %s, got: %s", detected, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOSCheck_Drift verifies the OS check returns WARN when the stored
|
||||||
|
// os differs from the detected os.
|
||||||
|
func TestOSCheck_Drift(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("ORCA_HOME", dir)
|
||||||
|
t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))
|
||||||
|
|
||||||
|
db, err := store.Open(filepath.Join(dir, "orca.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
repo := store.NewNodeRepo(db)
|
||||||
|
|
||||||
|
// Insert a localhost node with a deliberately wrong OS.
|
||||||
|
if err := repo.Insert(context.Background(), &model.Node{
|
||||||
|
ID: "os-drift-1", Name: "localhost", Address: "localhost:8443",
|
||||||
|
State: model.NodeStateReady, JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(),
|
||||||
|
Kind: "localhost", OS: "debian",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("insert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c := OS()
|
||||||
|
r, msg := c.Run(context.Background())
|
||||||
|
if r != ResultWarn {
|
||||||
|
t.Errorf("OS check: got %s, want WARN — %s", r, msg)
|
||||||
|
}
|
||||||
|
if !strings.Contains(msg, "drift") {
|
||||||
|
t.Errorf("OS check message should mention drift, got: %s", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProxmoxCheck_NoProxmoxNodes verifies the proxmox check returns
|
||||||
|
// WARN when no proxmox nodes are registered.
|
||||||
|
func TestProxmoxCheck_NoProxmoxNodes(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("ORCA_HOME", dir)
|
||||||
|
t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))
|
||||||
|
|
||||||
|
db, err := store.Open(filepath.Join(dir, "orca.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
c := Proxmox()
|
||||||
|
r, msg := c.Run(context.Background())
|
||||||
|
if r != ResultWarn {
|
||||||
|
t.Errorf("Proxmox check: got %s, want WARN — %s", r, msg)
|
||||||
|
}
|
||||||
|
if !strings.Contains(msg, "no proxmox nodes") {
|
||||||
|
t.Errorf("Proxmox check message should mention no proxmox nodes, got: %s", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProxmoxCheck_UnreachableNode verifies the proxmox check returns
|
||||||
|
// FAIL when a proxmox node is registered but unreachable (no SSH key
|
||||||
|
// or host down). We insert a proxmox node with an unreachable address;
|
||||||
|
// the SSH dial will fail (no SSH key file → error).
|
||||||
|
func TestProxmoxCheck_UnreachableNode(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("ORCA_HOME", dir)
|
||||||
|
t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))
|
||||||
|
|
||||||
|
db, err := store.Open(filepath.Join(dir, "orca.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
repo := store.NewNodeRepo(db)
|
||||||
|
|
||||||
|
// Insert a proxmox node. The SSH probe will fail because no SSH
|
||||||
|
// key exists in the test namespace dir.
|
||||||
|
if err := repo.Insert(context.Background(), &model.Node{
|
||||||
|
ID: "px-1", Name: "10.0.0.99", Address: "10.0.0.99:8443",
|
||||||
|
State: model.NodeStateReady, JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(),
|
||||||
|
Kind: "proxmox", OS: "pve",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("insert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c := Proxmox()
|
||||||
|
r, msg := c.Run(context.Background())
|
||||||
|
if r != ResultFail {
|
||||||
|
t.Errorf("Proxmox check: got %s, want FAIL — %s", r, msg)
|
||||||
|
}
|
||||||
|
if !strings.Contains(msg, "10.0.0.99") {
|
||||||
|
t.Errorf("Proxmox check message should mention the node, got: %s", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
// Suppress slog noise during tests.
|
// Suppress slog noise during tests.
|
||||||
_ = os.Setenv("ORCA_LOG_LEVEL", "error")
|
_ = os.Setenv("ORCA_LOG_LEVEL", "error")
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Package osdetect provides OS detection from /etc/os-release (D-032).
|
||||||
|
// It's a separate package to avoid import cycles between internal/cli
|
||||||
|
// and internal/doctor (both need to detect the local OS).
|
||||||
|
package osdetect
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// osReleasePaths are checked in order for the os-release file. The
|
||||||
|
// freedesktop.org spec says /etc/os-release is the canonical path,
|
||||||
|
// with /usr/lib/os-release as a fallback for minimal containers that
|
||||||
|
// may not symlink the former.
|
||||||
|
var osReleasePaths = []string{"/etc/os-release", "/usr/lib/os-release"}
|
||||||
|
|
||||||
|
// Detect reads /etc/os-release (then /usr/lib/os-release as a
|
||||||
|
// fallback) and returns the value of the ID= field. Returns "linux"
|
||||||
|
// (the generic fallback per D-032) if the file is missing, the ID
|
||||||
|
// field is absent, or the value is empty. Unknown ID values (e.g.
|
||||||
|
// "fedora", "arch") are returned verbatim — doctor os can warn on
|
||||||
|
// unknown values, but orca init must not fail.
|
||||||
|
func Detect() string {
|
||||||
|
for _, p := range osReleasePaths {
|
||||||
|
data, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if id := ParseID(data); id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "linux"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseID extracts the ID= value from os-release content.
|
||||||
|
// The format is shell-compatible KEY=VALUE lines; values may be
|
||||||
|
// double-quoted. Returns "" if ID is absent or empty.
|
||||||
|
func ParseID(data []byte) string {
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, value, ok := strings.Cut(line, "=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if key != "ID" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
// Strip surrounding double quotes (freedesktop spec allows quoted values).
|
||||||
|
if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
|
||||||
|
value = value[1 : len(value)-1]
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package osdetect
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseID_Ubuntu(t *testing.T) {
|
||||||
|
content := `NAME="Ubuntu"
|
||||||
|
VERSION="24.04.4 LTS (Noble Numbat)"
|
||||||
|
ID=ubuntu
|
||||||
|
ID_LIKE=debian`
|
||||||
|
if got := ParseID([]byte(content)); got != "ubuntu" {
|
||||||
|
t.Errorf("got %q, want ubuntu", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseID_Debian(t *testing.T) {
|
||||||
|
if got := ParseID([]byte("ID=debian\n")); got != "debian" {
|
||||||
|
t.Errorf("got %q, want debian", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseID_Alpine(t *testing.T) {
|
||||||
|
if got := ParseID([]byte("ID=alpine\n")); got != "alpine" {
|
||||||
|
t.Errorf("got %q, want alpine", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseID_PVE(t *testing.T) {
|
||||||
|
if got := ParseID([]byte("ID=pve\nID_LIKE=debian\n")); got != "pve" {
|
||||||
|
t.Errorf("got %q, want pve", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseID_QuotedValue(t *testing.T) {
|
||||||
|
if got := ParseID([]byte(`ID="ubuntu"` + "\n")); got != "ubuntu" {
|
||||||
|
t.Errorf("got %q, want ubuntu", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseID_MissingID(t *testing.T) {
|
||||||
|
if got := ParseID([]byte("NAME=Test\n")); got != "" {
|
||||||
|
t.Errorf("got %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseID_UnknownIDVerbatim(t *testing.T) {
|
||||||
|
if got := ParseID([]byte("ID=fedora\n")); got != "fedora" {
|
||||||
|
t.Errorf("got %q, want fedora", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseID_CommentsAndBlanks(t *testing.T) {
|
||||||
|
content := `# comment
|
||||||
|
|
||||||
|
NAME="Test"
|
||||||
|
# ID below
|
||||||
|
ID=arch`
|
||||||
|
if got := ParseID([]byte(content)); got != "arch" {
|
||||||
|
t.Errorf("got %q, want arch", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetect_FallbackToLinux(t *testing.T) {
|
||||||
|
orig := osReleasePaths
|
||||||
|
defer func() { osReleasePaths = orig }()
|
||||||
|
osReleasePaths = []string{filepath.Join(t.TempDir(), "nonexistent")}
|
||||||
|
if got := Detect(); got != "linux" {
|
||||||
|
t.Errorf("got %q, want linux (fallback)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetect_ReadsFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
orig := osReleasePaths
|
||||||
|
defer func() { osReleasePaths = orig }()
|
||||||
|
path := filepath.Join(dir, "os-release")
|
||||||
|
osReleasePaths = []string{path}
|
||||||
|
if err := os.WriteFile(path, []byte("ID=ubuntu\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
if got := Detect(); got != "ubuntu" {
|
||||||
|
t.Errorf("got %q, want ubuntu", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetect_FallbackToUsrLib(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
orig := osReleasePaths
|
||||||
|
defer func() { osReleasePaths = orig }()
|
||||||
|
osReleasePaths = []string{
|
||||||
|
filepath.Join(dir, "etc"), // missing
|
||||||
|
filepath.Join(dir, "usr-lib"), // fallback
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(osReleasePaths[1], []byte("ID=alpine\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
if got := Detect(); got != "alpine" {
|
||||||
|
t.Errorf("got %q, want alpine (from fallback)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user