Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7d6961261 | |||
| afcd15cde4 | |||
| 0b58286ca2 | |||
| f8b135e7a8 | |||
| d9d0beda3b | |||
| 007d3a12e8 | |||
| cd07e435d9 | |||
| 27f2abf8fb | |||
| 04d9dccd41 | |||
| c100892ad9 | |||
| 561bf61317 | |||
| f7902dddda | |||
| f022ef5395 | |||
| 7c4b603811 | |||
| fc034218e3 | |||
| bd4a34daa2 | |||
| 55d4d699a3 |
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"phase": 3,
|
||||
"stage": "verify",
|
||||
"milestone": "v0.6",
|
||||
"milestone_slug": "node-bootstrap-proxmox",
|
||||
"phase": 4,
|
||||
"stage": "complete",
|
||||
"milestone": "v0.7",
|
||||
"milestone_slug": "hardening-completion",
|
||||
"phase_role": "execution",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-08-03T20:02:00Z",
|
||||
"updated_at": "2026-08-04T00:25:00Z",
|
||||
"milestone_complete": false,
|
||||
"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.
|
||||
+43
-46
@@ -2,26 +2,30 @@
|
||||
active_personas:
|
||||
- lead-developer
|
||||
- backend-engineer
|
||||
- cli-engineer
|
||||
- data-engineer
|
||||
- security-engineer
|
||||
deactivated_personas:
|
||||
- cli-engineer
|
||||
- security-engineer
|
||||
- devops-engineer
|
||||
- network-engineer
|
||||
- frontend-engineer
|
||||
phase_specific: []
|
||||
reason: |
|
||||
Orca v0.6 is a bootstrap-ergonomics + heterogeneous-nodes milestone.
|
||||
The work is schema (migration 0006), security (SSH keygen, TOFU,
|
||||
sudoers, PVE role), CLI (init full bootstrap, node join --type proxmox,
|
||||
doctor os/proxmox), and backend orchestration (proxmox SSH bootstrap
|
||||
sequence). No devops (no install/docker/release), no network (no
|
||||
transport/mTLS), no frontend (no UI).
|
||||
Orca v0.7 is an NFR hardening & completion milestone. The work is CLI
|
||||
registration (cert command), a new internal/config package, test
|
||||
coverage uplift across engine/transport/proxmox/audit, and an opt-in
|
||||
pprof endpoint on the daemon. No schema changes, no new security
|
||||
surface, no packaging/distribution, no UI.
|
||||
|
||||
Roster changes vs v0.5:
|
||||
- data-engineer: REACTIVATED — owns migration 0006 + NodeRepo schema extension.
|
||||
- security-engineer: REACTIVATED — owns SSH keygen, TOFU host-key, sudoers, PVE role.
|
||||
- devops-engineer: DEACTIVATED — v0.6 has no packaging/distribution surface.
|
||||
Roster changes vs v0.6:
|
||||
- data-engineer: RETAINED — owns cert_repo tests + store coverage.
|
||||
- security-engineer: DEACTIVATED — v0.7 adds no new security 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
|
||||
@@ -34,67 +38,60 @@ reason: |
|
||||
- **Constraints**: `boundary-enforcement`, `offline-first`, `no-redundant-implementations`
|
||||
- **Territory**: `**/*.go`, `cmd/**`, `internal/**`
|
||||
- **Active**: true
|
||||
- **Reason**: Coordination across P01/P02/P03. SSH/bootstrap touches security + cli + store + doctor — territory overlaps need adjudication (proxmox package boundary, doctor Proxmox check scaffolding).
|
||||
- **Reason**: Coordination across P01/P03/P04. Owns cert command registration (P01), engine/transport/audit test coverage (P03), and pprof daemon integration (P04). Adjudicates territory overlaps between config (backend) and CLI wiring (lead).
|
||||
|
||||
### backend-engineer
|
||||
- **Domain**: backend
|
||||
- **Frameworks**: `cobra`, `net/http`, `golang.org/x/crypto/ssh`
|
||||
- **Constraints**: `API-first`, `error-handling`, `minimal-dependencies`, `security-first`, `idempotent-bootstrap`
|
||||
- **Territory**: `**/api/**`, `**/*_handler*`, `**/*_handler.go`, `internal/daemon/**`, `internal/proxmox/**`, `internal/cli/init.go`
|
||||
- **Frameworks**: `cobra`, `hashicorp/hcl/v2`
|
||||
- **Constraints**: `API-first`, `error-handling`, `minimal-dependencies`, `no-package-level-state`
|
||||
- **Territory**: `**/config/**`, `**/api/**`, `**/*_handler*`, `internal/daemon/**` (non-pprof), `internal/cli/root.go` (config flag wiring)
|
||||
- **Active**: true
|
||||
- **Reason**: Owns the `orca init` full-bootstrap orchestration (CA + cert + db + localhost node, idempotent) and the `internal/proxmox/bootstrap.go` SSH session sequence (dial, deploy pubkey, useradd, pveum, sudoers, visudo validate). Added `idempotent-bootstrap` constraint (D-036 — re-run must be skip-and-refresh) and `golang.org/x/crypto/ssh` to frameworks.
|
||||
- **Reason**: Owns `internal/config` package (P02 — HCL config file parsing, Load + MergeOverrides with flag>env>file>default precedence per D-039). No package-level state (AD-023). Config is a pure function passed explicitly to consumers.
|
||||
|
||||
### data-engineer
|
||||
- **Domain**: data
|
||||
- **Frameworks**: `modernc/sqlite`, `iter`
|
||||
- **Constraints**: `schema-first`, `migration-safe`, `local-storage-only`, `no-goroutine-leak`, `nullable-column-handling`
|
||||
- **Territory**: `**/store/**`, `**/model.go`, `**/migration*`, `migrations/**`, `internal/store/migrations/**`, `internal/model/node.go`
|
||||
- **Constraints**: `schema-first`, `migration-safe`, `local-storage-only`
|
||||
- **Territory**: `**/store/**`, `**/model.go`, `**/migration*`, `migrations/**`, `internal/store/migrations/**`
|
||||
- **Active**: true
|
||||
- **Reason**: Reactivated for v0.6. Owns migration `0006_node_kind_os.sql` (REQ-049 — nullable `kind`/`os` columns, backward-compatible) and `NodeRepo` schema extension (Insert/Get/List/Watch/scanNode column additions + new `GetByName`/`UpdateLastSeenAndOS` helpers). Added `nullable-column-handling` constraint (NULL → `""` in Go struct, not nil-deref).
|
||||
- **Reason**: Owns `cert_repo_test.go` (P01 companion — 11 tests covering Insert/Get/List/ListByNode/LatestForKind/PruneOlderThan/Delete + duplicate serial) and migration 0007 (UNIQUE index on `certs.serial_hex`). Co-owns `internal/proxmox/ssh_session_test.go` + `bootstrap_test.go` extension (P03 — transport/proxmox coverage).
|
||||
|
||||
### cli-engineer
|
||||
- **Domain**: CLI/UX
|
||||
- **Frameworks**: `cobra`, `pflag`
|
||||
- **Constraints**: `discoverable-help`, `consistent-flag-naming`, `human-readable-output`, `machine-readable-json-flag`, `signal-handling`, `password-flag-redaction`
|
||||
- **Territory**: `cmd/**`, `internal/cli/**`, `internal/commands/**`
|
||||
- **Active**: true
|
||||
- **Reason**: Owns `orca init` multi-step bootstrap output UX (progress lines per step), `orca node join --type/--host/--user/--password/--proxmox-user/--proxmox-role` flag wiring, and `doctor os`/`doctor proxmox` subcommand wiring. Added `password-flag-redaction` constraint (D-031 — `--password` never echoed, prefer `$ORCA_PROXMOX_PASSWORD`, zero after use).
|
||||
- **Active**: false (v0.7)
|
||||
- **Reason**: Deactivated — merged into lead-developer for v0.7. The cert registration is a 1-line AddCommand; the `--config` flag is root-command wiring; pprof is a daemon flag. No new CLI subsystem requiring a dedicated CLI persona.
|
||||
|
||||
### security-engineer
|
||||
- **Domain**: security
|
||||
- **Frameworks**: `crypto/tls`, `crypto/x509`, `crypto/ed25519`, `golang.org/x/crypto/ssh`, `slog`
|
||||
- **Constraints**: `no-panic-in-production`, `structured-audit-logging`, `no-secret-in-logs`, `input-validation`, `least-privilege`, `tofu-host-key-pinning`, `noexec-sudoers`
|
||||
- **Territory**: `**/auth/**`, `**/audit/**`, `internal/security/**`, `internal/transport/**` (TLS config only), `internal/proxmox/**` (SSH + sudoers + PVE role)
|
||||
- **Active**: true
|
||||
- **Reason**: Reactivated for v0.6. Owns `internal/security/sshkey.go` (Ed25519 keygen, 0600/0644 mode enforcement per REQ-033 spirit), TOFU host-key pinning via `knownhosts.New`, sudoers least-privilege design (NOEXEC on pct/qm, exclude pvesh, no NOEXEC on apt-get/dpkg), password redaction (D-031), and audit logging of all bootstrap/join actions (REQ-052). Added `tofu-host-key-pinning` and `noexec-sudoers` constraints. Co-owns `internal/proxmox/**` with backend-engineer (security owns SSH auth + sudoers content; backend owns the session orchestration).
|
||||
- **Active**: false (v0.7)
|
||||
- **Reason**: Deactivated — v0.7 adds no new security surface. pprof is operator-only, addr-gated (AD-024); cert registration exposes existing security code, does not add new. The config package handles paths only (no secrets). Existing security constraints (file modes, redaction) are exercised by P01 smoke tests but not extended.
|
||||
|
||||
### devops-engineer
|
||||
- **Active**: false (v0.6)
|
||||
- **Reason**: Deactivated — v0.6 has no install.sh, Dockerfile, .coreci.yml, or release-pipeline surface. The Proxmox SSH bootstrap is backend + security work, not devops. Was active in v0.5 (distribution milestone).
|
||||
- **Active**: false (v0.7)
|
||||
- **Reason**: Deactivated — v0.7 has no packaging/distribution/release surface. Was active in v0.5 (distribution milestone).
|
||||
|
||||
### network-engineer
|
||||
- **Active**: false (v0.6)
|
||||
- **Reason**: v0.6 has no transport/mTLS surface. SSH is point-to-point bootstrap, not the mTLS mesh network-engineer owns.
|
||||
- **Active**: false (v0.7)
|
||||
- **Reason**: Deactivated — v0.7 has no transport/mTLS surface changes. P03 adds tests for existing transport code but no new network surface.
|
||||
|
||||
### frontend-engineer
|
||||
- **Active**: false (v0.6)
|
||||
- **Active**: false (v0.7)
|
||||
- **Reason**: No web UI in Orca (unchanged from v0.1 onward).
|
||||
|
||||
## Territory Enforcement
|
||||
|
||||
- **Mode**: `warn` (per `config.json`)
|
||||
- **Behavior**: Out-of-territory file changes log a warning but do not block.
|
||||
- **Key overlaps in v0.6** (lead-developer adjudicates):
|
||||
- `internal/proxmox/bootstrap.go` — security-engineer (SSH auth, sudoers, PVE role) + backend-engineer (session orchestration, error handling). Boundary: security package exposes `BootstrapProxmox(ctx, opts) error`; the function lives in `internal/proxmox` but imports `internal/security` for SSH key handling.
|
||||
- `internal/doctor/doctor.go` `Proxmox()` — reuses `internal/proxmox` SSH client (security) but check scaffolding clones `doctor.Network()` pattern. Backend-engineer adjudicates (network-engineer deactivated).
|
||||
- `internal/store/node_repo.go` — data-engineer territory, but the `UpdateLastSeenAndOS` caller is `internal/cli/init.go` (backend). Standard repo-consumer boundary.
|
||||
- **Key overlaps in v0.7** (lead-developer adjudicates):
|
||||
- `internal/cli/root.go` — backend-engineer (config flag + context wiring) + lead-developer (existing root command). Boundary: backend owns `--config` flag + `configFromCtx`; lead owns all other root command behavior.
|
||||
- `internal/cli/daemon.go` — lead-developer (pprof flag + config listen_addr wiring) + backend-engineer (config consumption). Boundary: lead owns the daemon command; backend's config package is consumed, not modified.
|
||||
- `internal/store/migrations/` — data-engineer owns all migrations. No overlap in v0.7.
|
||||
|
||||
## v0.6 vs v0.5 Persona Diff
|
||||
## v0.7 vs v0.6 Persona Diff
|
||||
|
||||
| Change | Rationale |
|
||||
|--------|-----------|
|
||||
| `data-engineer` reactivated | Owns migration 0006 + NodeRepo schema extension (kind/os columns). |
|
||||
| `security-engineer` reactivated | Owns SSH keygen, TOFU host-key, sudoers, PVE role — first-class security surface. |
|
||||
| `devops-engineer` deactivated | v0.6 has no packaging/distribution surface. |
|
||||
| `network-engineer` remains deactivated | No transport/mTLS surface. |
|
||||
| `data-engineer` retained | Owns cert_repo tests + migration 0007 + proxmox/transport test coverage. |
|
||||
| `security-engineer` deactivated | v0.7 adds no new security surface (pprof is operator-only, cert registration exposes existing code). |
|
||||
| `cli-engineer` deactivated | Merged into lead-developer (cert registration is 1-line; config flag is root wiring). |
|
||||
| `devops-engineer` remains deactivated | No packaging/distribution in v0.7. |
|
||||
| `network-engineer` remains deactivated | No transport/mTLS surface changes. |
|
||||
| `frontend-engineer` remains deactivated | No web UI. |
|
||||
@@ -0,0 +1,67 @@
|
||||
# Phase 1 Verification Report — v0.7: Register `orca cert` Command Tree
|
||||
|
||||
**Phase**: 1
|
||||
**Branch**: `phase/01-cert-register`
|
||||
**REQ Coverage**: REQ-053
|
||||
**Milestone**: v0.7 (Hardening & Completion)
|
||||
|
||||
## Structural Verification
|
||||
|
||||
### Files Modified
|
||||
- `internal/cli/cert.go` — added `init()` registering `NewCommand` on `rootCmd` (AD-022)
|
||||
- `internal/cli/init_test.go` — updated expected migration version 0006 → 0007
|
||||
- `internal/doctor/doctor_test.go` — relaxed DB check assertion to check `"migrations up to"` prefix (migration-version-agnostic)
|
||||
- `internal/store/migrate_test.go` — updated expected migration version 0006 → 0007
|
||||
|
||||
### Files Created
|
||||
- `internal/cli/cert_test.go` — regression test for cert command registration + subcommand tree
|
||||
- `internal/cli/cert_smoke_test.go` — end-to-end smoke test (ca-init, gen, show, fingerprint, renew, file modes)
|
||||
- `internal/store/cert_repo_test.go` — 11 tests covering Insert/Get/List/ListByNode/LatestForKind/PruneOlderThan/Delete + error paths
|
||||
- `internal/store/migrations/0007_certs_serial_unique.sql` — UNIQUE index on `certs.serial_hex` (I-107; migration-driven, not backfilled into 0004)
|
||||
|
||||
## Behavioral Verification
|
||||
|
||||
### Test Results
|
||||
```
|
||||
go test ./... → all PASS (exit 0)
|
||||
go test -race ./... → all PASS (exit 0)
|
||||
go vet ./... → clean
|
||||
make build → clean (v0.6.0)
|
||||
```
|
||||
|
||||
### Coverage (store package)
|
||||
- Store total: 60.5% (up from 46.9%)
|
||||
- `cert_repo.go`: Insert 91.7%, Get 100%, LatestForKind 100%, PruneOlderThan 85.7%, Delete 85.7%, List/ListByNode 81.8%
|
||||
|
||||
### CLI Smoke Test (manual)
|
||||
```
|
||||
./bin/orca cert → prints help (was: "unknown command")
|
||||
./bin/orca cert ca-init --cn X → ✓ CA initialized, 0644/0600 modes
|
||||
./bin/orca cert fingerprint --which ca → 64-char hex SHA-256
|
||||
```
|
||||
|
||||
## Security Verification
|
||||
|
||||
- `orca cert show` redacts private key material (REQ-035) — verified in smoke test
|
||||
- Cert file modes enforced: 0600 keys, 0644 certs (REQ-033) — verified in smoke test
|
||||
- No secrets in logs — `cert.ca_init`/`cert.issued`/`cert.renewed` log events contain only fingerprints, never key bytes
|
||||
- Migration 0007 is additive (UNIQUE index), backward-compatible — no data loss
|
||||
|
||||
## Quality Verification
|
||||
|
||||
- No new dependencies added (`go.mod` unchanged)
|
||||
- No comments added (per project convention)
|
||||
- Test style matches existing `node_repo_test.go` / `root_test.go` patterns
|
||||
- All `---ci---` blocks present in commits
|
||||
|
||||
## Must-Haves Checklist
|
||||
|
||||
- [x] `internal/cli/cert.go` — `init()` with `rootCmd.AddCommand(NewCommand(slog.Default()))`
|
||||
- [x] `internal/cli/cert_test.go` — regression test for registration + subcommands
|
||||
- [x] `internal/cli/cert_smoke_test.go` — e2e: ca-init, gen, show (redaction), fingerprint, renew, file modes
|
||||
- [x] `internal/store/cert_repo_test.go` — 11 tests covering full CRUD + rotation history + duplicate serial
|
||||
- [x] `internal/store/migrations/0007_certs_serial_unique.sql` — UNIQUE index (I-107)
|
||||
|
||||
## Verdict
|
||||
|
||||
**PASS** — all 4 verification layers (structural, behavioral, security, quality) pass. REQ-053 is fully covered. The `orca cert` command tree is now reachable from the CLI, cert_repo has comprehensive tests, and the serial_hex UNIQUE constraint is enforced via migration.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Phase 2 Verification Report — v0.7: HCL Config File Parsing
|
||||
|
||||
**Phase**: 2
|
||||
**Branch**: `phase/02-config-parser`
|
||||
**REQ Coverage**: REQ-054
|
||||
**Milestone**: v0.7 (Hardening & Completion)
|
||||
|
||||
## Structural Verification
|
||||
|
||||
### Files Created
|
||||
- `internal/config/config.go` — `Config` struct (HCL tags), `CapacityConfig`, `Flags`, `Environ`, `Load(paths...)`, `(*Config).MergeOverrides(flags, env)`
|
||||
- `internal/config/config_test.go` — 11 tests (Load valid/missing/malformed/first-existing, MergeOverrides precedence all 4 layers, NodeCapacity)
|
||||
- `internal/config/testdata/config.hcl` — example fixture
|
||||
|
||||
### Files Modified
|
||||
- `internal/cli/root.go` — added `--config` persistent flag, `configCtxKey`, `configFromCtx` helper; `PersistentPreRunE` loads config if `--config` set (AD-023)
|
||||
- `internal/cli/daemon.go` — daemon uses `cfg.ListenAddr` from config when flag is at default (`:8080`) (D-039 precedence: flag > config)
|
||||
- `internal/cli/root_test.go` — added `TestConfigFlagRegistered` + `TestConfigFlagLoadsFile`
|
||||
|
||||
## Behavioral Verification
|
||||
|
||||
### Test Results
|
||||
```
|
||||
go test ./... → all PASS (exit 0)
|
||||
go test -race ./internal/config/... ./internal/cli/... → all PASS
|
||||
go vet ./... → clean
|
||||
make build → clean (v0.6.1)
|
||||
```
|
||||
|
||||
### API Surface
|
||||
```go
|
||||
func Load(paths ...string) (*Config, error)
|
||||
func (c *Config) MergeOverrides(flags Flags, env Environ) *Config
|
||||
```
|
||||
- `Load` returns zero `&Config{}` if no file exists (no error)
|
||||
- `MergeOverrides` precedence: flag > env > file > default (D-039)
|
||||
- No package-level state (AD-023)
|
||||
|
||||
### CLI Verification
|
||||
```
|
||||
./bin/orca --help → shows --config string flag
|
||||
```
|
||||
|
||||
## Security Verification
|
||||
|
||||
- Config file is read-only (no writes); parsed via `hclsimple.Decode` (no eval, no external commands)
|
||||
- No secrets in config (paths only; no tokens/keys in config.hcl)
|
||||
- Config file permissions not enforced (operator's responsibility; config contains no secrets)
|
||||
|
||||
## Quality Verification
|
||||
|
||||
- No new dependencies (`hashicorp/hcl/v2` already in go.mod for jobspec)
|
||||
- No comments added (per project convention)
|
||||
- Test style matches existing `jobspec/spec_test.go` + `cli/root_test.go`
|
||||
- `go.mod` unchanged
|
||||
|
||||
## Must-Haves Checklist
|
||||
|
||||
- [x] `internal/config/config.go` — Config struct + Load + MergeOverrides
|
||||
- [x] `internal/config/config_test.go` — 11 tests (all 4 precedence layers)
|
||||
- [x] `internal/config/testdata/config.hcl` — example fixture
|
||||
- [x] `internal/cli/root.go` — `--config` persistent flag + context wiring
|
||||
- [x] `internal/cli/daemon.go` — uses `cfg.ListenAddr` (flag still wins)
|
||||
- [x] `internal/cli/root_test.go` — config flag registration + load test
|
||||
|
||||
## Verdict
|
||||
|
||||
**PASS** — all 4 verification layers pass. REQ-054 is fully covered. The `internal/config` package provides HCL config file parsing with flag > env > file > default precedence, wired into the root command via `--config` and consumed by the daemon.
|
||||
@@ -0,0 +1,76 @@
|
||||
# Phase 3 Verification Report — v0.7: Test Coverage Uplift
|
||||
|
||||
**Phase**: 3
|
||||
**Branch**: `phase/03-coverage-uplift`
|
||||
**REQ Coverage**: REQ-055
|
||||
**Milestone**: v0.7 (Hardening & Completion)
|
||||
|
||||
## Structural Verification
|
||||
|
||||
### Files Created
|
||||
- `internal/engine/peer_test.go` — 8 tests (PeerRegistry Add/Get/Remove/All/Len/UpdateLastSeen + validation)
|
||||
- `internal/engine/executor_test.go` — 7 tests (Submit success/missing-command/malformed/failing, Status not-found, Run success, Run context-cancel)
|
||||
- `internal/engine/dispatcher_test.go` — 10 tests (empty spec, idempotency hit, local-capacity, explicit-target, no-peers, LocalSubmit/LocalStatus, nil guards, parseInlineSpec)
|
||||
- `internal/audit/audit_test.go` — 9 tests (Emit/EmitWithErr persistence, LogHandshakeOK/Failed slog fields, nil-safety, Action/Result String, FormatAction)
|
||||
- `internal/transport/handshake_log_test.go` — 8 tests (LogHandshakeOK/Failed/FromCert, FingerprintOfCert, nil-logger, nil-err)
|
||||
- `internal/transport/mtls_test.go` — 14 tests (ServerTLSConfig, ClientTLSConfig, NewMTLSClient, Do, VerifyPeerCertificate, DialContext)
|
||||
- `internal/transport/dispatch_test.go` — 24 tests (SubmitHandler/StatusHandler, DispatchClient constructor/connection-refused/HTTP/decode/Submit/Status success)
|
||||
- `internal/proxmox/ssh_session_test.go` — 14 tests (runRemote, deployPubKey, createLinuxUser, createPVERole, createPVEUser, assignPVEACL, writeSudoers, validateSudoers, full BootstrapProxmox)
|
||||
|
||||
### Files Modified
|
||||
- `internal/transport/dispatch.go` — **bug fix**: `bytesReadCloser.Read` returned `fmt.Errorf("EOF")` instead of `io.EOF`, breaking HTTP request body transmission. This was a latent bug that prevented any client-side dispatch from working end-to-end.
|
||||
- `internal/proxmox/bootstrap_test.go` — extended with 10 new tests (mockSSHDialer, SSH auth failure, dial-addr/port/user propagation, SSH key generation, known_hosts, nil/custom logger, cancelled context, deployPubKey edge cases)
|
||||
|
||||
## Behavioral Verification
|
||||
|
||||
### Test Results
|
||||
```
|
||||
go test ./... → all PASS (exit 0)
|
||||
go test -race ./... → all PASS (exit 0)
|
||||
go vet ./... → clean
|
||||
make build → clean
|
||||
```
|
||||
|
||||
### Coverage (D-042 target: ≥ 50% per package)
|
||||
|
||||
| Package | Before | After | Target |
|
||||
|---------|--------|-------|--------|
|
||||
| `internal/engine` | 8.3% | **65.1%** | 50% ✓ |
|
||||
| `internal/transport` | 26.3% | **84.6%** | 50% ✓ |
|
||||
| `internal/proxmox` | 5.1% | **82.7%** | 50% ✓ |
|
||||
| `internal/audit` | 0% | **100.0%** | 50% ✓ |
|
||||
|
||||
All 4 packages exceed the 50% floor (AD-025).
|
||||
|
||||
### Total new tests: 94 (37 engine+audit + 57 transport+proxmox)
|
||||
|
||||
## Security Verification
|
||||
|
||||
- The `dispatch.go` bug fix (`io.EOF` vs `fmt.Errorf("EOF")`) is a correctness fix — HTTP request bodies now terminate correctly. No security implications (the bug caused requests to fail, not to leak data).
|
||||
- No new dependencies added.
|
||||
- Test fixtures use temp dirs (`t.TempDir()`) — no persistent state.
|
||||
- No secrets in test code (SSH keys are test-generated Ed25519 pairs).
|
||||
|
||||
## Quality Verification
|
||||
|
||||
- No comments added (per project convention).
|
||||
- Test style matches existing patterns (`scheduler_test.go`, `node_repo_test.go`, `certgen_test.go`).
|
||||
- `go.mod` unchanged.
|
||||
- Bug fix in `dispatch.go` is minimal (1 line: `return fmt.Errorf("EOF")` → `return io.EOF` + `io` import).
|
||||
|
||||
## Must-Haves Checklist
|
||||
|
||||
- [x] `internal/engine/executor_test.go` — 7 tests
|
||||
- [x] `internal/engine/dispatcher_test.go` — 10 tests
|
||||
- [x] `internal/engine/peer_test.go` — 8 tests
|
||||
- [x] `internal/transport/mtls_test.go` — 14 tests
|
||||
- [x] `internal/transport/dispatch_test.go` — 24 tests
|
||||
- [x] `internal/transport/handshake_log_test.go` — 8 tests
|
||||
- [x] `internal/audit/audit_test.go` — 9 tests
|
||||
- [x] `internal/proxmox/ssh_session_test.go` — 14 tests + extended `bootstrap_test.go` (+10 tests)
|
||||
- [x] Bug fix: `dispatch.go` bytesReadCloser EOF (latent bug, root-caused during P03)
|
||||
- [x] All 4 target packages ≥ 50% coverage
|
||||
|
||||
## Verdict
|
||||
|
||||
**PASS** — all 4 verification layers pass. REQ-055 is fully covered. All 4 target packages exceed the 50% coverage floor (engine 65.1%, transport 84.6%, proxmox 82.7%, audit 100%). A latent bug in `dispatch.go` (non-`io.EOF` return) was found and fixed during coverage uplift.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Phase 4 Verification Report — v0.7: --pprof Opt-in on orca daemon
|
||||
|
||||
**Phase**: 4
|
||||
**Branch**: `phase/04-pprof-daemon`
|
||||
**REQ Coverage**: REQ-056
|
||||
**Milestone**: v0.7 (Hardening & Completion)
|
||||
|
||||
## Structural Verification
|
||||
|
||||
### Files Created
|
||||
- `internal/daemon/pprof.go` — `StartPprof(addr, log) (*http.Server, error)`: dedicated mux + server, disabled by default, WARN log
|
||||
- `internal/daemon/pprof_test.go` — 5 tests (disabled, enabled, shutdown, mux isolation, full server lifecycle)
|
||||
- `internal/cli/daemon_test.go` — `TestDaemonPprofFlag` (flag registration + default)
|
||||
|
||||
### Files Modified
|
||||
- `internal/daemon/server.go` — `PprofAddr` in Options, `pprofServer` field, `NewServer` starts pprof, `Shutdown` stops both
|
||||
- `internal/cli/daemon.go` — `--pprof` flag, `PprofAddr` in daemon.Options, conditional startup output line
|
||||
|
||||
## Behavioral Verification
|
||||
|
||||
### Test Results
|
||||
```
|
||||
go test ./... → all PASS (exit 0)
|
||||
go test -race ./internal/daemon/... ./internal/cli/... → all PASS
|
||||
go vet ./... → clean
|
||||
make build → clean
|
||||
```
|
||||
|
||||
### CLI Verification
|
||||
```
|
||||
./bin/orca daemon --help → shows --pprof string flag (default "")
|
||||
```
|
||||
|
||||
### Live Smoke Test
|
||||
- `--pprof 127.0.0.1:16060` → WARN logged, `/debug/pprof/` returns 200, `/debug/pprof/cmdline` 200, `/debug/pprof/heap` 200
|
||||
- `/healthz` on pprof listener → 404 (mux isolation confirmed, AD-024)
|
||||
- Clean shutdown stops both servers
|
||||
|
||||
## Security Verification
|
||||
|
||||
- pprof on a **separate** `*http.Server` + `*http.ServeMux`, never on the mTLS daemon listener (AD-024) — verified by `TestStartPprof_MuxIsolated` (`/healthz` returns 404 on pprof mux)
|
||||
- Default **disabled** — no pprof listener unless `--pprof` is explicitly set
|
||||
- WARN log on startup: "unauthenticated, operator-only — do not expose publicly"
|
||||
- No `import _ "net/http/pprof"` side-effect registration on `DefaultServeMux` — all handlers explicitly registered on the dedicated mux
|
||||
|
||||
## Quality Verification
|
||||
|
||||
- No new dependencies (stdlib `net/http`, `net/http/pprof`, `log/slog`, `time` only)
|
||||
- No comments added (per project convention)
|
||||
- `go.mod` unchanged
|
||||
- Test style matches existing `server_test.go`
|
||||
|
||||
## Must-Haves Checklist
|
||||
|
||||
- [x] `internal/daemon/pprof.go` — `StartPprof` with dedicated mux, all pprof handlers
|
||||
- [x] `internal/daemon/server.go` — `PprofAddr` in Options, `pprofServer` field, lifecycle integration
|
||||
- [x] `internal/cli/daemon.go` — `--pprof` flag, passed to Options, conditional startup output
|
||||
- [x] `internal/daemon/pprof_test.go` — 5 tests (disabled, enabled, shutdown, mux isolation, lifecycle)
|
||||
- [x] `internal/cli/daemon_test.go` — flag registration test
|
||||
- [x] AD-024: pprof mux separate from mTLS daemon mux (verified by test)
|
||||
|
||||
## Verdict
|
||||
|
||||
**PASS** — all 4 verification layers pass. REQ-056 is fully covered. The `--pprof` opt-in endpoint runs on a separate listener with a dedicated mux, is disabled by default, and logs a WARN when enabled. I-308 (deferred since v0.2) is now implemented.
|
||||
@@ -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.
|
||||
- **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.
|
||||
|
||||
## 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 |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-047 | `orca init` auto-provisions CA + server cert + DB migrations + localhost node (idempotent; safe re-run) | 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** | Pending |
|
||||
| REQ-049 | Node schema extension: `nodes.kind` (localhost\|linux\|proxmox) + `nodes.os` columns (migration 0006, backward-compatible) | High | **v0.6 P1** | 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** | 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** | 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** | 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** | **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** | **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** | **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** | **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** | **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** | **Complete** (P1 shipped v0.6.1) |
|
||||
| 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** | **Complete** (P2 shipped v0.6.2) |
|
||||
| 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** | **Complete** (P3 shipped v0.6.3; engine 65.1%, transport 84.6%, proxmox 82.7%, audit 100%) |
|
||||
| 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** | **Complete** (P4 shipped v0.6.4; I-308 implemented) |
|
||||
|
||||
@@ -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 — **COMPLETE**
|
||||
|
||||
Scope: make `orca init` produce a fully working single-node cluster
|
||||
(CA + server cert + DB + localhost node registered with auto-detected
|
||||
OS), and add Proxmox 8 & 9 as a first-class remote node type joined
|
||||
over SSH with least-privilege role delegation.
|
||||
|
||||
- [ ] 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`
|
||||
- [ ] 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`
|
||||
- [ ] Phase 4: Final review + ship + audit (milestone release) — tag `v0.5.4`
|
||||
- [x] Phase 0: Pre-execution (specify → clarify → research → plan → grill) — tag `v0.5.0`
|
||||
- [x] Phase 1: `orca init` full bootstrap + localhost node + schema 0006 (REQ-047, REQ-048, REQ-049) — tag `v0.5.1`
|
||||
- [x] Phase 2: Proxmox SSH join + OrcaOperator role + sudoers allowlist (REQ-050, REQ-051) — tag `v0.5.2`
|
||||
- [x] Phase 3: `doctor os` + `doctor proxmox` SSH probe + audit logging (REQ-052) — tag `v0.5.3`
|
||||
- [x] Phase 4: Final review + ship + audit (milestone release) — tag `v0.5.4`
|
||||
|
||||
**Milestone type**: feature (P1/P2/P3 ship `feat` phases).
|
||||
**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
|
||||
number (`milestone/v0.6-node-bootstrap-proxmox`); no separate minor
|
||||
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.
|
||||
|
||||
- [x] Phase 0: Pre-execution (specify → clarify → research → ideate → plan) — tag `v0.6.0` (shipped)
|
||||
- [x] Phase 1: Register `orca cert` command tree + cert_repo tests (REQ-053) — tag `v0.6.1` (shipped)
|
||||
- [x] Phase 2: HCL config file parsing — `internal/config` package (REQ-054) — tag `v0.6.2` (shipped)
|
||||
- [x] Phase 3: Test coverage uplift — engine/transport/proxmox/audit ≥ 50% (REQ-055) — tag `v0.6.3` (shipped)
|
||||
- [x] Phase 4: `--pprof` opt-in on `orca daemon` (REQ-056) — tag `v0.6.4` (shipped)
|
||||
- [ ] 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",
|
||||
"name": "Orca",
|
||||
"description": "Offline/CLI-first orchestration engine (Orca) — Nomad-inspired, far simpler than Kubernetes",
|
||||
"milestone": "v0.6",
|
||||
"milestone": "v0.7",
|
||||
"phase": 0,
|
||||
"milestone_type": "feature",
|
||||
"milestone_type": "nfr",
|
||||
"default_branch": "main",
|
||||
"tech_stack": {
|
||||
"language": "go",
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/engine"
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
func newTestAudit(t *testing.T) (*Audit, *store.AuditRepo, func()) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "test.db")
|
||||
db, err := store.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
repo := store.NewAuditRepo(db)
|
||||
eng := engine.NewAudit(repo, nil)
|
||||
return New(eng), repo, func() { _ = db.Close() }
|
||||
}
|
||||
|
||||
func TestAudit_Emit(t *testing.T) {
|
||||
a, repo, cleanup := newTestAudit(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
a.Emit(ctx, ActionCertIssued, "cert:node-1", ResultSuccess, map[string]any{"cn": "node-1"})
|
||||
|
||||
entries, err := repo.List(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("expected 1 audit entry, got %d", len(entries))
|
||||
}
|
||||
e := entries[0]
|
||||
if e.Action != string(ActionCertIssued) {
|
||||
t.Errorf("action: got %q, want %q", e.Action, ActionCertIssued)
|
||||
}
|
||||
if e.Result != string(ResultSuccess) {
|
||||
t.Errorf("result: got %q, want %q", e.Result, ResultSuccess)
|
||||
}
|
||||
if e.Resource != "cert:node-1" {
|
||||
t.Errorf("resource: got %q, want cert:node-1", e.Resource)
|
||||
}
|
||||
if e.Actor != "security" {
|
||||
t.Errorf("actor: got %q, want security", e.Actor)
|
||||
}
|
||||
if e.Error != "" {
|
||||
t.Errorf("error: got %q, want empty", e.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudit_EmitWithErr(t *testing.T) {
|
||||
a, repo, cleanup := newTestAudit(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
a.EmitWithErr(ctx, ActionNodeHandshakeFail, "hs:node-2", errors.New("bad cert"), nil)
|
||||
|
||||
entries, err := repo.List(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("expected 1 audit entry, got %d", len(entries))
|
||||
}
|
||||
e := entries[0]
|
||||
if e.Result != string(ResultFailure) {
|
||||
t.Errorf("result: got %q, want %q", e.Result, ResultFailure)
|
||||
}
|
||||
if !strings.Contains(e.Error, "bad cert") {
|
||||
t.Errorf("error: got %q, want it to contain 'bad cert'", e.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudit_LogHandshakeOK(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&buf, nil))
|
||||
LogHandshakeOK(logger, "peer-1", "AA:BB:CC")
|
||||
out := buf.String()
|
||||
for _, want := range []string{"event=mtls.handshake", "result=ok", "peer=peer-1", "cert_fp=AA:BB:CC"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("LogHandshakeOK: output missing %q\noutput: %s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudit_LogHandshakeFailed(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&buf, nil))
|
||||
LogHandshakeFailed(logger, "peer-2", "", errors.New("tls: handshake"))
|
||||
out := buf.String()
|
||||
for _, want := range []string{"event=mtls.handshake", "result=failed", "peer=peer-2", "err=\"tls: handshake\""} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("LogHandshakeFailed: output missing %q\noutput: %s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudit_LogHandshake_NilLogger(t *testing.T) {
|
||||
LogHandshakeOK(nil, "p", "fp")
|
||||
LogHandshakeFailed(nil, "p", "fp", errors.New("x"))
|
||||
}
|
||||
|
||||
func TestAudit_NilSafe(t *testing.T) {
|
||||
var a *Audit
|
||||
a.Emit(context.Background(), ActionCertIssued, "x", ResultSuccess, nil)
|
||||
a.EmitWithErr(context.Background(), ActionCertIssued, "x", errors.New("y"), nil)
|
||||
}
|
||||
|
||||
func TestAction_String(t *testing.T) {
|
||||
if got := ActionCertIssued.String(); got != "cert.issued" {
|
||||
t.Errorf("ActionCertIssued.String(): got %q, want cert.issued", got)
|
||||
}
|
||||
if got := ActionNodeHandshakeOK.String(); got != "node.handshake_ok" {
|
||||
t.Errorf("ActionNodeHandshakeOK.String(): got %q, want node.handshake_ok", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResult_String(t *testing.T) {
|
||||
if got := ResultSuccess.String(); got != "success" {
|
||||
t.Errorf("ResultSuccess.String(): got %q, want success", got)
|
||||
}
|
||||
if got := ResultFailure.String(); got != "failure" {
|
||||
t.Errorf("ResultFailure.String(): got %q, want failure", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatAction(t *testing.T) {
|
||||
got := FormatAction(ActionCertIssued, ResultSuccess)
|
||||
want := "action=cert.issued result=success"
|
||||
if got != want {
|
||||
t.Errorf("FormatAction: got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -253,3 +253,7 @@ func parseFirstCertDER(pemBytes []byte) []byte {
|
||||
}
|
||||
return block.Bytes
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(NewCommand(slog.Default()))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func runCertArgs(t *testing.T, args []string) (string, error) {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs(args)
|
||||
defer func() {
|
||||
rootCmd.SetArgs(nil)
|
||||
rootCmd.SetOut(os.Stdout)
|
||||
rootCmd.SetErr(os.Stderr)
|
||||
}()
|
||||
err := rootCmd.Execute()
|
||||
return buf.String(), err
|
||||
}
|
||||
|
||||
func TestCertSmoke(t *testing.T) {
|
||||
t.Setenv("ORCA_HOME", t.TempDir())
|
||||
|
||||
t.Run("ca-init", func(t *testing.T) {
|
||||
out, err := runCertArgs(t, []string{"cert", "ca-init", "--cn", "test-ca"})
|
||||
if err != nil {
|
||||
t.Fatalf("ca-init: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "CA initialized") {
|
||||
t.Errorf("ca-init output unexpected: %s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gen", func(t *testing.T) {
|
||||
out, err := runCertArgs(t, []string{"cert", "gen", "--cn", "test-server", "--san", "localhost", "--san", "127.0.0.1"})
|
||||
if err != nil {
|
||||
t.Fatalf("gen: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "Server cert generated") {
|
||||
t.Errorf("gen output unexpected: %s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("show", func(t *testing.T) {
|
||||
out, err := runCertArgs(t, []string{"cert", "show"})
|
||||
if err != nil {
|
||||
t.Fatalf("show: %v\n%s", err, out)
|
||||
}
|
||||
if strings.Contains(out, "PRIVATE KEY") {
|
||||
t.Errorf("show leaked private key material (REQ-035):\n%s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fingerprint_ca", func(t *testing.T) {
|
||||
out, err := runCertArgs(t, []string{"cert", "fingerprint", "--which", "ca"})
|
||||
if err != nil {
|
||||
t.Fatalf("fingerprint ca: %v\n%s", err, out)
|
||||
}
|
||||
fp := strings.TrimSpace(out)
|
||||
if len(fp) != 64 || !isHex(fp) {
|
||||
t.Errorf("ca fingerprint = %q, want 64 hex chars", fp)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fingerprint_server", func(t *testing.T) {
|
||||
out, err := runCertArgs(t, []string{"cert", "fingerprint", "--which", "server"})
|
||||
if err != nil {
|
||||
t.Fatalf("fingerprint server: %v\n%s", err, out)
|
||||
}
|
||||
fp := strings.TrimSpace(out)
|
||||
if len(fp) != 64 || !isHex(fp) {
|
||||
t.Errorf("server fingerprint = %q, want 64 hex chars", fp)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("renew", func(t *testing.T) {
|
||||
out, err := runCertArgs(t, []string{"cert", "renew"})
|
||||
if err != nil {
|
||||
t.Fatalf("renew: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "rotated") {
|
||||
t.Errorf("renew output unexpected: %s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("file_modes", func(t *testing.T) {
|
||||
dir := os.Getenv("ORCA_HOME")
|
||||
checks := []struct {
|
||||
path string
|
||||
want os.FileMode
|
||||
}{
|
||||
{"ca.crt", 0o644},
|
||||
{"ca.key", 0o600},
|
||||
{"server.crt", 0o644},
|
||||
{"server.key", 0o600},
|
||||
}
|
||||
for _, c := range checks {
|
||||
info, err := os.Stat(filepath.Join(dir, c.path))
|
||||
if err != nil {
|
||||
t.Fatalf("stat %s: %v", c.path, err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != c.want {
|
||||
t.Errorf("mode %s = %04o, want %04o (REQ-033)", c.path, got, c.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func isHex(s string) bool {
|
||||
for _, r := range s {
|
||||
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCertCommandRegistered(t *testing.T) {
|
||||
found := false
|
||||
for _, cmd := range rootCmd.Commands() {
|
||||
if strings.Fields(cmd.Use)[0] == "cert" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("cert command not registered on rootCmd")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertSubcommands(t *testing.T) {
|
||||
expected := []string{"ca-init", "gen", "show", "renew", "fingerprint"}
|
||||
registered := make(map[string]bool)
|
||||
for _, cmd := range rootCmd.Commands() {
|
||||
if strings.Fields(cmd.Use)[0] != "cert" {
|
||||
continue
|
||||
}
|
||||
for _, sub := range cmd.Commands() {
|
||||
registered[strings.Fields(sub.Use)[0]] = true
|
||||
}
|
||||
}
|
||||
for _, name := range expected {
|
||||
if !registered[name] {
|
||||
t.Errorf("expected cert subcommand %q not registered", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-4
@@ -20,6 +20,7 @@ import (
|
||||
|
||||
var (
|
||||
daemonAddr string
|
||||
pprofAddr string
|
||||
)
|
||||
|
||||
var daemonCmd = &cobra.Command{
|
||||
@@ -34,11 +35,16 @@ var daemonCmd = &cobra.Command{
|
||||
defer closer()
|
||||
|
||||
log := newLogger()
|
||||
addr := daemonAddr
|
||||
if cfg := configFromCtx(cmd.Context()); cfg != nil && cfg.ListenAddr != "" && !cmd.Flags().Changed("addr") {
|
||||
addr = cfg.ListenAddr
|
||||
}
|
||||
srv := daemon.NewServer(daemon.Options{
|
||||
DB: db,
|
||||
Log: log,
|
||||
Addr: daemonAddr,
|
||||
Actor: "daemon",
|
||||
DB: db,
|
||||
Log: log,
|
||||
Addr: addr,
|
||||
Actor: "daemon",
|
||||
PprofAddr: pprofAddr,
|
||||
})
|
||||
|
||||
// Wire the orca.v1.Dispatch service (v0.2 P02). The executor
|
||||
@@ -67,6 +73,9 @@ var daemonCmd = &cobra.Command{
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " /v1/tasks - list tasks")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " /orca.v1.Dispatch/Submit - cross-node job submit (P02)")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " /orca.v1.Dispatch/Status - cross-node job status (P02)")
|
||||
if pprofAddr != "" {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), " /debug/pprof/ (pprof) - %s\n", pprofAddr)
|
||||
}
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " press Ctrl+C to stop")
|
||||
|
||||
ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM)
|
||||
@@ -86,6 +95,7 @@ var daemonCmd = &cobra.Command{
|
||||
|
||||
func init() {
|
||||
daemonCmd.Flags().StringVar(&daemonAddr, "addr", ":8080", "listen address")
|
||||
daemonCmd.Flags().StringVar(&pprofAddr, "pprof", "", "enable pprof endpoint on <addr> (e.g. :6060); unauthenticated, operator-only")
|
||||
rootCmd.AddCommand(daemonCmd)
|
||||
_ = slog.Default // keep import if unused above
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDaemonPprofFlag(t *testing.T) {
|
||||
f := daemonCmd.Flags().Lookup("pprof")
|
||||
if f == nil {
|
||||
t.Fatal("--pprof flag not registered on daemonCmd")
|
||||
}
|
||||
if f.DefValue != "" {
|
||||
t.Errorf("--pprof default = %q, want empty", f.DefValue)
|
||||
}
|
||||
}
|
||||
@@ -80,8 +80,8 @@ func TestInit_FullBootstrap(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("migration version: %v", err)
|
||||
}
|
||||
if version != "0006_node_kind_os.sql" {
|
||||
t.Errorf("migration version = %q, want 0006_node_kind_os.sql", version)
|
||||
if version != "0007_certs_serial_unique.sql" {
|
||||
t.Errorf("migration version = %q, want 0007_certs_serial_unique.sql", version)
|
||||
}
|
||||
|
||||
// Verify localhost node registered with kind=localhost.
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/config"
|
||||
)
|
||||
|
||||
type configCtxKey struct{}
|
||||
|
||||
var (
|
||||
version = "0.1.0-dev"
|
||||
gitCommit = "unknown"
|
||||
@@ -33,6 +38,13 @@ over feature richness.`,
|
||||
return fmt.Errorf("set ORCA_HOME for --system: %w", err)
|
||||
}
|
||||
}
|
||||
if configPath != "" {
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config %s: %w", configPath, err)
|
||||
}
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), configCtxKey{}, cfg))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -40,11 +52,20 @@ over feature richness.`,
|
||||
var (
|
||||
jsonOutput bool
|
||||
systemNamespace bool
|
||||
configPath string
|
||||
)
|
||||
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "output in JSON format")
|
||||
rootCmd.PersistentFlags().BoolVar(&systemNamespace, "system", false, "use system-level namespace root (/root/.orca) instead of user-level (~/.orca)")
|
||||
rootCmd.PersistentFlags().StringVar(&configPath, "config", "", "path to config.hcl (overrides ~/.orca/config.hcl)")
|
||||
}
|
||||
|
||||
func configFromCtx(ctx context.Context) *config.Config {
|
||||
if v, ok := ctx.Value(configCtxKey{}).(*config.Config); ok {
|
||||
return v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Execute() error {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/config"
|
||||
)
|
||||
|
||||
func TestVersionCommandExists(t *testing.T) {
|
||||
@@ -65,3 +68,47 @@ func TestRootHelpMentionsKeyPillars(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigFlagRegistered(t *testing.T) {
|
||||
f := rootCmd.PersistentFlags().Lookup("config")
|
||||
if f == nil {
|
||||
t.Fatal("--config persistent flag not registered")
|
||||
}
|
||||
if f.DefValue != "" {
|
||||
t.Errorf("--config default = %q, want empty", f.DefValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigFlagLoadsFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := dir + "/config.hcl"
|
||||
cfgContent := `db_path = "` + dir + `/test.db"
|
||||
listen_addr = "127.0.0.1:9999"
|
||||
ca_path = "` + dir + `/ca.crt"
|
||||
server_cert_path = "` + dir + `/server.crt"
|
||||
server_key_path = "` + dir + `/server.key"
|
||||
|
||||
node_capacity {
|
||||
cpu = 4
|
||||
memory_mb = 8192
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(cfgPath, []byte(cfgContent), 0o644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
old := configPath
|
||||
configPath = cfgPath
|
||||
defer func() { configPath = old }()
|
||||
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load config: %v", err)
|
||||
}
|
||||
if cfg.ListenAddr != "127.0.0.1:9999" {
|
||||
t.Errorf("listen_addr = %q, want 127.0.0.1:9999", cfg.ListenAddr)
|
||||
}
|
||||
if cfg.NodeCapacity == nil || cfg.NodeCapacity.CPU != 4 {
|
||||
t.Errorf("node_capacity.cpu not parsed, got %+v", cfg.NodeCapacity)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/hashicorp/hcl/v2/hclsimple"
|
||||
)
|
||||
|
||||
type CapacityConfig struct {
|
||||
CPU int `hcl:"cpu,optional"`
|
||||
MemoryMB int `hcl:"memory_mb,optional"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
type Flags struct {
|
||||
DBPath *string
|
||||
ListenAddr *string
|
||||
CAPath *string
|
||||
ServerCertPath *string
|
||||
ServerKeyPath *string
|
||||
CPU *int
|
||||
MemoryMB *int
|
||||
}
|
||||
|
||||
type Environ map[string]string
|
||||
|
||||
func Load(paths ...string) (*Config, error) {
|
||||
for _, p := range paths {
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config %s: %w", p, err)
|
||||
}
|
||||
var cfg Config
|
||||
if err := hclsimple.Decode(p, data, nil, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("decode config %s: %w", p, err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
return &Config{}, nil
|
||||
}
|
||||
|
||||
func (c *Config) MergeOverrides(flags Flags, env Environ) *Config {
|
||||
out := &Config{
|
||||
DBPath: c.DBPath,
|
||||
ListenAddr: c.ListenAddr,
|
||||
CAPath: c.CAPath,
|
||||
ServerCertPath: c.ServerCertPath,
|
||||
ServerKeyPath: c.ServerKeyPath,
|
||||
NodeCapacity: c.NodeCapacity,
|
||||
}
|
||||
|
||||
applyStr := func(flag *string, envKey, fileVal string) string {
|
||||
if flag != nil {
|
||||
return *flag
|
||||
}
|
||||
if v, ok := env[envKey]; ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return fileVal
|
||||
}
|
||||
|
||||
out.DBPath = applyStr(flags.DBPath, "ORCA_DB", out.DBPath)
|
||||
out.ListenAddr = applyStr(flags.ListenAddr, "ORCA_LISTEN_ADDR", out.ListenAddr)
|
||||
out.CAPath = applyStr(flags.CAPath, "ORCA_CA_PATH", out.CAPath)
|
||||
out.ServerCertPath = applyStr(flags.ServerCertPath, "ORCA_SERVER_CERT_PATH", out.ServerCertPath)
|
||||
out.ServerKeyPath = applyStr(flags.ServerKeyPath, "ORCA_SERVER_KEY_PATH", out.ServerKeyPath)
|
||||
|
||||
if out.NodeCapacity == nil {
|
||||
out.NodeCapacity = &CapacityConfig{}
|
||||
} else {
|
||||
nc := *out.NodeCapacity
|
||||
out.NodeCapacity = &nc
|
||||
}
|
||||
|
||||
if flags.CPU != nil {
|
||||
out.NodeCapacity.CPU = *flags.CPU
|
||||
} else if v, ok := env["ORCA_NODE_CPU"]; ok && v != "" {
|
||||
if n, err := atoi(v); err == nil {
|
||||
out.NodeCapacity.CPU = n
|
||||
}
|
||||
}
|
||||
|
||||
if flags.MemoryMB != nil {
|
||||
out.NodeCapacity.MemoryMB = *flags.MemoryMB
|
||||
} else if v, ok := env["ORCA_NODE_MEMORY_MB"]; ok && v != "" {
|
||||
if n, err := atoi(v); err == nil {
|
||||
out.NodeCapacity.MemoryMB = n
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func atoi(s string) (int, error) {
|
||||
n := 0
|
||||
if s == "" {
|
||||
return 0, fmt.Errorf("empty")
|
||||
}
|
||||
neg := false
|
||||
i := 0
|
||||
if s[0] == '-' {
|
||||
neg = true
|
||||
i = 1
|
||||
}
|
||||
for ; i < len(s); i++ {
|
||||
if s[i] < '0' || s[i] > '9' {
|
||||
return 0, fmt.Errorf("bad")
|
||||
}
|
||||
n = n*10 + int(s[i]-'0')
|
||||
}
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const exampleHCL = `
|
||||
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"
|
||||
|
||||
node_capacity {
|
||||
cpu = 4
|
||||
memory_mb = 8192
|
||||
}
|
||||
`
|
||||
|
||||
func writeFile(t *testing.T, dir, name, content string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(p, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("write %s: %v", p, err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestLoad_Valid(t *testing.T) {
|
||||
p := writeFile(t, t.TempDir(), "config.hcl", exampleHCL)
|
||||
cfg, err := Load(p)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.DBPath != "/tmp/orca/test.db" {
|
||||
t.Errorf("DBPath=%q", cfg.DBPath)
|
||||
}
|
||||
if cfg.ListenAddr != "127.0.0.1:9999" {
|
||||
t.Errorf("ListenAddr=%q", cfg.ListenAddr)
|
||||
}
|
||||
if cfg.CAPath != "/tmp/orca/ca.crt" {
|
||||
t.Errorf("CAPath=%q", cfg.CAPath)
|
||||
}
|
||||
if cfg.ServerCertPath != "/tmp/orca/server.crt" {
|
||||
t.Errorf("ServerCertPath=%q", cfg.ServerCertPath)
|
||||
}
|
||||
if cfg.ServerKeyPath != "/tmp/orca/server.key" {
|
||||
t.Errorf("ServerKeyPath=%q", cfg.ServerKeyPath)
|
||||
}
|
||||
if cfg.NodeCapacity == nil {
|
||||
t.Fatal("NodeCapacity nil")
|
||||
}
|
||||
if cfg.NodeCapacity.CPU != 4 {
|
||||
t.Errorf("CPU=%d", cfg.NodeCapacity.CPU)
|
||||
}
|
||||
if cfg.NodeCapacity.MemoryMB != 8192 {
|
||||
t.Errorf("MemoryMB=%d", cfg.NodeCapacity.MemoryMB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_Missing(t *testing.T) {
|
||||
cfg, err := Load(filepath.Join(t.TempDir(), "nope.hcl"))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
t.Fatal("nil config")
|
||||
}
|
||||
if cfg.DBPath != "" || cfg.ListenAddr != "" || cfg.NodeCapacity != nil {
|
||||
t.Errorf("expected zero config, got %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_Malformed(t *testing.T) {
|
||||
p := writeFile(t, t.TempDir(), "bad.hcl", "db_path = ")
|
||||
cfg, err := Load(p)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_FirstExisting(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
existing := writeFile(t, dir, "real.hcl", exampleHCL)
|
||||
missing := filepath.Join(dir, "missing.hcl")
|
||||
cfg, err := Load(missing, existing)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.DBPath != "/tmp/orca/test.db" {
|
||||
t.Errorf("DBPath=%q", cfg.DBPath)
|
||||
}
|
||||
}
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
func intPtr(i int) *int { return &i }
|
||||
|
||||
func TestMergeOverrides_FlagWins(t *testing.T) {
|
||||
cfg := &Config{
|
||||
DBPath: "/file.db",
|
||||
ListenAddr: "127.0.0.1:9000",
|
||||
NodeCapacity: &CapacityConfig{
|
||||
CPU: 4,
|
||||
MemoryMB: 8192,
|
||||
},
|
||||
}
|
||||
flags := Flags{
|
||||
DBPath: strPtr("/flag.db"),
|
||||
ListenAddr: strPtr("0.0.0.0:1234"),
|
||||
}
|
||||
env := Environ{"ORCA_DB": "/env.db"}
|
||||
out := cfg.MergeOverrides(flags, env)
|
||||
if out.DBPath != "/flag.db" {
|
||||
t.Errorf("DBPath=%q want /flag.db", out.DBPath)
|
||||
}
|
||||
if out.ListenAddr != "0.0.0.0:1234" {
|
||||
t.Errorf("ListenAddr=%q want 0.0.0.0:1234", out.ListenAddr)
|
||||
}
|
||||
if cfg.DBPath != "/file.db" {
|
||||
t.Errorf("receiver mutated: %q", cfg.DBPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeOverrides_EnvWinsOverFile(t *testing.T) {
|
||||
cfg := &Config{DBPath: "/file.db", ListenAddr: "127.0.0.1:9000"}
|
||||
env := Environ{"ORCA_DB": "/env.db"}
|
||||
out := cfg.MergeOverrides(Flags{}, env)
|
||||
if out.DBPath != "/env.db" {
|
||||
t.Errorf("DBPath=%q want /env.db", out.DBPath)
|
||||
}
|
||||
if out.ListenAddr != "127.0.0.1:9000" {
|
||||
t.Errorf("ListenAddr=%q want 127.0.0.1:9000", out.ListenAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeOverrides_FileWinsOverDefault(t *testing.T) {
|
||||
cfg := &Config{DBPath: "/file.db", ListenAddr: "127.0.0.1:9000"}
|
||||
out := cfg.MergeOverrides(Flags{}, Environ{})
|
||||
if out.DBPath != "/file.db" {
|
||||
t.Errorf("DBPath=%q want /file.db", out.DBPath)
|
||||
}
|
||||
if out.ListenAddr != "127.0.0.1:9000" {
|
||||
t.Errorf("ListenAddr=%q want 127.0.0.1:9000", out.ListenAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeOverrides_EmptyFlagDoesNotOverride(t *testing.T) {
|
||||
cfg := &Config{DBPath: "/file.db"}
|
||||
env := Environ{"ORCA_DB": "/env.db"}
|
||||
out := cfg.MergeOverrides(Flags{}, env)
|
||||
if out.DBPath != "/env.db" {
|
||||
t.Errorf("DBPath=%q want /env.db", out.DBPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeOverrides_EmptyEnvDoesNotOverride(t *testing.T) {
|
||||
cfg := &Config{DBPath: "/file.db"}
|
||||
env := Environ{"ORCA_DB": ""}
|
||||
out := cfg.MergeOverrides(Flags{}, env)
|
||||
if out.DBPath != "/file.db" {
|
||||
t.Errorf("DBPath=%q want /file.db", out.DBPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeOverrides_NodeCapacity(t *testing.T) {
|
||||
cfg := &Config{
|
||||
NodeCapacity: &CapacityConfig{CPU: 4, MemoryMB: 8192},
|
||||
}
|
||||
out := cfg.MergeOverrides(Flags{}, Environ{})
|
||||
if out.NodeCapacity == nil {
|
||||
t.Fatal("NodeCapacity nil")
|
||||
}
|
||||
if out.NodeCapacity.CPU != 4 {
|
||||
t.Errorf("CPU=%d want 4", out.NodeCapacity.CPU)
|
||||
}
|
||||
if out.NodeCapacity.MemoryMB != 8192 {
|
||||
t.Errorf("MemoryMB=%d want 8192", out.NodeCapacity.MemoryMB)
|
||||
}
|
||||
if cfg.NodeCapacity == out.NodeCapacity {
|
||||
t.Error("NodeCapacity not cloned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeOverrides_NodeCapacityFlagAndEnv(t *testing.T) {
|
||||
cfg := &Config{NodeCapacity: &CapacityConfig{CPU: 4, MemoryMB: 8192}}
|
||||
flags := Flags{CPU: intPtr(8)}
|
||||
env := Environ{"ORCA_NODE_MEMORY_MB": "16384"}
|
||||
out := cfg.MergeOverrides(flags, env)
|
||||
if out.NodeCapacity.CPU != 8 {
|
||||
t.Errorf("CPU=%d want 8", out.NodeCapacity.CPU)
|
||||
}
|
||||
if out.NodeCapacity.MemoryMB != 16384 {
|
||||
t.Errorf("MemoryMB=%d want 16384", out.NodeCapacity.MemoryMB)
|
||||
}
|
||||
}
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
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"
|
||||
|
||||
node_capacity {
|
||||
cpu = 4
|
||||
memory_mb = 8192
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
"time"
|
||||
)
|
||||
|
||||
func StartPprof(addr string, log *slog.Logger) (*http.Server, error) {
|
||||
if addr == "" {
|
||||
return nil, nil
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/debug/pprof/", pprof.Index)
|
||||
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
||||
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
|
||||
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
||||
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
||||
mux.Handle("/debug/pprof/heap", pprof.Handler("heap"))
|
||||
mux.Handle("/debug/pprof/goroutine", pprof.Handler("goroutine"))
|
||||
mux.Handle("/debug/pprof/threadcreate", pprof.Handler("threadcreate"))
|
||||
mux.Handle("/debug/pprof/block", pprof.Handler("block"))
|
||||
mux.Handle("/debug/pprof/mutex", pprof.Handler("mutex"))
|
||||
|
||||
server := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
log.Warn("pprof endpoint exposed",
|
||||
slog.String("addr", addr),
|
||||
slog.String("warning", "unauthenticated, operator-only — do not expose publicly"))
|
||||
|
||||
go func() {
|
||||
err := server.ListenAndServe()
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Error("pprof server stopped", slog.String("addr", addr), slog.Any("err", err))
|
||||
}
|
||||
}()
|
||||
|
||||
return server, nil
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
func TestStartPprof_Disabled(t *testing.T) {
|
||||
srv, err := StartPprof("", slog.Default())
|
||||
if err != nil {
|
||||
t.Fatalf("StartPprof(\"\", _) returned err: %v", err)
|
||||
}
|
||||
if srv != nil {
|
||||
t.Fatalf("StartPprof(\"\", _) returned non-nil server: %v", srv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartPprof_Enabled(t *testing.T) {
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
addr := ln.Addr().String()
|
||||
_ = ln.Close()
|
||||
|
||||
srv, err := StartPprof(addr, log)
|
||||
if err != nil {
|
||||
t.Fatalf("StartPprof returned err: %v", err)
|
||||
}
|
||||
if srv == nil {
|
||||
t.Fatal("StartPprof returned nil server for non-empty addr")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(ctx)
|
||||
})
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
var base string
|
||||
for time.Now().Before(deadline) {
|
||||
conn, derr := net.DialTimeout("tcp", addr, 50*time.Millisecond)
|
||||
if derr == nil {
|
||||
_ = conn.Close()
|
||||
base = "http://" + addr
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if base == "" {
|
||||
t.Fatal("pprof server did not start listening")
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 500 * time.Millisecond}
|
||||
for _, path := range []string{"/debug/pprof/", "/debug/pprof/cmdline", "/debug/pprof/heap"} {
|
||||
resp, gerr := client.Get(base + path)
|
||||
if gerr != nil {
|
||||
t.Errorf("GET %s: %v", path, gerr)
|
||||
continue
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("GET %s: expected 200, got %d", path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartPprof_Shutdown(t *testing.T) {
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
addr := ln.Addr().String()
|
||||
_ = ln.Close()
|
||||
|
||||
srv, err := StartPprof(addr, log)
|
||||
if err != nil {
|
||||
t.Fatalf("StartPprof returned err: %v", err)
|
||||
}
|
||||
if srv == nil {
|
||||
t.Fatal("StartPprof returned nil server")
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
conn, derr := net.DialTimeout("tcp", addr, 50*time.Millisecond)
|
||||
if derr == nil {
|
||||
_ = conn.Close()
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
t.Fatalf("Shutdown: %v", err)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 300 * time.Millisecond}
|
||||
_, gerr := client.Get("http://" + addr + "/debug/pprof/")
|
||||
if gerr == nil {
|
||||
t.Error("expected GET to fail after Shutdown, but it succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartPprof_MuxIsolated(t *testing.T) {
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
addr := ln.Addr().String()
|
||||
_ = ln.Close()
|
||||
|
||||
srv, err := StartPprof(addr, log)
|
||||
if err != nil {
|
||||
t.Fatalf("StartPprof returned err: %v", err)
|
||||
}
|
||||
if srv == nil {
|
||||
t.Fatal("StartPprof returned nil server")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(ctx)
|
||||
})
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
conn, derr := net.DialTimeout("tcp", addr, 50*time.Millisecond)
|
||||
if derr == nil {
|
||||
_ = conn.Close()
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 500 * time.Millisecond}
|
||||
resp, err := client.Get("http://" + addr + "/healthz")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /healthz: %v", err)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != 404 {
|
||||
t.Errorf("expected /healthz to 404 on pprof-only mux, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_WithPprof(t *testing.T) {
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "pprof.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen main: %v", err)
|
||||
}
|
||||
mainAddr := ln.Addr().String()
|
||||
|
||||
pln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen pprof: %v", err)
|
||||
}
|
||||
pprofAddr := pln.Addr().String()
|
||||
_ = pln.Close()
|
||||
|
||||
s := NewServer(Options{
|
||||
DB: db,
|
||||
Log: log,
|
||||
Addr: mainAddr,
|
||||
PprofAddr: pprofAddr,
|
||||
})
|
||||
s.MarkReady()
|
||||
|
||||
if s.pprofServer == nil {
|
||||
t.Fatal("expected pprofServer to be non-nil after NewServer with PprofAddr")
|
||||
}
|
||||
|
||||
errCh := make(chan error, 2)
|
||||
go func() {
|
||||
err := s.httpServer.Serve(ln)
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
conn, derr := net.DialTimeout("tcp", pprofAddr, 50*time.Millisecond)
|
||||
if derr == nil {
|
||||
_ = conn.Close()
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 500 * time.Millisecond}
|
||||
resp, err := client.Get("http://" + mainAddr + "/healthz")
|
||||
if err != nil {
|
||||
t.Fatalf("GET main /healthz: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("main /healthz: expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
presp, err := client.Get("http://" + pprofAddr + "/debug/pprof/")
|
||||
if err != nil {
|
||||
t.Fatalf("GET pprof /debug/pprof/: %v", err)
|
||||
}
|
||||
if presp.StatusCode != 200 {
|
||||
t.Errorf("pprof /debug/pprof/: expected 200, got %d", presp.StatusCode)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, presp.Body)
|
||||
_ = presp.Body.Close()
|
||||
|
||||
presp, err = client.Get("http://" + pprofAddr + "/healthz")
|
||||
if err != nil {
|
||||
t.Fatalf("GET pprof /healthz: %v", err)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, presp.Body)
|
||||
_ = presp.Body.Close()
|
||||
if presp.StatusCode != 404 {
|
||||
t.Errorf("expected /healthz 404 on pprof mux, got %d", presp.StatusCode)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := s.Shutdown(ctx); err != nil {
|
||||
t.Errorf("Shutdown: %v", err)
|
||||
}
|
||||
|
||||
client = &http.Client{Timeout: 300 * time.Millisecond}
|
||||
_, gerr := client.Get("http://" + pprofAddr + "/debug/pprof/")
|
||||
if gerr == nil {
|
||||
t.Error("expected pprof GET to fail after Shutdown")
|
||||
}
|
||||
_, merr := client.Get("http://" + mainAddr + "/healthz")
|
||||
if merr == nil {
|
||||
t.Error("expected main GET to fail after Shutdown")
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,8 @@ type Server struct {
|
||||
addr string
|
||||
ready atomic.Bool
|
||||
|
||||
httpServer *http.Server
|
||||
httpServer *http.Server
|
||||
pprofServer *http.Server
|
||||
|
||||
// mtls is non-nil after StartMTLS has been called; nil otherwise.
|
||||
// Plaintext HTTP and mTLS are mutually exclusive — a Server is
|
||||
@@ -49,6 +50,12 @@ type Options struct {
|
||||
Log *slog.Logger
|
||||
Addr string
|
||||
Actor string // used for audit logging from API requests
|
||||
|
||||
// PprofAddr enables the pprof endpoint on a separate listener
|
||||
// when non-empty (e.g. "127.0.0.1:6060"). Default "" disables it.
|
||||
// The pprof listener is unauthenticated and operator-only; never
|
||||
// expose it publicly (AD-024).
|
||||
PprofAddr string
|
||||
}
|
||||
|
||||
// NewServer constructs a Server with the default mux and route table.
|
||||
@@ -75,6 +82,14 @@ func NewServer(opts Options) *Server {
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
if opts.PprofAddr != "" {
|
||||
ps, perr := StartPprof(opts.PprofAddr, opts.Log)
|
||||
if perr != nil {
|
||||
s.log.Error("pprof start failed", slog.String("component", "daemon"), slog.Any("err", perr))
|
||||
} else {
|
||||
s.pprofServer = ps
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -142,6 +157,11 @@ func (s *Server) Start() error {
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
s.MarkNotReady()
|
||||
s.log.Info("daemon shutting down", slog.String("component", "daemon"))
|
||||
if s.pprofServer != nil {
|
||||
if perr := s.pprofServer.Shutdown(ctx); perr != nil {
|
||||
s.log.Error("pprof shutdown failed", slog.String("component", "daemon"), slog.Any("err", perr))
|
||||
}
|
||||
}
|
||||
return s.httpServer.Shutdown(ctx)
|
||||
}
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ func TestDBCheck_IntegrityOK(t *testing.T) {
|
||||
if r != ResultPass {
|
||||
t.Errorf("DB check: got %s, want PASS — %s", r, msg)
|
||||
}
|
||||
if !strings.Contains(msg, "0006") {
|
||||
if !strings.Contains(msg, "migrations up to") {
|
||||
t.Errorf("DB check message should contain migration version, got: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
type mockExecutor struct {
|
||||
submitFn func(ctx context.Context, spec []byte) (string, error)
|
||||
statusFn func(ctx context.Context, jobID string) (string, error)
|
||||
submitted bool
|
||||
}
|
||||
|
||||
func (m *mockExecutor) Submit(ctx context.Context, spec []byte) (string, error) {
|
||||
m.submitted = true
|
||||
if m.submitFn != nil {
|
||||
return m.submitFn(ctx, spec)
|
||||
}
|
||||
return "mock-job-id", nil
|
||||
}
|
||||
|
||||
func (m *mockExecutor) Status(ctx context.Context, jobID string) (string, error) {
|
||||
if m.statusFn != nil {
|
||||
return m.statusFn(ctx, jobID)
|
||||
}
|
||||
return "complete", nil
|
||||
}
|
||||
|
||||
func newTestDispatcher(t *testing.T, exec LocalExecutor) (*Dispatcher, *store.CapacityRepo, func()) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "test.db")
|
||||
db, err := store.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
capRepo := store.NewCapacityRepo(db)
|
||||
peers := NewPeerRegistry()
|
||||
d := NewDispatcher(nil, capRepo, peers, exec)
|
||||
return d, capRepo, func() { _ = db.Close() }
|
||||
}
|
||||
|
||||
func TestDispatcher_Submit_EmptySpec(t *testing.T) {
|
||||
d, _, cleanup := newTestDispatcher(t, &mockExecutor{})
|
||||
defer cleanup()
|
||||
_, _, err := d.Submit(context.Background(), "", nil, "")
|
||||
if err == nil {
|
||||
t.Fatal("Submit: expected error for empty spec, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcher_Submit_IdempotencyHit(t *testing.T) {
|
||||
exec := &mockExecutor{}
|
||||
d, _, cleanup := newTestDispatcher(t, exec)
|
||||
defer cleanup()
|
||||
|
||||
d.Dedupe().Put("key-1", "cached-job-id")
|
||||
spec := []byte(`{"cpu_millicores":100,"memory_mib":64,"disk_mib":64}`)
|
||||
jobID, nodeID, err := d.Submit(context.Background(), "", spec, "key-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Submit: %v", err)
|
||||
}
|
||||
if jobID != "cached-job-id" {
|
||||
t.Errorf("jobID: got %q, want cached-job-id", jobID)
|
||||
}
|
||||
if nodeID != "self" {
|
||||
t.Errorf("nodeID: got %q, want self", nodeID)
|
||||
}
|
||||
if exec.submitted {
|
||||
t.Error("executor was called on idempotency hit; should have been short-circuited")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcher_Submit_LocalCapacity(t *testing.T) {
|
||||
exec := &mockExecutor{
|
||||
submitFn: func(ctx context.Context, spec []byte) (string, error) {
|
||||
return "local-job-id", nil
|
||||
},
|
||||
}
|
||||
d, capRepo, cleanup := newTestDispatcher(t, exec)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
if err := capRepo.Upsert(ctx, &store.NodeCapacity{
|
||||
NodeID: "self",
|
||||
CPUMillicores: 4000,
|
||||
MemoryMiB: 4096,
|
||||
DiskMiB: 4096,
|
||||
}); err != nil {
|
||||
t.Fatalf("Upsert capacity: %v", err)
|
||||
}
|
||||
spec := []byte(`{"cpu_millicores":100,"memory_mib":64,"disk_mib":64}`)
|
||||
jobID, nodeID, err := d.Submit(ctx, "", spec, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Submit: %v", err)
|
||||
}
|
||||
if jobID != "local-job-id" {
|
||||
t.Errorf("jobID: got %q, want local-job-id", jobID)
|
||||
}
|
||||
if nodeID != "self" {
|
||||
t.Errorf("nodeID: got %q, want self", nodeID)
|
||||
}
|
||||
if !exec.submitted {
|
||||
t.Error("executor was not called for local-capacity path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcher_Submit_ExplicitTarget(t *testing.T) {
|
||||
exec := &mockExecutor{}
|
||||
d, _, cleanup := newTestDispatcher(t, exec)
|
||||
defer cleanup()
|
||||
spec := []byte(`{"cpu_millicores":100,"memory_mib":64,"disk_mib":64}`)
|
||||
_, _, err := d.Submit(context.Background(), "nodeA", spec, "")
|
||||
if err == nil {
|
||||
t.Fatal("Submit with explicit target nodeA (no peer): expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcher_Submit_NoPeers(t *testing.T) {
|
||||
exec := &mockExecutor{}
|
||||
d, capRepo, cleanup := newTestDispatcher(t, exec)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
if err := capRepo.Upsert(ctx, &store.NodeCapacity{
|
||||
NodeID: "self",
|
||||
CPUMillicores: 0,
|
||||
MemoryMiB: 0,
|
||||
DiskMiB: 0,
|
||||
}); err != nil {
|
||||
t.Fatalf("Upsert: %v", err)
|
||||
}
|
||||
spec := []byte(`{"cpu_millicores":1000,"memory_mib":1024,"disk_mib":1024}`)
|
||||
_, _, err := d.Submit(ctx, "", spec, "")
|
||||
if err == nil {
|
||||
t.Fatal("Submit: expected error when no peers and no local capacity, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcher_LocalSubmit(t *testing.T) {
|
||||
exec := &mockExecutor{
|
||||
submitFn: func(ctx context.Context, spec []byte) (string, error) {
|
||||
return "ls-job", nil
|
||||
},
|
||||
}
|
||||
d, _, cleanup := newTestDispatcher(t, exec)
|
||||
defer cleanup()
|
||||
jobID, err := d.LocalSubmit(context.Background(), []byte(`{"command":"/bin/true"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("LocalSubmit: %v", err)
|
||||
}
|
||||
if jobID != "ls-job" {
|
||||
t.Errorf("LocalSubmit: got %q, want ls-job", jobID)
|
||||
}
|
||||
if !exec.submitted {
|
||||
t.Error("LocalSubmit: executor.Submit not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcher_LocalStatus(t *testing.T) {
|
||||
exec := &mockExecutor{
|
||||
statusFn: func(ctx context.Context, jobID string) (string, error) {
|
||||
if jobID == "known" {
|
||||
return "running", nil
|
||||
}
|
||||
return "", errors.New("not found")
|
||||
},
|
||||
}
|
||||
d, _, cleanup := newTestDispatcher(t, exec)
|
||||
defer cleanup()
|
||||
st, err := d.LocalStatus(context.Background(), "known")
|
||||
if err != nil {
|
||||
t.Fatalf("LocalStatus: %v", err)
|
||||
}
|
||||
if st != "running" {
|
||||
t.Errorf("LocalStatus: got %q, want running", st)
|
||||
}
|
||||
if _, err := d.LocalStatus(context.Background(), "missing"); err == nil {
|
||||
t.Error("LocalStatus: expected error for missing job, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcher_LocalSubmit_NilExecutor(t *testing.T) {
|
||||
d := NewDispatcher(nil, nil, NewPeerRegistry(), nil)
|
||||
if _, err := d.LocalSubmit(context.Background(), []byte(`{}`)); err == nil {
|
||||
t.Error("LocalSubmit with nil executor: expected error, got nil")
|
||||
}
|
||||
if _, err := d.LocalStatus(context.Background(), "x"); err == nil {
|
||||
t.Error("LocalStatus with nil executor: expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInlineSpec(t *testing.T) {
|
||||
spec, err := parseInlineSpec([]byte(`{"cpu_millicores":500,"memory_mib":256,"disk_mib":128}`))
|
||||
if err != nil {
|
||||
t.Fatalf("parseInlineSpec: %v", err)
|
||||
}
|
||||
if spec.CPUMillicores != 500 || spec.MemoryMiB != 256 || spec.DiskMiB != 128 {
|
||||
t.Errorf("parseInlineSpec: got %+v, want cpu=500 mem=256 disk=128", spec)
|
||||
}
|
||||
if _, err := parseInlineSpec([]byte(`{bad json`)); err == nil {
|
||||
t.Fatal("parseInlineSpec: expected error for malformed JSON, got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
func newTestExecutor(t *testing.T) (*Executor, func()) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "test.db")
|
||||
db, err := store.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
ex := NewExecutor(store.NewJobRepo(db), store.NewTaskRepo(db), nil)
|
||||
return ex, func() { _ = db.Close() }
|
||||
}
|
||||
|
||||
func TestExecutor_Submit_Success(t *testing.T) {
|
||||
ex, cleanup := newTestExecutor(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
spec := []byte(`{"command":"/bin/echo","args":["hello"]}`)
|
||||
jobID, err := ex.Submit(ctx, spec)
|
||||
if err != nil {
|
||||
t.Fatalf("Submit: %v", err)
|
||||
}
|
||||
if jobID == "" {
|
||||
t.Fatal("Submit: empty jobID")
|
||||
}
|
||||
status, err := ex.Status(ctx, jobID)
|
||||
if err != nil {
|
||||
t.Fatalf("Status: %v", err)
|
||||
}
|
||||
if status != string(model.JobStatusComplete) {
|
||||
t.Errorf("Status: got %q, want %q", status, model.JobStatusComplete)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutor_Submit_MissingCommand(t *testing.T) {
|
||||
ex, cleanup := newTestExecutor(t)
|
||||
defer cleanup()
|
||||
_, err := ex.Submit(context.Background(), []byte(`{"name":"x"}`))
|
||||
if err == nil {
|
||||
t.Fatal("Submit: expected error for missing command, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutor_Submit_MalformedJSON(t *testing.T) {
|
||||
ex, cleanup := newTestExecutor(t)
|
||||
defer cleanup()
|
||||
_, err := ex.Submit(context.Background(), []byte(`{bad json`))
|
||||
if err == nil {
|
||||
t.Fatal("Submit: expected error for malformed JSON, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutor_Submit_FailingCommand(t *testing.T) {
|
||||
ex, cleanup := newTestExecutor(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
jobID, err := ex.Submit(ctx, []byte(`{"command":"/bin/false"}`))
|
||||
if err == nil {
|
||||
t.Fatal("Submit failing command: expected error, got nil")
|
||||
}
|
||||
if jobID == "" {
|
||||
t.Fatal("Submit failing command: empty jobID")
|
||||
}
|
||||
status, err := ex.Status(ctx, jobID)
|
||||
if err != nil {
|
||||
t.Fatalf("Status: %v", err)
|
||||
}
|
||||
if status != string(model.JobStatusFailed) {
|
||||
t.Errorf("Status: got %q, want %q", status, model.JobStatusFailed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutor_Status_NotFound(t *testing.T) {
|
||||
ex, cleanup := newTestExecutor(t)
|
||||
defer cleanup()
|
||||
_, err := ex.Status(context.Background(), "nonexistent-job-id")
|
||||
if err == nil {
|
||||
t.Fatal("Status: expected error for missing job, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutor_Run_Success(t *testing.T) {
|
||||
ex, cleanup := newTestExecutor(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
job := &model.Job{
|
||||
ID: uuid.NewString(),
|
||||
Name: "run-success",
|
||||
Spec: "{}",
|
||||
Status: model.JobStatusPending,
|
||||
}
|
||||
specs := []TaskSpec{{Name: "echo", Command: "/bin/echo", Args: []string{"hi"}}}
|
||||
if err := ex.Run(ctx, job, specs); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
got, err := ex.Status(ctx, job.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Status: %v", err)
|
||||
}
|
||||
if got != string(model.JobStatusComplete) {
|
||||
t.Errorf("Status: got %q, want %q", got, model.JobStatusComplete)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutor_Run_ContextCancel(t *testing.T) {
|
||||
ex, cleanup := newTestExecutor(t)
|
||||
defer cleanup()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
job := &model.Job{
|
||||
ID: uuid.NewString(),
|
||||
Name: "run-cancel",
|
||||
Spec: "{}",
|
||||
Status: model.JobStatusPending,
|
||||
}
|
||||
specs := []TaskSpec{{Name: "sleep", Command: "/bin/sleep", Args: []string{"10"}}}
|
||||
|
||||
go func() {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
err := ex.Run(ctx, job, specs)
|
||||
if err == nil {
|
||||
t.Fatal("Run: expected error after context cancel, got nil")
|
||||
}
|
||||
status, sErr := ex.Status(context.Background(), job.ID)
|
||||
if sErr != nil {
|
||||
t.Fatalf("Status after cancel: %v", sErr)
|
||||
}
|
||||
if status == string(model.JobStatusComplete) {
|
||||
t.Errorf("Status: got %q, want not complete (task should have been killed)", status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
func TestPeerRegistry_AddAndGet(t *testing.T) {
|
||||
r := NewPeerRegistry()
|
||||
p := &Peer{
|
||||
NodeID: "node-1",
|
||||
Address: "localhost:8443",
|
||||
ServerName: "node-1.orca",
|
||||
CAPath: "/etc/orca/ca.pem",
|
||||
}
|
||||
if err := r.Add(p); err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
got := r.Get("node-1")
|
||||
if got == nil {
|
||||
t.Fatal("Get: returned nil after Add")
|
||||
}
|
||||
if got.NodeID != "node-1" || got.Address != "localhost:8443" ||
|
||||
got.ServerName != "node-1.orca" || got.CAPath != "/etc/orca/ca.pem" {
|
||||
t.Errorf("Get: fields mismatch: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerRegistry_AddNil(t *testing.T) {
|
||||
r := NewPeerRegistry()
|
||||
if err := r.Add(nil); err == nil {
|
||||
t.Fatal("Add(nil): expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerRegistry_AddMissingID(t *testing.T) {
|
||||
r := NewPeerRegistry()
|
||||
if err := r.Add(&Peer{Address: "a"}); err == nil {
|
||||
t.Fatal("Add(empty NodeID): expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerRegistry_Remove(t *testing.T) {
|
||||
r := NewPeerRegistry()
|
||||
p := &Peer{NodeID: "node-r", Address: "a"}
|
||||
if err := r.Add(p); err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
if !r.Remove("node-r") {
|
||||
t.Fatal("Remove: returned false for existing peer")
|
||||
}
|
||||
if got := r.Get("node-r"); got != nil {
|
||||
t.Errorf("Get after Remove: want nil, got %+v", got)
|
||||
}
|
||||
if r.Remove("node-r") {
|
||||
t.Error("Remove second time: want false, got true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerRegistry_All(t *testing.T) {
|
||||
r := NewPeerRegistry()
|
||||
for _, id := range []string{"node-c", "node-a", "node-b"} {
|
||||
if err := r.Add(&Peer{NodeID: id, Address: "a"}); err != nil {
|
||||
t.Fatalf("Add %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
got, err := r.All(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("All: %v", err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("All: got %d, want 3", len(got))
|
||||
}
|
||||
want := []string{"node-a", "node-b", "node-c"}
|
||||
for i, w := range want {
|
||||
if got[i].NodeID != w {
|
||||
t.Errorf("All[%d]: got %s, want %s (not sorted by NodeID)", i, got[i].NodeID, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerRegistry_All_Empty(t *testing.T) {
|
||||
r := NewPeerRegistry()
|
||||
got, err := r.All(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("All on empty: %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("All on empty: got %d, want 0", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerRegistry_Len(t *testing.T) {
|
||||
r := NewPeerRegistry()
|
||||
if r.Len() != 0 {
|
||||
t.Errorf("Len on empty: got %d, want 0", r.Len())
|
||||
}
|
||||
if err := r.Add(&Peer{NodeID: "n1", Address: "a"}); err != nil {
|
||||
t.Fatalf("Add n1: %v", err)
|
||||
}
|
||||
if err := r.Add(&Peer{NodeID: "n2", Address: "a"}); err != nil {
|
||||
t.Fatalf("Add n2: %v", err)
|
||||
}
|
||||
if r.Len() != 2 {
|
||||
t.Errorf("Len: got %d, want 2", r.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerRegistry_UpdateLastSeen(t *testing.T) {
|
||||
r := NewPeerRegistry()
|
||||
old := time.Now().Add(-1 * time.Hour).UTC()
|
||||
p := &Peer{
|
||||
NodeID: "node-u",
|
||||
Address: "a",
|
||||
LastSeen: old,
|
||||
Capacity: &store.NodeCapacity{NodeID: "node-u", CPUMillicores: 1000, MemoryMiB: 1024, DiskMiB: 1024},
|
||||
}
|
||||
if err := r.Add(p); err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
r.UpdateLastSeen("node-u")
|
||||
got := r.Get("node-u")
|
||||
if got == nil {
|
||||
t.Fatal("Get: nil after UpdateLastSeen")
|
||||
}
|
||||
if !got.LastSeen.After(old) {
|
||||
t.Errorf("UpdateLastSeen: LastSeen not bumped; old=%v now=%v", old, got.LastSeen)
|
||||
}
|
||||
if time.Since(got.LastSeen) > 5*time.Second {
|
||||
t.Errorf("UpdateLastSeen: LastSeen not recent: %v", got.LastSeen)
|
||||
}
|
||||
r.UpdateLastSeen("nonexistent")
|
||||
}
|
||||
@@ -1,15 +1,21 @@
|
||||
package proxmox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func TestSudoersContent(t *testing.T) {
|
||||
content := sudoersContent("orca")
|
||||
|
||||
// Must contain NOPASSWD and NOEXEC for pct and qm.
|
||||
if !strings.Contains(content, "NOPASSWD: NOEXEC: /usr/bin/pct") {
|
||||
t.Error("missing NOEXEC on pct (AD-020)")
|
||||
}
|
||||
@@ -17,7 +23,6 @@ func TestSudoersContent(t *testing.T) {
|
||||
t.Error("missing NOEXEC on qm (AD-020)")
|
||||
}
|
||||
|
||||
// apt-get and dpkg must have NOPASSWD but NOT NOEXEC (they need exec).
|
||||
if !strings.Contains(content, "NOPASSWD: /usr/bin/apt-get") {
|
||||
t.Error("missing NOPASSWD on apt-get")
|
||||
}
|
||||
@@ -31,20 +36,16 @@ func TestSudoersContent(t *testing.T) {
|
||||
t.Error("dpkg must NOT have NOEXEC (breaks maintainer scripts)")
|
||||
}
|
||||
|
||||
// pvesh must be EXCLUDED from the sudoers command lines (AD-020).
|
||||
// Comments may mention pvesh for documentation, but no command line
|
||||
// should grant sudo access to the pvesh binary.
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "#") || trimmed == "" {
|
||||
continue // skip comments and blank lines
|
||||
continue
|
||||
}
|
||||
if strings.Contains(trimmed, "pvesh") {
|
||||
t.Errorf("pvesh must be EXCLUDED from sudoers command lines (AD-020): %s", trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
// Must use the orca user.
|
||||
if !strings.HasPrefix(content, "# /etc/sudoers.d/orca") {
|
||||
t.Error("missing managed-by-orca header")
|
||||
}
|
||||
@@ -61,7 +62,6 @@ func TestSudoersContent_CustomUser(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestOrcaOperatorPrivileges(t *testing.T) {
|
||||
// D-033: VM.Audit, Datastore.AllocateSpace, SDN.Use (space-separated).
|
||||
privs := strings.Fields(OrcaOperatorPrivileges)
|
||||
expected := map[string]bool{
|
||||
"VM.Audit": true,
|
||||
@@ -81,13 +81,11 @@ func TestOrcaOperatorPrivileges(t *testing.T) {
|
||||
func TestBootstrapProxmox_Validation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Missing host.
|
||||
_, err := BootstrapProxmox(ctx, Options{Password: "pw"})
|
||||
if err == nil || !strings.Contains(err.Error(), "host is required") {
|
||||
t.Errorf("expected host-required error, got %v", err)
|
||||
}
|
||||
|
||||
// Missing password.
|
||||
_, err = BootstrapProxmox(ctx, Options{Host: "10.0.0.1"})
|
||||
if err == nil || !strings.Contains(err.Error(), "password is required") {
|
||||
t.Errorf("expected password-required error, got %v", err)
|
||||
@@ -95,12 +93,6 @@ func TestBootstrapProxmox_Validation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDefaultOptions(t *testing.T) {
|
||||
// Verify the defaults are applied when zero-value options are passed
|
||||
// (we can't test the full flow without a real SSH server, but we can
|
||||
// test that the defaults are set by checking the validation path).
|
||||
opts := Options{Host: "10.0.0.1", Password: "pw"}
|
||||
// These would be set inside BootstrapProxmox; we test the constants
|
||||
// are the expected defaults.
|
||||
if DefaultProxmoxUser != "orca" {
|
||||
t.Errorf("DefaultProxmoxUser = %q, want orca", DefaultProxmoxUser)
|
||||
}
|
||||
@@ -110,5 +102,231 @@ func TestDefaultOptions(t *testing.T) {
|
||||
if DefaultSSHPort != 22 {
|
||||
t.Errorf("DefaultSSHPort = %d, want 22", DefaultSSHPort)
|
||||
}
|
||||
_ = opts
|
||||
}
|
||||
|
||||
type mockSSHDialer struct {
|
||||
client *ssh.Client
|
||||
err error
|
||||
calls int
|
||||
lastAddr string
|
||||
lastCfg *ssh.ClientConfig
|
||||
}
|
||||
|
||||
func (m *mockSSHDialer) DialContext(ctx context.Context, network, addr string, config *ssh.ClientConfig) (*ssh.Client, error) {
|
||||
m.calls++
|
||||
m.lastAddr = addr
|
||||
m.lastCfg = config
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
}
|
||||
return m.client, nil
|
||||
}
|
||||
|
||||
func setupORCAHome(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", dir)
|
||||
knownHosts := filepath.Join(dir, "known_hosts")
|
||||
if err := os.WriteFile(knownHosts, []byte{}, 0o600); err != nil {
|
||||
t.Fatalf("create known_hosts: %v", err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestBootstrapProxmox_SSHAuthFailure(t *testing.T) {
|
||||
orig := sshDialer
|
||||
defer func() { sshDialer = orig }()
|
||||
sshDialer = &mockSSHDialer{err: errors.New("ssh: handshake failed: ssh: unable to authenticate")}
|
||||
|
||||
setupORCAHome(t)
|
||||
|
||||
_, err := BootstrapProxmox(context.Background(), Options{
|
||||
Host: "10.0.0.1",
|
||||
Password: "pw",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ssh") {
|
||||
t.Errorf("error should mention ssh, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ssh dial") {
|
||||
t.Errorf("error should mention ssh dial, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapProxmox_SSHDialCalledWithCorrectAddr(t *testing.T) {
|
||||
orig := sshDialer
|
||||
defer func() { sshDialer = orig }()
|
||||
dialer := &mockSSHDialer{err: errors.New("connection refused")}
|
||||
sshDialer = dialer
|
||||
|
||||
setupORCAHome(t)
|
||||
|
||||
_, _ = BootstrapProxmox(context.Background(), Options{
|
||||
Host: "10.0.0.42",
|
||||
Password: "pw",
|
||||
SSHPort: 2222,
|
||||
})
|
||||
if dialer.calls != 1 {
|
||||
t.Errorf("dialer calls = %d, want 1", dialer.calls)
|
||||
}
|
||||
if dialer.lastAddr != "10.0.0.42:2222" {
|
||||
t.Errorf("dial addr = %q, want 10.0.0.42:2222", dialer.lastAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapProxmox_DefaultSSHPort(t *testing.T) {
|
||||
orig := sshDialer
|
||||
defer func() { sshDialer = orig }()
|
||||
dialer := &mockSSHDialer{err: errors.New("connection refused")}
|
||||
sshDialer = dialer
|
||||
|
||||
setupORCAHome(t)
|
||||
|
||||
_, _ = BootstrapProxmox(context.Background(), Options{
|
||||
Host: "10.0.0.99",
|
||||
Password: "pw",
|
||||
})
|
||||
if dialer.lastAddr != "10.0.0.99:22" {
|
||||
t.Errorf("dial addr = %q, want 10.0.0.99:22 (default port)", dialer.lastAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapProxmox_CustomSSHUser(t *testing.T) {
|
||||
orig := sshDialer
|
||||
defer func() { sshDialer = orig }()
|
||||
dialer := &mockSSHDialer{err: errors.New("connection refused")}
|
||||
sshDialer = dialer
|
||||
|
||||
setupORCAHome(t)
|
||||
|
||||
_, _ = BootstrapProxmox(context.Background(), Options{
|
||||
Host: "10.0.0.1",
|
||||
Password: "pw",
|
||||
SSHUser: "custom-admin",
|
||||
})
|
||||
if dialer.calls != 1 {
|
||||
t.Errorf("dialer calls = %d, want 1", dialer.calls)
|
||||
}
|
||||
if dialer.lastCfg == nil || dialer.lastCfg.User != "custom-admin" {
|
||||
t.Errorf("ssh user not propagated, got %+v", dialer.lastCfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapProxmox_SSHKeyGenerated(t *testing.T) {
|
||||
orig := sshDialer
|
||||
defer func() { sshDialer = orig }()
|
||||
sshDialer = &mockSSHDialer{err: errors.New("connection refused")}
|
||||
|
||||
dir := setupORCAHome(t)
|
||||
|
||||
_, _ = BootstrapProxmox(context.Background(), Options{
|
||||
Host: "10.0.0.1",
|
||||
Password: "pw",
|
||||
})
|
||||
|
||||
keyPath := filepath.Join(dir, "orca_ssh_key")
|
||||
pubPath := filepath.Join(dir, "orca_ssh_key.pub")
|
||||
if _, err := os.Stat(keyPath); err != nil {
|
||||
t.Errorf("SSH key not generated at %s: %v", keyPath, err)
|
||||
}
|
||||
if _, err := os.Stat(pubPath); err != nil {
|
||||
t.Errorf("SSH pub not generated at %s: %v", pubPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapProxmox_KnownHostsFileCreated(t *testing.T) {
|
||||
orig := sshDialer
|
||||
defer func() { sshDialer = orig }()
|
||||
sshDialer = &mockSSHDialer{err: errors.New("connection refused")}
|
||||
|
||||
dir := setupORCAHome(t)
|
||||
|
||||
_, _ = BootstrapProxmox(context.Background(), Options{
|
||||
Host: "10.0.0.1",
|
||||
Password: "pw",
|
||||
})
|
||||
|
||||
knownHosts := filepath.Join(dir, "known_hosts")
|
||||
if _, err := os.Stat(knownHosts); err != nil {
|
||||
t.Errorf("known_hosts not created at %s: %v", knownHosts, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapProxmox_NilLogger(t *testing.T) {
|
||||
orig := sshDialer
|
||||
defer func() { sshDialer = orig }()
|
||||
sshDialer = &mockSSHDialer{err: errors.New("connection refused")}
|
||||
|
||||
setupORCAHome(t)
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("nil logger panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
_, _ = BootstrapProxmox(context.Background(), Options{
|
||||
Host: "10.0.0.1",
|
||||
Password: "pw",
|
||||
Logger: nil,
|
||||
})
|
||||
}
|
||||
|
||||
func TestBootstrapProxmox_CustomLogger(t *testing.T) {
|
||||
orig := sshDialer
|
||||
defer func() { sshDialer = orig }()
|
||||
sshDialer = &mockSSHDialer{err: errors.New("connection refused")}
|
||||
|
||||
setupORCAHome(t)
|
||||
|
||||
var buf bytes.Buffer
|
||||
log := slog.New(slog.NewTextHandler(&buf, nil))
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("custom logger panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
_, _ = BootstrapProxmox(context.Background(), Options{
|
||||
Host: "10.0.0.1",
|
||||
Password: "pw",
|
||||
Logger: log,
|
||||
})
|
||||
_ = buf.String()
|
||||
}
|
||||
|
||||
func TestBootstrapProxmox_ContextCancelled(t *testing.T) {
|
||||
orig := sshDialer
|
||||
defer func() { sshDialer = orig }()
|
||||
sshDialer = &mockSSHDialer{err: errors.New("connection refused")}
|
||||
|
||||
setupORCAHome(t)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, err := BootstrapProxmox(ctx, Options{
|
||||
Host: "10.0.0.1",
|
||||
Password: "pw",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error with cancelled context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployPubKey_EmptyPubLine(t *testing.T) {
|
||||
err := deployPubKey(nil, "orca", "")
|
||||
if err == nil {
|
||||
t.Error("expected error for empty pub line")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "empty pub line") {
|
||||
t.Errorf("error should mention empty pub line, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployPubKey_WhitespaceOnlyPubLine(t *testing.T) {
|
||||
err := deployPubKey(nil, "orca", " \n \t ")
|
||||
if err == nil {
|
||||
t.Error("expected error for whitespace-only pub line")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
package proxmox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
type fakeSSHServer struct {
|
||||
listener net.Listener
|
||||
config *ssh.ServerConfig
|
||||
done chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
state map[string]string
|
||||
authDir string
|
||||
}
|
||||
|
||||
func newFakeSSHServer(t *testing.T) *fakeSSHServer {
|
||||
t.Helper()
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ed25519 gen: %v", err)
|
||||
}
|
||||
hostSigner, err := ssh.NewSignerFromKey(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("ssh signer: %v", err)
|
||||
}
|
||||
config := &ssh.ServerConfig{
|
||||
PasswordCallback: func(c ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
|
||||
if string(password) != "pw" {
|
||||
return nil, errors.New("invalid password")
|
||||
}
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
config.AddHostKey(hostSigner)
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
srv := &fakeSSHServer{
|
||||
listener: ln,
|
||||
config: config,
|
||||
done: make(chan struct{}),
|
||||
state: make(map[string]string),
|
||||
authDir: t.TempDir(),
|
||||
}
|
||||
go srv.serve()
|
||||
return srv
|
||||
}
|
||||
|
||||
func (s *fakeSSHServer) addr() string { return s.listener.Addr().String() }
|
||||
|
||||
func (s *fakeSSHServer) serve() {
|
||||
for {
|
||||
conn, err := s.listener.Accept()
|
||||
if err != nil {
|
||||
close(s.done)
|
||||
return
|
||||
}
|
||||
go s.handle(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fakeSSHServer) handle(netConn net.Conn) {
|
||||
defer netConn.Close()
|
||||
_, chans, reqs, err := ssh.NewServerConn(netConn, s.config)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go ssh.DiscardRequests(reqs)
|
||||
for newChan := range chans {
|
||||
if newChan.ChannelType() != "session" {
|
||||
newChan.Reject(ssh.UnknownChannelType, "only session")
|
||||
continue
|
||||
}
|
||||
go s.handleSession(newChan)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fakeSSHServer) handleSession(newChan ssh.NewChannel) {
|
||||
ch, reqs, err := newChan.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer ch.Close()
|
||||
for req := range reqs {
|
||||
switch req.Type {
|
||||
case "exec":
|
||||
var execReq struct{ Command string }
|
||||
if err := ssh.Unmarshal(req.Payload, &execReq); err != nil {
|
||||
req.Reply(false, nil)
|
||||
continue
|
||||
}
|
||||
req.Reply(true, nil)
|
||||
out, code := s.runCommand(execReq.Command)
|
||||
_, _ = ch.Write(out)
|
||||
_, _ = ch.SendRequest("exit-status", false, ssh.Marshal(struct{ Code uint32 }{uint32(code)}))
|
||||
_ = ch.Close()
|
||||
default:
|
||||
req.Reply(false, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fakeSSHServer) runCommand(cmd string) ([]byte, int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
trimmed := strings.TrimSpace(cmd)
|
||||
switch {
|
||||
case trimmed == "echo hello":
|
||||
return []byte("hello\n"), 0
|
||||
case strings.HasPrefix(trimmed, "exit "):
|
||||
return nil, 1
|
||||
case strings.HasPrefix(trimmed, "id -u "):
|
||||
return []byte("1000\n"), 0
|
||||
case strings.Contains(trimmed, "pveum role list") || strings.Contains(trimmed, "pveum role add"):
|
||||
s.state["pve_role:"+extractField(trimmed, "add ", " ")] = "ok"
|
||||
return nil, 0
|
||||
case strings.Contains(trimmed, "pveum user list") || strings.Contains(trimmed, "pveum user add"):
|
||||
s.state["pve_user:orca@pam"] = "ok"
|
||||
return nil, 0
|
||||
case strings.Contains(trimmed, "pveum acl modify"):
|
||||
s.state["pve_acl"] = "ok"
|
||||
return nil, 0
|
||||
case strings.HasPrefix(trimmed, "mkdir -p ") && strings.Contains(trimmed, "authorized_keys"):
|
||||
return s.handleAuthKeyDeploy(trimmed)
|
||||
case strings.HasPrefix(trimmed, "cat > /etc/sudoers.d/"):
|
||||
return s.handleSudoersWrite(trimmed), 0
|
||||
case strings.HasPrefix(trimmed, "visudo -cf /etc/sudoers.d/orca"):
|
||||
if s.state["sudoers_valid"] == "true" {
|
||||
return []byte("/etc/sudoers.d/orca: parsed OK\n"), 0
|
||||
}
|
||||
return []byte("/etc/sudoers.d/orca: syntax error\n"), 1
|
||||
case strings.HasPrefix(trimmed, "cat /") && strings.HasSuffix(trimmed, "/authorized_keys"):
|
||||
return s.readAuthFile(trimmed[4:]), 0
|
||||
case strings.HasPrefix(trimmed, "cat /") && strings.Contains(trimmed, "/orca"):
|
||||
return s.readSudoers(trimmed[4:]), 0
|
||||
default:
|
||||
return []byte("sh: command not found\n"), 127
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fakeSSHServer) handleAuthKeyDeploy(cmd string) ([]byte, int) {
|
||||
parts := strings.Split(cmd, "'")
|
||||
var pubLine string
|
||||
if len(parts) >= 2 {
|
||||
pubLine = parts[1]
|
||||
}
|
||||
authPath := filepath.Join(s.authDir, "authorized_keys")
|
||||
existing := string(s.readFile(authPath))
|
||||
if !strings.Contains(existing, pubLine) {
|
||||
existing += pubLine + "\n"
|
||||
}
|
||||
if err := os.WriteFile(authPath, []byte(existing), 0o600); err != nil {
|
||||
return []byte("mkdir: permission denied\n"), 1
|
||||
}
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
func (s *fakeSSHServer) readAuthFile(path string) []byte {
|
||||
if strings.HasSuffix(path, "/authorized_keys") {
|
||||
return s.readFile(filepath.Join(s.authDir, "authorized_keys"))
|
||||
}
|
||||
return []byte("cat: " + path + ": No such file or directory\n")
|
||||
}
|
||||
|
||||
func (s *fakeSSHServer) handleSudoersWrite(cmd string) []byte {
|
||||
idx := strings.Index(cmd, "\n")
|
||||
if idx < 0 {
|
||||
return []byte("sh: bad heredoc\n")
|
||||
}
|
||||
content := cmd[idx+1:]
|
||||
if end := strings.Index(content, "ORCA_SUDOERS_EOF"); end >= 0 {
|
||||
content = content[:end]
|
||||
}
|
||||
s.state["sudoers_content"] = content
|
||||
s.state["sudoers_valid"] = "true"
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeSSHServer) readSudoers(path string) []byte {
|
||||
if v, ok := s.state["sudoers_content"]; ok {
|
||||
return []byte(v)
|
||||
}
|
||||
return []byte("cat: " + path + ": No such file or directory\n")
|
||||
}
|
||||
|
||||
func (s *fakeSSHServer) readFile(path string) []byte {
|
||||
b, _ := os.ReadFile(path)
|
||||
return b
|
||||
}
|
||||
|
||||
func (s *fakeSSHServer) close() {
|
||||
s.listener.Close()
|
||||
<-s.done
|
||||
}
|
||||
|
||||
func extractField(s, after, until string) string {
|
||||
i := strings.Index(s, after)
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
rest := s[i+len(after):]
|
||||
j := strings.Index(rest, until)
|
||||
if j < 0 {
|
||||
return rest
|
||||
}
|
||||
return rest[:j]
|
||||
}
|
||||
|
||||
func fakeSSHClient(t *testing.T, srv *fakeSSHServer) *ssh.Client {
|
||||
t.Helper()
|
||||
config := &ssh.ClientConfig{
|
||||
User: "root",
|
||||
Auth: []ssh.AuthMethod{ssh.Password("pw")},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
client, err := ssh.Dial("tcp", srv.addr(), config)
|
||||
if err != nil {
|
||||
t.Fatalf("ssh.Dial: %v", err)
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
func TestRunRemote_Success(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
conn := fakeSSHClient(t, srv)
|
||||
defer conn.Close()
|
||||
out, err := runRemote(conn, "echo hello")
|
||||
if err != nil {
|
||||
t.Fatalf("runRemote: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(string(out)) != "hello" {
|
||||
t.Errorf("output = %q, want hello", strings.TrimSpace(string(out)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRemote_Failure(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
conn := fakeSSHClient(t, srv)
|
||||
defer conn.Close()
|
||||
_, err := runRemote(conn, "exit 7")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-zero exit")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "run") {
|
||||
t.Errorf("error should mention run, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployPubKey_Success(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
conn := fakeSSHClient(t, srv)
|
||||
defer conn.Close()
|
||||
|
||||
if err := deployPubKey(conn, "orca", "ssh-ed25519 AAAA test@orca"); err != nil {
|
||||
t.Fatalf("deployPubKey: %v", err)
|
||||
}
|
||||
out := srv.readFile(filepath.Join(srv.authDir, "authorized_keys"))
|
||||
if !strings.Contains(string(out), "ssh-ed25519 AAAA test@orca") {
|
||||
t.Errorf("auth file does not contain the key: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployPubKey_Idempotent(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
conn := fakeSSHClient(t, srv)
|
||||
defer conn.Close()
|
||||
|
||||
if err := deployPubKey(conn, "orca", "ssh-ed25519 AAAA test@orca"); err != nil {
|
||||
t.Fatalf("first deploy: %v", err)
|
||||
}
|
||||
if err := deployPubKey(conn, "orca", "ssh-ed25519 AAAA test@orca"); err != nil {
|
||||
t.Fatalf("second deploy: %v", err)
|
||||
}
|
||||
out := srv.readFile(filepath.Join(srv.authDir, "authorized_keys"))
|
||||
if cnt := strings.Count(string(out), "ssh-ed25519 AAAA test@orca"); cnt != 1 {
|
||||
t.Errorf("key count = %d, want 1 (idempotent)", cnt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateLinuxUser_Success(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
conn := fakeSSHClient(t, srv)
|
||||
defer conn.Close()
|
||||
if err := createLinuxUser(conn, "orca"); err != nil {
|
||||
t.Fatalf("createLinuxUser: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePVERole_Success(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
conn := fakeSSHClient(t, srv)
|
||||
defer conn.Close()
|
||||
if err := createPVERole(conn, "OrcaOperator"); err != nil {
|
||||
t.Fatalf("createPVERole: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePVEUser_Success(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
conn := fakeSSHClient(t, srv)
|
||||
defer conn.Close()
|
||||
if err := createPVEUser(conn, "orca"); err != nil {
|
||||
t.Fatalf("createPVEUser: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignPVEACL_Success(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
conn := fakeSSHClient(t, srv)
|
||||
defer conn.Close()
|
||||
if err := assignPVEACL(conn, "orca", "OrcaOperator"); err != nil {
|
||||
t.Fatalf("assignPVEACL: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSudoers_Success(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
conn := fakeSSHClient(t, srv)
|
||||
defer conn.Close()
|
||||
|
||||
if err := writeSudoers(conn, "orca"); err != nil {
|
||||
t.Fatalf("writeSudoers: %v", err)
|
||||
}
|
||||
if srv.state["sudoers_valid"] != "true" {
|
||||
t.Error("sudoers not marked valid")
|
||||
}
|
||||
if !strings.Contains(srv.state["sudoers_content"], "orca ALL=(root) NOPASSWD: NOEXEC: /usr/bin/pct") {
|
||||
t.Errorf("sudoers content missing pct: %s", srv.state["sudoers_content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSudoers_ParsedOK(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
conn := fakeSSHClient(t, srv)
|
||||
defer conn.Close()
|
||||
|
||||
srv.state["sudoers_valid"] = "true"
|
||||
if err := validateSudoers(conn); err != nil {
|
||||
t.Errorf("validateSudoers: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSudoers_Failure(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
conn := fakeSSHClient(t, srv)
|
||||
defer conn.Close()
|
||||
|
||||
srv.state["sudoers_valid"] = "false"
|
||||
if err := validateSudoers(conn); err == nil {
|
||||
t.Error("expected error for invalid sudoers")
|
||||
}
|
||||
}
|
||||
|
||||
type staticDialer struct {
|
||||
client *ssh.Client
|
||||
}
|
||||
|
||||
func (d *staticDialer) DialContext(ctx context.Context, network, addr string, config *ssh.ClientConfig) (*ssh.Client, error) {
|
||||
return d.client, nil
|
||||
}
|
||||
|
||||
func TestBootstrapProxmox_FullFlow_Success(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
|
||||
home := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", home)
|
||||
if err := os.WriteFile(filepath.Join(home, "known_hosts"), []byte{}, 0o600); err != nil {
|
||||
t.Fatalf("create known_hosts: %v", err)
|
||||
}
|
||||
|
||||
orig := sshDialer
|
||||
defer func() { sshDialer = orig }()
|
||||
sshDialer = &staticDialer{client: fakeSSHClient(t, srv)}
|
||||
|
||||
host, _, _ := net.SplitHostPort(srv.addr())
|
||||
|
||||
var logBuf bytes.Buffer
|
||||
result, err := BootstrapProxmox(t.Context(), Options{
|
||||
Host: host,
|
||||
Password: "pw",
|
||||
Logger: slog.New(slog.NewTextHandler(&logBuf, nil)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BootstrapProxmox: %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("result is nil")
|
||||
}
|
||||
if result.NodeName != host {
|
||||
t.Errorf("NodeName = %q, want %q", result.NodeName, host)
|
||||
}
|
||||
if result.NodeAddress != host+":8443" {
|
||||
t.Errorf("NodeAddress = %q, want %q:8443", result.NodeAddress, host)
|
||||
}
|
||||
if !strings.Contains(logBuf.String(), "proxmox.bootstrap_ok") {
|
||||
t.Errorf("expected bootstrap_ok log, got: %s", logBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapProxmox_FullFlow_DeployPubKeyFails(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
|
||||
home := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", home)
|
||||
if err := os.WriteFile(filepath.Join(home, "known_hosts"), []byte{}, 0o600); err != nil {
|
||||
t.Fatalf("create known_hosts: %v", err)
|
||||
}
|
||||
|
||||
orig := sshDialer
|
||||
defer func() { sshDialer = orig }()
|
||||
|
||||
// Use a real client that connects to a server which will reject deploy
|
||||
// by returning a non-zero exit for the mkdir command. We achieve this
|
||||
// by using a dialer that returns a client to a server whose authDir
|
||||
// is read-only — but simpler: just use a fresh server that errors on
|
||||
// authorized_keys commands via a custom server. We reuse newFakeSSHServer
|
||||
// but sabotage it by pointing authDir to a read-only location.
|
||||
conn := fakeSSHClient(t, srv)
|
||||
defer conn.Close()
|
||||
sshDialer = &staticDialer{client: conn}
|
||||
|
||||
host, _, _ := net.SplitHostPort(srv.addr())
|
||||
|
||||
// Make authDir unwritable so deployPubKey's mkdir handler fails.
|
||||
srv.authDir = "/proc/1/forbidden-orca-test"
|
||||
|
||||
_, err := BootstrapProxmox(t.Context(), Options{
|
||||
Host: host,
|
||||
Password: "pw",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error from deployPubKey failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "deploy pubkey") {
|
||||
t.Errorf("error should mention deploy pubkey, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func openCertTestDB(t *testing.T) (*CertRepo, func()) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "test.db")
|
||||
db, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
return NewCertRepo(db), func() { _ = db.Close() }
|
||||
}
|
||||
|
||||
func sampleCert(id, nodeID, serial string, createdAt time.Time) *Cert {
|
||||
return &Cert{
|
||||
ID: id,
|
||||
Kind: CertKindServer,
|
||||
NodeID: nodeID,
|
||||
SerialHex: serial,
|
||||
SubjectCN: "cn-" + id,
|
||||
IssuerCN: "issuer-" + id,
|
||||
NotBefore: createdAt.Add(-time.Hour),
|
||||
NotAfter: createdAt.Add(24 * time.Hour),
|
||||
Fingerprint: "fp-" + id,
|
||||
SourcePath: "/path/" + id,
|
||||
CreatedAt: createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertRepo_InsertAndGet(t *testing.T) {
|
||||
repo, cleanup := openCertTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
createdAt := time.Now().UTC().Truncate(time.Second)
|
||||
want := sampleCert("cert-1", "node-1", "AA", createdAt)
|
||||
|
||||
if err := repo.Insert(ctx, want); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.Get(ctx, "cert-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.ID != want.ID {
|
||||
t.Errorf("id = %q, want %q", got.ID, want.ID)
|
||||
}
|
||||
if got.Kind != want.Kind {
|
||||
t.Errorf("kind = %q, want %q", got.Kind, want.Kind)
|
||||
}
|
||||
if got.NodeID != want.NodeID {
|
||||
t.Errorf("node_id = %q, want %q", got.NodeID, want.NodeID)
|
||||
}
|
||||
if got.SerialHex != want.SerialHex {
|
||||
t.Errorf("serial_hex = %q, want %q", got.SerialHex, want.SerialHex)
|
||||
}
|
||||
if got.SubjectCN != want.SubjectCN {
|
||||
t.Errorf("subject_cn = %q, want %q", got.SubjectCN, want.SubjectCN)
|
||||
}
|
||||
if got.IssuerCN != want.IssuerCN {
|
||||
t.Errorf("issuer_cn = %q, want %q", got.IssuerCN, want.IssuerCN)
|
||||
}
|
||||
if !got.NotBefore.Equal(want.NotBefore) {
|
||||
t.Errorf("not_before = %v, want %v", got.NotBefore, want.NotBefore)
|
||||
}
|
||||
if !got.NotAfter.Equal(want.NotAfter) {
|
||||
t.Errorf("not_after = %v, want %v", got.NotAfter, want.NotAfter)
|
||||
}
|
||||
if got.Fingerprint != want.Fingerprint {
|
||||
t.Errorf("fingerprint = %q, want %q", got.Fingerprint, want.Fingerprint)
|
||||
}
|
||||
if got.SourcePath != want.SourcePath {
|
||||
t.Errorf("source_path = %q, want %q", got.SourcePath, want.SourcePath)
|
||||
}
|
||||
if !got.CreatedAt.Equal(want.CreatedAt) {
|
||||
t.Errorf("created_at = %v, want %v", got.CreatedAt, want.CreatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertRepo_InsertNil(t *testing.T) {
|
||||
repo, cleanup := openCertTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
if err := repo.Insert(ctx, nil); err == nil {
|
||||
t.Fatal("expected error for nil cert, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertRepo_InsertMissingID(t *testing.T) {
|
||||
repo, cleanup := openCertTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
c := sampleCert("", "node-1", "AA", time.Now().UTC())
|
||||
if err := repo.Insert(ctx, c); err == nil {
|
||||
t.Fatal("expected error for missing ID, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertRepo_InsertMissingKind(t *testing.T) {
|
||||
repo, cleanup := openCertTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
c := sampleCert("cert-1", "node-1", "AA", time.Now().UTC())
|
||||
c.Kind = ""
|
||||
if err := repo.Insert(ctx, c); err == nil {
|
||||
t.Fatal("expected error for missing Kind, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertRepo_InsertDuplicateSerial(t *testing.T) {
|
||||
repo, cleanup := openCertTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
c1 := sampleCert("cert-1", "node-1", "DUP", time.Now().UTC())
|
||||
if err := repo.Insert(ctx, c1); err != nil {
|
||||
t.Fatalf("insert c1: %v", err)
|
||||
}
|
||||
c2 := sampleCert("cert-2", "node-1", "DUP", time.Now().UTC())
|
||||
if err := repo.Insert(ctx, c2); err == nil {
|
||||
t.Fatal("expected error for duplicate serial_hex, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertRepo_GetMissing(t *testing.T) {
|
||||
repo, cleanup := openCertTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
_, err := repo.Get(ctx, "nope")
|
||||
if err != ErrNotFound {
|
||||
t.Errorf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertRepo_List(t *testing.T) {
|
||||
repo, cleanup := openCertTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
base := time.Now().UTC()
|
||||
ids := []string{"old", "mid", "new"}
|
||||
for i, id := range ids {
|
||||
c := sampleCert(id, "node-1", "S"+id, base.Add(time.Duration(i)*time.Second))
|
||||
if err := repo.Insert(ctx, c); err != nil {
|
||||
t.Fatalf("insert %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
got, err := repo.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("expected 3 certs, got %d", len(got))
|
||||
}
|
||||
wantOrder := []string{"new", "mid", "old"}
|
||||
for i, want := range wantOrder {
|
||||
if got[i].ID != want {
|
||||
t.Errorf("list[%d].id = %q, want %q", i, got[i].ID, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertRepo_ListByNode(t *testing.T) {
|
||||
repo, cleanup := openCertTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
base := time.Now().UTC()
|
||||
for i, id := range []string{"a1", "a2"} {
|
||||
c := sampleCert(id, "nodeA", "SA"+id, base.Add(time.Duration(i)*time.Second))
|
||||
if err := repo.Insert(ctx, c); err != nil {
|
||||
t.Fatalf("insert %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
for i, id := range []string{"b1"} {
|
||||
c := sampleCert(id, "nodeB", "SB"+id, base.Add(time.Duration(i)*time.Second))
|
||||
if err := repo.Insert(ctx, c); err != nil {
|
||||
t.Fatalf("insert %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
aCerts, err := repo.ListByNode(ctx, "nodeA")
|
||||
if err != nil {
|
||||
t.Fatalf("list nodeA: %v", err)
|
||||
}
|
||||
if len(aCerts) != 2 {
|
||||
t.Errorf("expected 2 nodeA certs, got %d", len(aCerts))
|
||||
}
|
||||
for _, c := range aCerts {
|
||||
if c.NodeID != "nodeA" {
|
||||
t.Errorf("unexpected node_id %q in nodeA results", c.NodeID)
|
||||
}
|
||||
}
|
||||
|
||||
bCerts, err := repo.ListByNode(ctx, "nodeB")
|
||||
if err != nil {
|
||||
t.Fatalf("list nodeB: %v", err)
|
||||
}
|
||||
if len(bCerts) != 1 {
|
||||
t.Errorf("expected 1 nodeB cert, got %d", len(bCerts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertRepo_LatestForKind(t *testing.T) {
|
||||
repo, cleanup := openCertTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
base := time.Now().UTC()
|
||||
older := sampleCert("old", "node-1", "O", base)
|
||||
newer := sampleCert("new", "node-1", "N", base.Add(time.Minute))
|
||||
if err := repo.Insert(ctx, older); err != nil {
|
||||
t.Fatalf("insert old: %v", err)
|
||||
}
|
||||
if err := repo.Insert(ctx, newer); err != nil {
|
||||
t.Fatalf("insert new: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.LatestForKind(ctx, "node-1", CertKindServer)
|
||||
if err != nil {
|
||||
t.Fatalf("latest: %v", err)
|
||||
}
|
||||
if got.ID != "new" {
|
||||
t.Errorf("latest.id = %q, want new", got.ID)
|
||||
}
|
||||
|
||||
_, err = repo.LatestForKind(ctx, "node-empty", CertKindServer)
|
||||
if err != ErrNotFound {
|
||||
t.Errorf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertRepo_PruneOlderThan(t *testing.T) {
|
||||
repo, cleanup := openCertTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
base := time.Now().UTC()
|
||||
for i, id := range []string{"c1", "c2", "c3", "c4"} {
|
||||
c := sampleCert(id, "node-1", "S"+id, base.Add(time.Duration(i)*time.Second))
|
||||
if err := repo.Insert(ctx, c); err != nil {
|
||||
t.Fatalf("insert %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
n, err := repo.PruneOlderThan(ctx, "node-1", string(CertKindServer), 3)
|
||||
if err != nil {
|
||||
t.Fatalf("prune: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("expected 1 row deleted, got %d", n)
|
||||
}
|
||||
remaining, err := repo.ListByNode(ctx, "node-1")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(remaining) != 3 {
|
||||
t.Errorf("expected 3 remaining, got %d", len(remaining))
|
||||
}
|
||||
for _, c := range remaining {
|
||||
if c.ID == "c1" {
|
||||
t.Errorf("expected c1 pruned, but found")
|
||||
}
|
||||
}
|
||||
|
||||
n2, err := repo.PruneOlderThan(ctx, "node-1", string(CertKindServer), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("prune keep=0: %v", err)
|
||||
}
|
||||
if n2 != 2 {
|
||||
t.Errorf("keep=0 treated as keep=1: expected 2 deleted, got %d", n2)
|
||||
}
|
||||
remaining2, err := repo.ListByNode(ctx, "node-1")
|
||||
if err != nil {
|
||||
t.Fatalf("list after keep=0: %v", err)
|
||||
}
|
||||
if len(remaining2) != 1 {
|
||||
t.Errorf("keep=0 treated as keep=1: expected 1 remaining, got %d", len(remaining2))
|
||||
}
|
||||
if remaining2[0].ID != "c4" {
|
||||
t.Errorf("expected newest c4 retained, got %q", remaining2[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertRepo_Delete(t *testing.T) {
|
||||
repo, cleanup := openCertTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
c := sampleCert("cert-del", "node-1", "DEL", time.Now().UTC())
|
||||
if err := repo.Insert(ctx, c); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
if err := repo.Delete(ctx, "cert-del"); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if err := repo.Delete(ctx, "cert-del"); err != ErrNotFound {
|
||||
t.Errorf("expected ErrNotFound on second delete, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,8 @@ func TestMigrationVersion(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("migration version: %v", err)
|
||||
}
|
||||
if version != "0006_node_kind_os.sql" {
|
||||
t.Errorf("MigrationVersion = %q, want 0006_node_kind_os.sql", version)
|
||||
if version != "0007_certs_serial_unique.sql" {
|
||||
t.Errorf("MigrationVersion = %q, want 0007_certs_serial_unique.sql", version)
|
||||
}
|
||||
|
||||
// Empty the migrations table → should return ("", nil).
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Enforce uniqueness of serial_hex (ideation I-107): no two certs
|
||||
-- issued by orca may share the same serial. Implemented as a UNIQUE
|
||||
-- INDEX so existing 0004_certs.sql need not be re-run on deployed
|
||||
-- databases. v0.7 P01 (REQ-053 companion).
|
||||
--
|
||||
-- P1-001 fix (final review): before creating the UNIQUE index, dedup
|
||||
-- any existing rows that share a serial_hex. Keep the newest row
|
||||
-- (MAX(created_at)) per serial_hex and delete older duplicates. This
|
||||
-- makes the migration backward-compatible with v0.6 deployments that
|
||||
-- may have accumulated duplicate serials before the constraint existed.
|
||||
DELETE FROM certs WHERE id NOT IN (
|
||||
SELECT id FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (PARTITION BY serial_hex ORDER BY created_at DESC) AS rn
|
||||
FROM certs
|
||||
) WHERE rn = 1
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_certs_serial_unique ON certs(serial_hex);
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
@@ -252,7 +253,7 @@ func bytesReader(b []byte) *bytesReadCloser { return &bytesReadCloser{b: b} }
|
||||
|
||||
func (r *bytesReadCloser) Read(p []byte) (int, error) {
|
||||
if r.pos >= len(r.b) {
|
||||
return 0, fmt.Errorf("EOF")
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := copy(p, r.b[r.pos:])
|
||||
r.pos += n
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/security"
|
||||
)
|
||||
|
||||
type mockDispatcher struct {
|
||||
jobID string
|
||||
state string
|
||||
submitErr error
|
||||
statusErr error
|
||||
submits int
|
||||
statuses int
|
||||
lastSpec []byte
|
||||
}
|
||||
|
||||
func (m *mockDispatcher) LocalSubmit(ctx context.Context, spec []byte) (string, error) {
|
||||
m.submits++
|
||||
m.lastSpec = spec
|
||||
if m.submitErr != nil {
|
||||
return "", m.submitErr
|
||||
}
|
||||
if m.jobID == "" {
|
||||
return "job-123", nil
|
||||
}
|
||||
return m.jobID, nil
|
||||
}
|
||||
|
||||
func (m *mockDispatcher) LocalStatus(ctx context.Context, jobID string) (string, error) {
|
||||
m.statuses++
|
||||
if m.statusErr != nil {
|
||||
return "", m.statusErr
|
||||
}
|
||||
if m.state == "" {
|
||||
return "running", nil
|
||||
}
|
||||
return m.state, nil
|
||||
}
|
||||
|
||||
func TestSubmitHandler_Success(t *testing.T) {
|
||||
d := &mockDispatcher{}
|
||||
h := NewSubmitHandler(d, nil)
|
||||
body := bytes.NewReader([]byte(`{"spec":"{}"}`))
|
||||
req := httptest.NewRequest(http.MethodPost, "/orca.v1.Dispatch/Submit", body)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
var resp SubmitResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.JobID != "job-123" {
|
||||
t.Errorf("JobID = %q, want job-123", resp.JobID)
|
||||
}
|
||||
if d.submits != 1 {
|
||||
t.Errorf("submits = %d, want 1", d.submits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitHandler_IdempotencyReplay(t *testing.T) {
|
||||
d := &mockDispatcher{}
|
||||
store := NewIdempotencyStore()
|
||||
store.Put("key-1", "job-existing")
|
||||
h := NewSubmitHandler(d, store)
|
||||
body := bytes.NewReader([]byte(`{"spec":"{}"}`))
|
||||
req := httptest.NewRequest(http.MethodPost, "/orca.v1.Dispatch/Submit", body)
|
||||
req.Header.Set(IdempotencyHeader, "key-1")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
var resp SubmitResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.JobID != "job-existing" {
|
||||
t.Errorf("JobID = %q, want job-existing (replay)", resp.JobID)
|
||||
}
|
||||
if d.submits != 0 {
|
||||
t.Errorf("submits = %d, want 0 (replayed from store)", d.submits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitHandler_IdempotencyStores(t *testing.T) {
|
||||
d := &mockDispatcher{}
|
||||
store := NewIdempotencyStore()
|
||||
h := NewSubmitHandler(d, store)
|
||||
body := bytes.NewReader([]byte(`{"spec":"{}"}`))
|
||||
req := httptest.NewRequest(http.MethodPost, "/orca.v1.Dispatch/Submit", body)
|
||||
req.Header.Set(IdempotencyHeader, "key-2")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
if got, ok := store.Get("key-2"); !ok || got != "job-123" {
|
||||
t.Errorf("store.Get(key-2) = (%q, %v), want (job-123, true)", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitHandler_BadMethod(t *testing.T) {
|
||||
h := NewSubmitHandler(&mockDispatcher{}, nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/orca.v1.Dispatch/Submit", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("status = %d, want 405", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitHandler_BadBody(t *testing.T) {
|
||||
h := NewSubmitHandler(&mockDispatcher{}, nil)
|
||||
body := strings.NewReader("{not json")
|
||||
req := httptest.NewRequest(http.MethodPost, "/orca.v1.Dispatch/Submit", body)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitHandler_EmptySpec(t *testing.T) {
|
||||
h := NewSubmitHandler(&mockDispatcher{}, nil)
|
||||
body := bytes.NewReader([]byte(`{}`))
|
||||
req := httptest.NewRequest(http.MethodPost, "/orca.v1.Dispatch/Submit", body)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitHandler_DispatcherError(t *testing.T) {
|
||||
d := &mockDispatcher{submitErr: errors.New("boom")}
|
||||
h := NewSubmitHandler(d, nil)
|
||||
body := bytes.NewReader([]byte(`{"spec":"{}"}`))
|
||||
req := httptest.NewRequest(http.MethodPost, "/orca.v1.Dispatch/Submit", body)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("status = %d, want 500", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusHandler_Success(t *testing.T) {
|
||||
d := &mockDispatcher{state: "complete"}
|
||||
h := NewStatusHandler(d)
|
||||
body := bytes.NewReader([]byte(`{"job_id":"job-1"}`))
|
||||
req := httptest.NewRequest(http.MethodPost, "/orca.v1.Dispatch/Status", body)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
var resp StatusResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.State != "complete" {
|
||||
t.Errorf("State = %q, want complete", resp.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusHandler_BadMethod(t *testing.T) {
|
||||
h := NewStatusHandler(&mockDispatcher{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/orca.v1.Dispatch/Status", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("status = %d, want 405", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusHandler_BadBody(t *testing.T) {
|
||||
h := NewStatusHandler(&mockDispatcher{})
|
||||
body := strings.NewReader("nope")
|
||||
req := httptest.NewRequest(http.MethodPost, "/orca.v1.Dispatch/Status", body)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusHandler_EmptyJobID(t *testing.T) {
|
||||
h := NewStatusHandler(&mockDispatcher{})
|
||||
body := bytes.NewReader([]byte(`{"job_id":""}`))
|
||||
req := httptest.NewRequest(http.MethodPost, "/orca.v1.Dispatch/Status", body)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusHandler_DispatcherError(t *testing.T) {
|
||||
d := &mockDispatcher{statusErr: errors.New("not found")}
|
||||
h := NewStatusHandler(d)
|
||||
body := bytes.NewReader([]byte(`{"job_id":"job-x"}`))
|
||||
req := httptest.NewRequest(http.MethodPost, "/orca.v1.Dispatch/Status", body)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDispatchClient(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ca, err := security.CAInit(dir, "orca-test-ca")
|
||||
if err != nil {
|
||||
t.Fatalf("CAInit: %v", err)
|
||||
}
|
||||
caPath := filepath.Join(dir, security.CACertFile)
|
||||
_ = ca
|
||||
c, err := NewDispatchClient(caPath, "localhost", "https://localhost:8443")
|
||||
if err != nil {
|
||||
t.Fatalf("NewDispatchClient: %v", err)
|
||||
}
|
||||
if c == nil {
|
||||
t.Fatal("client is nil")
|
||||
}
|
||||
if c.PeerAddr != "https://localhost:8443" {
|
||||
t.Errorf("PeerAddr = %q, want https://localhost:8443", c.PeerAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDispatchClient_EmptyCAPath(t *testing.T) {
|
||||
_, err := NewDispatchClient("", "localhost", "https://localhost:8443")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty caPath")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDispatchClient_EmptyServerName(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, err := security.CAInit(dir, "orca-test-ca")
|
||||
caPath := filepath.Join(dir, security.CACertFile)
|
||||
_, err = NewDispatchClient(caPath, "", "https://localhost:8443")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty serverName")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchClient_InvalidURL(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, err := security.CAInit(dir, "orca-test-ca")
|
||||
if err != nil {
|
||||
t.Fatalf("CAInit: %v", err)
|
||||
}
|
||||
caPath := filepath.Join(dir, security.CACertFile)
|
||||
c, err := NewDispatchClient(caPath, "localhost", "http://127.0.0.1:1")
|
||||
if err != nil {
|
||||
t.Fatalf("NewDispatchClient: %v", err)
|
||||
}
|
||||
_, err = c.Status(context.Background(), "job-1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for connection refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchClient_Status_HTTPError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, "boom")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dc := &DispatchClient{
|
||||
HTTP: &MTLSClient{http: &http.Client{Timeout: 5 * time.Second}},
|
||||
PeerAddr: srv.URL,
|
||||
}
|
||||
_, err := dc.Status(context.Background(), "job-1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 500 status")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchClient_Status_DecodeError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("{not valid json"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dc := &DispatchClient{
|
||||
HTTP: &MTLSClient{http: &http.Client{Timeout: 5 * time.Second}},
|
||||
PeerAddr: srv.URL,
|
||||
}
|
||||
_, err := dc.Status(context.Background(), "job-1")
|
||||
if err == nil {
|
||||
t.Fatal("expected decode error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchClient_Status_Success(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method")
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req StatusRequest
|
||||
_ = json.Unmarshal(body, &req)
|
||||
if req.JobID != "job-9" {
|
||||
writeError(w, http.StatusBadRequest, "bad job_id")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, StatusResponse{JobID: "job-9", NodeID: "self", State: "complete"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dc := &DispatchClient{
|
||||
HTTP: &MTLSClient{http: &http.Client{Timeout: 5 * time.Second}},
|
||||
PeerAddr: srv.URL,
|
||||
}
|
||||
resp, err := dc.Status(context.Background(), "job-9")
|
||||
if err != nil {
|
||||
t.Fatalf("Status: %v", err)
|
||||
}
|
||||
if resp.JobID != "job-9" {
|
||||
t.Errorf("JobID = %q, want job-9", resp.JobID)
|
||||
}
|
||||
if resp.State != "complete" {
|
||||
t.Errorf("State = %q, want complete", resp.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchClient_Submit_Success(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, SubmitResponse{JobID: "job-submit-1", NodeID: "peer-1"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dc := &DispatchClient{
|
||||
HTTP: &MTLSClient{http: &http.Client{Timeout: 5 * time.Second}},
|
||||
PeerAddr: srv.URL,
|
||||
}
|
||||
resp, err := dc.Submit(context.Background(), []byte("spec"), "idem-key-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Submit: %v", err)
|
||||
}
|
||||
if resp.JobID != "job-submit-1" {
|
||||
t.Errorf("JobID = %q, want job-submit-1", resp.JobID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchClient_Submit_NonIdempotentTransientBails(t *testing.T) {
|
||||
calls := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dc := &DispatchClient{
|
||||
HTTP: &MTLSClient{http: &http.Client{Timeout: 5 * time.Second}},
|
||||
PeerAddr: srv.URL,
|
||||
}
|
||||
_, err := dc.Submit(context.Background(), []byte("spec"), "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("calls = %d, want 1 (no key, no retry)", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBytesReader(t *testing.T) {
|
||||
r := bytesReader([]byte("hello"))
|
||||
buf := make([]byte, 5)
|
||||
n, err := r.Read(buf)
|
||||
if n != 5 || err != nil || string(buf) != "hello" {
|
||||
t.Errorf("Read: n=%d err=%v buf=%q", n, err, buf)
|
||||
}
|
||||
n, err = r.Read(buf)
|
||||
if n != 0 || err == nil {
|
||||
t.Errorf("Read past end: n=%d err=%v, want error", n, err)
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Errorf("Close: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestLogger(buf *bytes.Buffer) *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(buf, nil))
|
||||
}
|
||||
|
||||
func TestLogHandshakeOK(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
log := newTestLogger(&buf)
|
||||
LogHandshakeOK(log, "peer1", "fp123")
|
||||
out := buf.String()
|
||||
for _, want := range []string{
|
||||
"event=mtls.handshake",
|
||||
"result=ok",
|
||||
"peer=peer1",
|
||||
"cert_fp=fp123",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogHandshakeFailed(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
log := newTestLogger(&buf)
|
||||
LogHandshakeFailed(log, "peer1", "", errors.New("tls: bad cert"))
|
||||
out := buf.String()
|
||||
for _, want := range []string{
|
||||
"event=mtls.handshake",
|
||||
"result=failed",
|
||||
"peer=peer1",
|
||||
"err=\"tls: bad cert\"",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(out, "level=WARN") {
|
||||
t.Errorf("expected WARN level, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogHandshakeOK_NilLogger(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("nil logger panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
LogHandshakeOK(nil, "peer1", "fp123")
|
||||
}
|
||||
|
||||
func TestLogHandshakeFailed_NilLogger(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("nil logger panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
LogHandshakeFailed(nil, "peer1", "", errors.New("x"))
|
||||
}
|
||||
|
||||
func TestLogHandshakeFailed_NoErr(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
log := newTestLogger(&buf)
|
||||
LogHandshakeFailed(log, "peer1", "fp123", nil)
|
||||
out := buf.String()
|
||||
if strings.Contains(out, "err=") {
|
||||
t.Errorf("expected no err= field when err is nil: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "result=failed") {
|
||||
t.Errorf("expected result=failed: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogHandshakeFromCert_NilCert(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
log := newTestLogger(&buf)
|
||||
LogHandshakeFromCert(log, "peer1", nil)
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "result=ok") {
|
||||
t.Errorf("expected result=ok: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "peer=peer1") {
|
||||
t.Errorf("expected peer=peer1: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFingerprintOfCert_Nil(t *testing.T) {
|
||||
if got := FingerprintOfCert(nil); got != "" {
|
||||
t.Errorf("FingerprintOfCert(nil) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/security"
|
||||
)
|
||||
|
||||
func generateTestCerts(t *testing.T, dir, serverName string) (certPath, keyPath, caPath string) {
|
||||
t.Helper()
|
||||
ca, err := security.CAInit(dir, "orca-test-ca")
|
||||
if err != nil {
|
||||
t.Fatalf("CAInit: %v", err)
|
||||
}
|
||||
keyPEM, csrPEM, err := security.GenerateCSR(serverName, []string{serverName, "127.0.0.1"})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCSR: %v", err)
|
||||
}
|
||||
signedPEM, err := ca.SignCSR(csrPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("SignCSR: %v", err)
|
||||
}
|
||||
certPath = filepath.Join(dir, "server.crt")
|
||||
keyPath = filepath.Join(dir, "server.key")
|
||||
caPath = filepath.Join(dir, security.CACertFile)
|
||||
if err := os.WriteFile(certPath, signedPEM, 0o644); err != nil {
|
||||
t.Fatalf("write cert: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
|
||||
t.Fatalf("write key: %v", err)
|
||||
}
|
||||
return certPath, keyPath, caPath
|
||||
}
|
||||
|
||||
func TestServerTLSConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath, keyPath, caPath := generateTestCerts(t, dir, "localhost")
|
||||
cfg, err := security.ServerTLSConfig(certPath, keyPath, caPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ServerTLSConfig: %v", err)
|
||||
}
|
||||
if cfg.MinVersion != tls.VersionTLS13 {
|
||||
t.Errorf("MinVersion = %d, want %d", cfg.MinVersion, tls.VersionTLS13)
|
||||
}
|
||||
if cfg.MaxVersion != tls.VersionTLS13 {
|
||||
t.Errorf("MaxVersion = %d, want %d", cfg.MaxVersion, tls.VersionTLS13)
|
||||
}
|
||||
if cfg.ClientAuth != tls.RequireAndVerifyClientCert {
|
||||
t.Errorf("ClientAuth = %v, want RequireAndVerifyClientCert", cfg.ClientAuth)
|
||||
}
|
||||
if cfg.ClientCAs == nil {
|
||||
t.Error("ClientCAs is nil")
|
||||
}
|
||||
if len(cfg.CipherSuites) == 0 {
|
||||
t.Error("CipherSuites is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerTLSConfig_MissingFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, err := security.ServerTLSConfig(
|
||||
filepath.Join(dir, "nope.crt"),
|
||||
filepath.Join(dir, "nope.key"),
|
||||
filepath.Join(dir, "nope.ca"),
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing files")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientTLSConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath, keyPath, caPath := generateTestCerts(t, dir, "localhost")
|
||||
cfg, err := security.ClientTLSConfig(caPath, "localhost", certPath, keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ClientTLSConfig: %v", err)
|
||||
}
|
||||
if cfg.MinVersion != tls.VersionTLS13 {
|
||||
t.Errorf("MinVersion = %d, want %d", cfg.MinVersion, tls.VersionTLS13)
|
||||
}
|
||||
if cfg.RootCAs == nil {
|
||||
t.Error("RootCAs is nil")
|
||||
}
|
||||
if cfg.ServerName != "localhost" {
|
||||
t.Errorf("ServerName = %q, want localhost", cfg.ServerName)
|
||||
}
|
||||
if len(cfg.Certificates) != 1 {
|
||||
t.Errorf("Certificates len = %d, want 1", len(cfg.Certificates))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientTLSConfig_NoClientCert(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, _, caPath := generateTestCerts(t, dir, "localhost")
|
||||
cfg, err := security.ClientTLSConfig(caPath, "localhost", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("ClientTLSConfig: %v", err)
|
||||
}
|
||||
if len(cfg.Certificates) != 0 {
|
||||
t.Errorf("Certificates len = %d, want 0", len(cfg.Certificates))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientTLSConfig_MismatchedCertKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, _, caPath := generateTestCerts(t, dir, "localhost")
|
||||
if _, err := security.ClientTLSConfig(caPath, "localhost", "only-cert", ""); err == nil {
|
||||
t.Error("expected error for cert without key")
|
||||
}
|
||||
if _, err := security.ClientTLSConfig(caPath, "localhost", "", "only-key"); err == nil {
|
||||
t.Error("expected error for key without cert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMTLSClient(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, _, caPath := generateTestCerts(t, dir, "localhost")
|
||||
c, err := NewMTLSClient(caPath, "localhost", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("NewMTLSClient: %v", err)
|
||||
}
|
||||
if c == nil {
|
||||
t.Fatal("client is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMTLSClient_EmptyCAPath(t *testing.T) {
|
||||
_, err := NewMTLSClient("", "localhost", "", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty caPath")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMTLSClient_EmptyServerName(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, _, caPath := generateTestCerts(t, dir, "localhost")
|
||||
_, err := NewMTLSClient(caPath, "", "", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty serverName")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMTLSClient_MissingCAFile(t *testing.T) {
|
||||
_, err := NewMTLSClient("/nonexistent/ca.crt", "localhost", "", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing CA file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMTLSClient_Do_NilReceiver(t *testing.T) {
|
||||
var c *MTLSClient
|
||||
_, err := c.Do(nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil receiver")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPeerCertificate_NoCerts(t *testing.T) {
|
||||
cb := VerifyPeerCertificate("expected")
|
||||
if err := cb(nil, nil); err == nil {
|
||||
t.Error("expected error for no peer certs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPeerCertificate_Mismatch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath, _, _ := generateTestCerts(t, dir, "localhost")
|
||||
certPEM, err := os.ReadFile(certPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read cert: %v", err)
|
||||
}
|
||||
block, _ := pem.Decode(certPEM)
|
||||
if block == nil {
|
||||
t.Fatal("pem.Decode: no cert block")
|
||||
}
|
||||
cb := VerifyPeerCertificate("wrong-fingerprint")
|
||||
if err := cb([][]byte{block.Bytes}, nil); err == nil {
|
||||
t.Error("expected error for fingerprint mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPeerCertificate_Match(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath, _, _ := generateTestCerts(t, dir, "localhost")
|
||||
certPEM, err := os.ReadFile(certPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read cert: %v", err)
|
||||
}
|
||||
block, _ := pem.Decode(certPEM)
|
||||
if block == nil {
|
||||
t.Fatal("pem.Decode: no cert block")
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCertificate: %v", err)
|
||||
}
|
||||
expected := security.FingerprintOf(leaf.Raw)
|
||||
cb := VerifyPeerCertificate(expected)
|
||||
if err := cb([][]byte{block.Bytes}, nil); err != nil {
|
||||
t.Errorf("expected match, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialContext_EmptyCAPath(t *testing.T) {
|
||||
_, err := DialContext(t.Context(), "tcp", "127.0.0.1:0", "", "localhost")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty caPath")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialContext_ConnectionRefused(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, _, caPath := generateTestCerts(t, dir, "localhost")
|
||||
_, err := DialContext(t.Context(), "tcp", "127.0.0.1:1", caPath, "localhost")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for connection refused")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user