Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38220192bb | |||
| ba5ffd76f9 | |||
| 9b308c79f4 | |||
| a7bb00d935 | |||
| b4d9409e4d | |||
| efdbd2a61d | |||
| 5755f12053 | |||
| 5dba3cef80 | |||
| fc6a6c07e2 |
@@ -526,115 +526,3 @@ orca CLI orca daemon orca daemon
|
|||||||
For v0.2, one node must be the CA holder (`orca cert init` was run
|
For v0.2, one node must be the CA holder (`orca cert init` was run
|
||||||
on it). The CA holder's `ca.crt` is copied to each peer manually by
|
on it). The CA holder's `ca.crt` is copied to each peer manually by
|
||||||
the operator; peers do not auto-fetch it.
|
the operator; peers do not auto-fetch it.
|
||||||
|
|
||||||
## v0.6 Architecture Addendum — Node Bootstrap & Proxmox
|
|
||||||
|
|
||||||
### `orca init` Full Bootstrap (REQ-047, REQ-048, REQ-049)
|
|
||||||
|
|
||||||
`orca init` transforms from a bare `mkdir` into a full single-node
|
|
||||||
cluster bootstrap. The sequence (idempotent per D-036):
|
|
||||||
|
|
||||||
```
|
|
||||||
orca init
|
|
||||||
1. MkdirAll(certpaths.Dir(), 0o755) # namespace dir
|
|
||||||
2. store.Open(certpaths.DBPath()) # runs migrations 0001..0006
|
|
||||||
3. security.CAInit(dir, "orca-internal-ca") # idempotent fast-path
|
|
||||||
4. if !exists(server.crt):
|
|
||||||
GenerateCSR("localhost", ["localhost","127.0.0.1"])
|
|
||||||
ca.SignCSR(csr) → WriteCert + WriteKey # server cert (skip if present)
|
|
||||||
5. os := detectOS() # /etc/os-release ID=
|
|
||||||
6. node := Node{kind:"localhost", os:os, name:"localhost", addr:"localhost:8443"}
|
|
||||||
if GetByName("localhost") exists:
|
|
||||||
UpdateLastSeenAndOS(id, os) # refresh, keep id/joined_at
|
|
||||||
else:
|
|
||||||
NodeRepo.Insert(node) # first-run insert
|
|
||||||
7. print summary (CA fp, server cert fp, os, node id)
|
|
||||||
```
|
|
||||||
|
|
||||||
After `orca init`, `orca doctor` MUST pass with zero FAILs.
|
|
||||||
|
|
||||||
### Node Schema Extension (REQ-049)
|
|
||||||
|
|
||||||
Migration 0006 adds two nullable columns to `nodes`:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
ALTER TABLE nodes ADD COLUMN kind TEXT; -- localhost | linux | proxmox
|
|
||||||
ALTER TABLE nodes ADD COLUMN os TEXT; -- ubuntu | debian | alpine | pve | linux
|
|
||||||
```
|
|
||||||
|
|
||||||
Existing rows get SQL NULL → mapped to `""` in Go (`sql.NullString`).
|
|
||||||
`Node` struct gains `Kind string` + `OS string` fields (JSON tags
|
|
||||||
`kind,omitempty` / `os,omitempty`). `NodeRepo` extends all
|
|
||||||
INSERT/SELECT/scanNode calls; adds `GetByName(ctx, name)` and
|
|
||||||
`UpdateLastSeenAndOS(ctx, id, os)` helpers.
|
|
||||||
|
|
||||||
### Proxmox SSH Bootstrap (REQ-050, REQ-051)
|
|
||||||
|
|
||||||
```
|
|
||||||
orca node join --type proxmox --host <addr> --user root --password <pw>
|
|
||||||
│ password from --password or $ORCA_PROXMOX_PASSWORD (never persisted, D-031)
|
|
||||||
▼
|
|
||||||
internal/proxmox.BootstrapProxmox(ctx, opts)
|
|
||||||
1. GenerateOrLoadSSHKey(certpaths.Dir()) # Ed25519, ~/.orca/orca_ssh_key{,.pub}
|
|
||||||
2. SSH dial (password auth, knownhosts.New TOFU) # capture host key on first connect
|
|
||||||
3. Deploy pubkey → ~orca/.ssh/authorized_keys # via session heredoc (no SFTP dep)
|
|
||||||
4. useradd -m orca # create Linux system user (config-overridable name)
|
|
||||||
5. pveum role add OrcaOperator --privs "VM.Audit Datastore.AllocateSpace SDN.Use"
|
|
||||||
(idempotent: probe pveum role list first)
|
|
||||||
6. pveum user add orca@pam -comment "Orca automation user"
|
|
||||||
(idempotent: probe pveum user list first)
|
|
||||||
7. pveum acl modify / -user orca@pam -role OrcaOperator
|
|
||||||
(idempotent: modify creates or updates)
|
|
||||||
8. Write /etc/sudoers.d/orca (mode 0440):
|
|
||||||
orca ALL=(root) NOPASSWD: NOEXEC: /usr/bin/pct, /usr/bin/qm
|
|
||||||
orca ALL=(root) NOPASSWD: /usr/bin/apt-get, /usr/bin/dpkg
|
|
||||||
9. visudo -cf /etc/sudoers.d/orca # validate; abort on error
|
|
||||||
10. NodeRepo.Insert(Node{kind:"proxmox", os:"pve", name:host, addr:host})
|
|
||||||
11. Audit log: proxmox.bootstrap_ok (host, user, role, fp)
|
|
||||||
```
|
|
||||||
|
|
||||||
**`pvesh` excluded from sudoers** — `pvesh` can trigger the API
|
|
||||||
`/nodes/{node}/execute` endpoint which spawns shell commands
|
|
||||||
server-side, bypassing sudo's `NOEXEC` tag. API access is via the
|
|
||||||
`OrcaOperator` PVE role + `orca@pam` user (PVE RBAC), not sudo'd `pvesh`.
|
|
||||||
|
|
||||||
### Doctor Extensions (REQ-052)
|
|
||||||
|
|
||||||
- **`doctor os`**: re-runs `detectOS()` from `/etc/os-release`, compares
|
|
||||||
to the stored localhost node's `os` field. Drift = WARN (OS upgraded
|
|
||||||
since init? re-run `orca init` to refresh). Match = PASS.
|
|
||||||
- **`doctor proxmox`**: iterates `kind=proxmox` nodes, SSH-probes each
|
|
||||||
with `pveversion` (3s timeout per peer, clones `doctor.Network()`
|
|
||||||
pattern). PASS = reachable + pveversion exits 0. WARN = zero proxmox
|
|
||||||
nodes (single-node cluster is legitimate). FAIL = any node
|
|
||||||
unreachable or pveversion fails.
|
|
||||||
|
|
||||||
### SSH Key Handling (D-037)
|
|
||||||
|
|
||||||
- **Location**: `~/.orca/orca_ssh_key` (0600) + `~/.orca/orca_ssh_key.pub` (0644)
|
|
||||||
- **Algorithm**: Ed25519 (smaller, faster, more secure than RSA for SSH)
|
|
||||||
- **Generation**: lazy — on first `orca node join --type proxmox`, NOT at `orca init` (localhost doesn't need SSH)
|
|
||||||
- **Format**: PKCS8 PEM (consistent with `ca.key`/`server.key`; `ssh.ParsePrivateKey` accepts it)
|
|
||||||
- **TOFU host keys**: `~/.orca/known_hosts` (OpenSSH format via `knownhosts.New`)
|
|
||||||
|
|
||||||
### Dependency Map (v0.6 addition)
|
|
||||||
|
|
||||||
```
|
|
||||||
golang.org/x/crypto v0.54.0 # SSH (ssh + ssh/knownhosts + ed25519)
|
|
||||||
└─ golang.org/x/sys v0.47.0 # indirect (bumped from v0.42.0)
|
|
||||||
└─ golang.org/x/term v0.45.0 # indirect (pulled by ssh for PTY)
|
|
||||||
```
|
|
||||||
|
|
||||||
Total direct deps: 5 (was 4). One new direct dep (`x/crypto`). Matches
|
|
||||||
D-030 minimal-deps rationale. No SFTP module (file upload via session
|
|
||||||
heredoc).
|
|
||||||
|
|
||||||
### v0.6 Architectural Decisions (AD-017..AD-021)
|
|
||||||
|
|
||||||
| ID | Decision | Rationale |
|
|
||||||
|----|----------|-----------|
|
|
||||||
| AD-017 | `orca init` = full bootstrap (CA + cert + db + localhost node) | Single command produces a working cluster; `orca doctor` passes post-init. Idempotent (D-036). |
|
|
||||||
| AD-018 | Proxmox join via SSH (golang.org/x/crypto/ssh), not PVE REST API | SSH is the universal Proxmox management entry point; REST API would require API token bootstrap (chicken-and-egg). One new direct dep (D-030). |
|
|
||||||
| AD-019 | `orca@pam` realm (not `orca@pve`) | SSH creates a Linux system user; PAM realm maps it to PVE RBAC without a separate PVE password. `@pve` requires interactive password prompt over non-PTY SSH (hangs). |
|
|
||||||
| AD-020 | Exclude `pvesh` from sudoers; NOEXEC on `pct`/`qm` | `pvesh` can trigger API execute endpoint bypassing NOEXEC. `pct`/`qm` are Perl scripts via dynamically-linked perl → NOEXEC effective. `apt-get`/`dpkg` need exec for maintainer scripts → no NOEXEC. |
|
|
||||||
| AD-021 | TOFU host-key via `knownhosts.New` | Avoids deprecated `ssh.InsecureIgnoreHostKey`. Capture-on-first-connect, verify-on-subsequent. Fail closed on mismatch (operator runs key-reset). |
|
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
{
|
{
|
||||||
"phase": 3,
|
"phase": 0,
|
||||||
"stage": "verify",
|
"stage": "grill",
|
||||||
"milestone": "v0.6",
|
"milestone": "v0.3",
|
||||||
"milestone_slug": "node-bootstrap-proxmox",
|
"milestone_slug": "scheduling-streaming",
|
||||||
"phase_role": "execution",
|
"phase_role": "pre_execution",
|
||||||
"attempts": 0,
|
"attempts": 0,
|
||||||
"updated_at": "2026-08-03T20:02:00Z",
|
"updated_at": "2026-08-01T00:04:00Z"
|
||||||
"milestone_complete": false,
|
|
||||||
"next_milestone": null
|
|
||||||
}
|
}
|
||||||
+110
-49
@@ -2,26 +2,52 @@
|
|||||||
active_personas:
|
active_personas:
|
||||||
- lead-developer
|
- lead-developer
|
||||||
- backend-engineer
|
- backend-engineer
|
||||||
|
- data-engineer
|
||||||
|
- cli-engineer
|
||||||
|
- security-engineer
|
||||||
|
- network-engineer
|
||||||
|
deactivated_personas:
|
||||||
|
- frontend-engineer
|
||||||
|
- devops-sre
|
||||||
|
phase_specific:
|
||||||
- cli-engineer
|
- cli-engineer
|
||||||
- data-engineer
|
- data-engineer
|
||||||
- security-engineer
|
- security-engineer
|
||||||
deactivated_personas:
|
|
||||||
- devops-engineer
|
|
||||||
- network-engineer
|
- network-engineer
|
||||||
- frontend-engineer
|
|
||||||
phase_specific: []
|
|
||||||
reason: |
|
reason: |
|
||||||
Orca v0.6 is a bootstrap-ergonomics + heterogeneous-nodes milestone.
|
Orca is a CLI-first, offline-first orchestration engine with no web UI and
|
||||||
The work is schema (migration 0006), security (SSH keygen, TOFU,
|
a single-binary distribution model. The v0.3 milestone is a 2-phase
|
||||||
sudoers, PVE role), CLI (init full bootstrap, node join --type proxmox,
|
completion milestone (iter.Seq streaming + doctor network/db) that touches
|
||||||
doctor os/proxmox), and backend orchestration (proxmox SSH bootstrap
|
the CLI, store, doctor, transport, and security layers. The persona roster
|
||||||
sequence). No devops (no install/docker/release), no network (no
|
reflects this:
|
||||||
transport/mTLS), no frontend (no UI).
|
|
||||||
|
|
||||||
Roster changes vs v0.5:
|
- lead-developer: coordination, task decomposition, territory adjudication
|
||||||
- data-engineer: REACTIVATED — owns migration 0006 + NodeRepo schema extension.
|
(e.g. D-039 dbPath relocation between cli-engineer territory and the
|
||||||
- security-engineer: REACTIVATED — owns SSH keygen, TOFU host-key, sudoers, PVE role.
|
doctor package).
|
||||||
- devops-engineer: DEACTIVATED — v0.6 has no packaging/distribution surface.
|
- backend-engineer: daemon health endpoint surface that the doctor network
|
||||||
|
check probes; transport dispatch client reuse.
|
||||||
|
- data-engineer: iter.Seq[Job|Node] on the store repos (P01) and the
|
||||||
|
migration-version query + PRAGMA integrity_check in the store layer (P02).
|
||||||
|
- cli-engineer: the --watch flag on `orca job list` / `orca node list`
|
||||||
|
(P01) and the doctor subcommand wiring (P02).
|
||||||
|
- security-engineer: mTLS client config reuse for the doctor network probe
|
||||||
|
(P02) — TLS config is the security-engineer territory per v0.2.
|
||||||
|
- network-engineer: the doctor /healthz probe over mTLS reuses the
|
||||||
|
transport layer (P02) — connection lifecycle / peer reachability is the
|
||||||
|
network-engineer territory.
|
||||||
|
|
||||||
|
Deactivated:
|
||||||
|
- frontend-engineer: no web UI in Orca (v0.1 onward). NOT relevant to v0.3.
|
||||||
|
- devops-sre: no container/cloud integrations; release flow is handled by
|
||||||
|
CoreCI (not a persona territory).
|
||||||
|
|
||||||
|
Phase-specific (v0.3):
|
||||||
|
- cli-engineer: P01 (--watch flag is a CLI surface) + P02 (doctor
|
||||||
|
subcommand wiring).
|
||||||
|
- data-engineer: P01 (iter.Seq on store repos) + P02 (migration version +
|
||||||
|
integrity check in store layer).
|
||||||
|
- security-engineer: P02 only (mTLS client config for doctor network probe).
|
||||||
|
- network-engineer: P02 only (mTLS /healthz probe over transport).
|
||||||
---
|
---
|
||||||
|
|
||||||
# Personas: Orca
|
# Personas: Orca
|
||||||
@@ -34,67 +60,102 @@ reason: |
|
|||||||
- **Constraints**: `boundary-enforcement`, `offline-first`, `no-redundant-implementations`
|
- **Constraints**: `boundary-enforcement`, `offline-first`, `no-redundant-implementations`
|
||||||
- **Territory**: `**/*.go`, `cmd/**`, `internal/**`
|
- **Territory**: `**/*.go`, `cmd/**`, `internal/**`
|
||||||
- **Active**: true
|
- **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).
|
|
||||||
|
|
||||||
### backend-engineer
|
### backend-engineer
|
||||||
- **Domain**: backend
|
- **Domain**: backend
|
||||||
- **Frameworks**: `cobra`, `net/http`, `golang.org/x/crypto/ssh`
|
- **Frameworks**: `cobra`, `net/http`
|
||||||
- **Constraints**: `API-first`, `error-handling`, `minimal-dependencies`, `security-first`, `idempotent-bootstrap`
|
- **Constraints**: `API-first`, `error-handling`, `minimal-dependencies`, `security-first`
|
||||||
- **Territory**: `**/api/**`, `**/*_handler*`, `**/*_handler.go`, `internal/daemon/**`, `internal/proxmox/**`, `internal/cli/init.go`
|
- **Territory**: `**/api/**`, `**/*_handler*`, `**/*_handler.go`, `internal/daemon/**`
|
||||||
- **Active**: true
|
- **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 the daemon health endpoints (`/healthz`, `/readyz`) that the P02 doctor network check probes. The transport dispatch client (reused by doctor) lives in `internal/transport` but the *handler* surface is backend-engineer territory.
|
||||||
|
|
||||||
### data-engineer
|
### data-engineer
|
||||||
- **Domain**: data
|
- **Domain**: data
|
||||||
- **Frameworks**: `modernc/sqlite`, `iter`
|
- **Frameworks**: `modernc/sqlite`, `iter`
|
||||||
- **Constraints**: `schema-first`, `migration-safe`, `local-storage-only`, `no-goroutine-leak`, `nullable-column-handling`
|
- **Constraints**: `schema-first`, `migration-safe`, `local-storage-only`, `no-goroutine-leak`
|
||||||
- **Territory**: `**/store/**`, `**/model.go`, `**/migration*`, `migrations/**`, `internal/store/migrations/**`, `internal/model/node.go`
|
- **Territory**: `**/store/**`, `**/model.go`, `**/migration*`, `migrations/**`, `internal/store/migrations/**`
|
||||||
- **Active**: true
|
- **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 the `iter.Seq[Job|Node]` implementations on `JobRepo`/`NodeRepo` (P01) and the `MigrationVersion` query + `PRAGMA integrity_check` helper (P02). Added `iter` to frameworks and `no-goroutine-leak` to constraints (the iter.Seq polling loop must not leak — see RESEARCH_v0.3.md D-032). Territory confirmed against actual file structure: `internal/store/` holds all repos + `migrations/` subdir with `0001..0005_*.sql`.
|
||||||
|
|
||||||
### cli-engineer
|
### cli-engineer (custom)
|
||||||
- **Domain**: CLI/UX
|
- **Domain**: CLI/UX
|
||||||
- **Frameworks**: `cobra`, `pflag`
|
- **Frameworks**: `cobra`, `pflag`
|
||||||
- **Constraints**: `discoverable-help`, `consistent-flag-naming`, `human-readable-output`, `machine-readable-json-flag`, `signal-handling`, `password-flag-redaction`
|
- **Constraints**: `discoverable-help`, `consistent-flag-naming`, `human-readable-output`, `machine-readable-json-flag`, `signal-handling`
|
||||||
- **Territory**: `cmd/**`, `internal/cli/**`, `internal/commands/**`
|
- **Territory**: `cmd/**`, `internal/cli/**`, `internal/commands/**`
|
||||||
- **Active**: true
|
- **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).
|
- **Reason**: Orca is CLI-first; this persona ensures CLI quality and discoverability. For v0.3 P01 it owns the `--watch` flag on `orca job list` / `orca node list` (signal.NotifyContext cancellation, table refresh vs streaming JSON). For P02 it owns the `internal/cli/doctor.go` subcommand wiring (replacing NetworkStub/DBStub calls). Added `signal-handling` to constraints (ctrl-c propagation to iter.Seq is a P01 correctness requirement). Territory confirmed: `internal/cli/` holds all Cobra commands.
|
||||||
|
|
||||||
### security-engineer
|
### security-engineer (custom)
|
||||||
- **Domain**: security
|
- **Domain**: security
|
||||||
- **Frameworks**: `crypto/tls`, `crypto/x509`, `crypto/ed25519`, `golang.org/x/crypto/ssh`, `slog`
|
- **Frameworks**: `crypto/tls`, `crypto/x509`, `slog`
|
||||||
- **Constraints**: `no-panic-in-production`, `structured-audit-logging`, `no-secret-in-logs`, `input-validation`, `least-privilege`, `tofu-host-key-pinning`, `noexec-sudoers`
|
- **Constraints**: `no-panic-in-production`, `structured-audit-logging`, `no-secret-in-logs`, `input-validation`, `least-privilege`
|
||||||
- **Territory**: `**/auth/**`, `**/audit/**`, `internal/security/**`, `internal/transport/**` (TLS config only), `internal/proxmox/**` (SSH + sudoers + PVE role)
|
- **Territory**: `**/auth/**`, `**/audit/**`, `internal/security/**`, `internal/transport/**` (TLS config only)
|
||||||
- **Active**: true
|
- **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).
|
- **Reason**: mTLS, audit logging, and input validation are first-class concerns. For v0.3 P02, the doctor network check reuses `security.ClientTLSConfig` (via `transport.NewMTLSClient`) to build the mTLS client that probes peer `/healthz`. The TLS-config portion of `internal/transport/**` remains security-engineer territory.
|
||||||
|
- **Phase scope**: P02 only (mTLS client config for doctor network probe). P01 has no security surface.
|
||||||
|
|
||||||
### devops-engineer
|
### network-engineer (custom, NEW in v0.2)
|
||||||
- **Active**: false (v0.6)
|
- **Domain**: networking
|
||||||
- **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).
|
- **Frameworks**: `net/http`, `crypto/tls` (via `internal/security`), `iter`
|
||||||
|
- **Constraints**: `connection-resilience`, `retry-with-backoff`, `graceful-disconnect`, `context-propagation`, `bounded-probe-timeout`
|
||||||
### network-engineer
|
- **Territory**: `**/transport/**`, `**/engine/dispatcher*`, `**/engine/peer*`, `internal/engine/dispatcher.go`, `internal/engine/peer.go`, `internal/transport/**`
|
||||||
- **Active**: false (v0.6)
|
- **Active**: true
|
||||||
- **Reason**: v0.6 has no transport/mTLS surface. SSH is point-to-point bootstrap, not the mTLS mesh network-engineer owns.
|
- **Reason**: Owns the transport layer and peer-to-peer connection lifecycle. For v0.3 P02, the doctor network check is a read-only mTLS `/healthz` probe that reuses `transport.MTLSClient` — the connection lifecycle (dial, per-probe 3s timeout, handshake) is network-engineer territory. Added `bounded-probe-timeout` to constraints (doctor must not stall on one slow peer — RESEARCH_v0.3.md D-038). Territory confirmed: `internal/transport/` holds mtls.go, dispatch.go, retry.go, idempotency.go, handshake_log.go.
|
||||||
|
- **Phase scope**: P02 only (doctor network probe reuses transport layer).
|
||||||
|
|
||||||
### frontend-engineer
|
### frontend-engineer
|
||||||
- **Active**: false (v0.6)
|
- **Active**: false
|
||||||
- **Reason**: No web UI in Orca (unchanged from v0.1 onward).
|
- **Reason**: No web UI in Orca (v0.1 onward). NOT relevant to v0.3 — v0.3 adds no UI surface. Confirmed deactivated.
|
||||||
|
|
||||||
|
### devops-sre
|
||||||
|
- **Active**: false
|
||||||
|
- **Reason**: No container/cloud integrations. Release flow is handled by CoreCI (not a persona territory). Confirmed deactivated.
|
||||||
|
|
||||||
## Territory Enforcement
|
## Territory Enforcement
|
||||||
|
|
||||||
- **Mode**: `warn` (per `config.json`)
|
- **Mode**: `warn` (per `config.json`)
|
||||||
- **Behavior**: Out-of-territory file changes log a warning but do not block.
|
- **Behavior**: Out-of-territory file changes log a warning but do not block.
|
||||||
- **Key overlaps in v0.6** (lead-developer adjudicates):
|
- **Rationale**: Allows flexibility during early development; tighten to `strict` post-v0.1. For v0.3, the main territory-overlap risk is D-039 (moving `dbPath` from `internal/cli` to `internal/certpaths`) which crosses cli-engineer and the shared-infra concern — 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.
|
|
||||||
|
|
||||||
## v0.6 vs v0.5 Persona Diff
|
## Phase-Specific Personas (v0.3)
|
||||||
|
|
||||||
|
| Persona | Active in | Reason |
|
||||||
|
|---------|-----------|--------|
|
||||||
|
| `cli-engineer` | P01, P02 | P01: `--watch` flag is a CLI surface (signal handling, table/JSON render). P02: doctor subcommand wiring in `internal/cli/doctor.go`. |
|
||||||
|
| `data-engineer` | P01, P02 | P01: `iter.Seq[Job|Node]` on the store repos + the no-leak polling loop. P02: `MigrationVersion` query + `PRAGMA integrity_check` in the store layer. |
|
||||||
|
| `security-engineer` | P02 | mTLS client config reuse for the doctor network probe. P01 has no security surface. |
|
||||||
|
| `network-engineer` | P02 | mTLS `/healthz` probe over the transport layer (connection lifecycle, per-probe timeout). P01 has no network surface. |
|
||||||
|
|
||||||
|
In full-autonomy mode, all personas are auto-accepted and the phase-scope
|
||||||
|
assignments are applied automatically when a phase is committed.
|
||||||
|
|
||||||
|
## v0.3 vs v0.2 Persona Diff
|
||||||
|
|
||||||
| Change | Rationale |
|
| Change | Rationale |
|
||||||
|--------|-----------|
|
|--------|-----------|
|
||||||
| `data-engineer` reactivated | Owns migration 0006 + NodeRepo schema extension (kind/os columns). |
|
| `data-engineer` frameworks: added `iter` | P01 introduces `iter.Seq[T]` on the store repos — a new stdlib framework surface for this persona. |
|
||||||
| `security-engineer` reactivated | Owns SSH keygen, TOFU host-key, sudoers, PVE role — first-class security surface. |
|
| `data-engineer` constraints: added `no-goroutine-leak` | The iter.Seq polling loop must not leak goroutines (inline pull loop, defer ticker.Stop, rows.Close on every path — RESEARCH D-032). |
|
||||||
| `devops-engineer` deactivated | v0.6 has no packaging/distribution surface. |
|
| `cli-engineer` constraints: added `signal-handling` | P01 requires `signal.NotifyContext` for ctrl-c propagation to iter.Seq (D-031). |
|
||||||
| `network-engineer` remains deactivated | No transport/mTLS surface. |
|
| `network-engineer` constraints: added `bounded-probe-timeout` | P02 doctor network check must bound each peer probe (3s) so one slow peer doesn't stall diagnostics (D-038). |
|
||||||
| `frontend-engineer` remains deactivated | No web UI. |
|
| `network-engineer` phase scope: was P02-only (v0.2), now P02-only (v0.3) | Same persona, different phase content — v0.3 P02 is doctor network, not multi-node dispatch. |
|
||||||
|
| `security-engineer` phase scope: was P01+P02 (v0.2), now P02-only (v0.3) | v0.3 has no new cert/CA work; security surface is limited to reusing the existing mTLS client config in doctor. |
|
||||||
|
| `frontend-engineer` | Remains deactivated (no UI in v0.3). |
|
||||||
|
| `devops-sre` | Remains deactivated (CoreCI handles release). |
|
||||||
|
|
||||||
|
## Migration from v0.2
|
||||||
|
|
||||||
|
- `backend-engineer` territory unchanged: `internal/daemon/**` still owns HTTP
|
||||||
|
handlers. The `/healthz` endpoint that the doctor network check probes is
|
||||||
|
backend-engineer territory; the *probing* client is network-engineer.
|
||||||
|
- `data-engineer` territory expanded scope: still owns `internal/store/**` but
|
||||||
|
now adds the `iter.Seq` polling implementations (P01) and a public
|
||||||
|
`MigrationVersion` query (P02).
|
||||||
|
- `security-engineer` territory unchanged: `internal/security/**` + the TLS
|
||||||
|
config portion of `internal/transport/**`. The doctor network check calls
|
||||||
|
into `security.ClientTLSConfig` indirectly via `transport.NewMTLSClient` —
|
||||||
|
no new security-engineer files, just reuse.
|
||||||
|
- `cli-engineer` territory unchanged: `internal/cli/**`. P01 modifies
|
||||||
|
`job.go` and `node.go`; P02 modifies `doctor.go`. The `dbPath` relocation
|
||||||
|
(D-039) moves a 5-line function out of `internal/cli/node.go` into
|
||||||
|
`internal/certpaths` — cli-engineer territory loses one function, shared
|
||||||
|
infra gains it.
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
# Phase 1 Verification: Namespace Unification (v0.5 P1)
|
|
||||||
|
|
||||||
**Phase**: 1 (namespace unification)
|
|
||||||
**Milestone**: v0.5 Distribution
|
|
||||||
**Requirements covered**: REQ-041, REQ-042
|
|
||||||
**Date**: 2026-08-03
|
|
||||||
|
|
||||||
## Structural Layer
|
|
||||||
|
|
||||||
- `gofmt -l .` → clean (no files need formatting).
|
|
||||||
- `go vet ./...` → clean (no warnings).
|
|
||||||
- `go build ./...` → succeeds.
|
|
||||||
- New files: `internal/cli/namespace_test.go`, `docs/namespace.md`.
|
|
||||||
- Modified files: `internal/cli/root.go`, `internal/cli/init.go`, `internal/store/store.go`.
|
|
||||||
|
|
||||||
## Behavioral Layer
|
|
||||||
|
|
||||||
### Unit tests (new)
|
|
||||||
- `TestNamespaceDefaultsToUserHome` ✓ — empty `ORCA_HOME` → `~/.orca`.
|
|
||||||
- `TestNamespaceHonorsORCAHOME` ✓ — `ORCA_HOME=/tmp/x` → `Dir()=/tmp/x`, `DBPath()=/tmp/x/orca.db`.
|
|
||||||
- `TestInitHonorsORCAHOME` ✓ — `init` creates `$ORCA_HOME` dir.
|
|
||||||
- `TestSystemFlagSetsORCAHOME` ✓ — `--system` sets `ORCA_HOME=/root/.orca`.
|
|
||||||
- `TestSystemFlagConflictsWithORCAHOME` ✓ — `--system` + `ORCA_HOME=/custom` → error.
|
|
||||||
- `TestInitJSONOutput` ✓ — `init --json` returns `{"path":"...","status":"initialized"}`.
|
|
||||||
- `TestSystemFlagIsPersistent` ✓ — `--system` registered as persistent flag on `rootCmd`.
|
|
||||||
|
|
||||||
### Unit tests (regression — all pass)
|
|
||||||
- `internal/cli/` (9.8s) ✓
|
|
||||||
- `internal/store/` ✓
|
|
||||||
- `internal/doctor/` ✓
|
|
||||||
- `internal/daemon/` ✓
|
|
||||||
- `internal/security/` ✓
|
|
||||||
- `internal/engine/` ✓
|
|
||||||
- `internal/jobspec/` ✓
|
|
||||||
- `internal/transport/` ✓
|
|
||||||
|
|
||||||
### Manual e2e
|
|
||||||
- `ORCA_HOME=/tmp/orca-test-user ./bin/orca init` → creates `/tmp/orca-test-user` ✓
|
|
||||||
- `./bin/orca --system init` → creates `/root/.orca` ✓
|
|
||||||
- `ORCA_HOME=/custom ./bin/orca --system init` → error "conflicts with ORCA_HOME" ✓
|
|
||||||
- `./bin/orca version --json` → `{"version":"v0.4.1",...}` ✓
|
|
||||||
|
|
||||||
## Security Layer
|
|
||||||
|
|
||||||
- No new secret handling. The namespace unification moves path resolution
|
|
||||||
but does not change cert/key file modes (0600/0644 per REQ-033 unchanged).
|
|
||||||
- `--system` flag does not escalate privileges — it only changes the
|
|
||||||
namespace root path. Running as non-root with `--system` will fail at
|
|
||||||
`os.MkdirAll("/root/.orca")` with a permission error (expected).
|
|
||||||
- No new network surface.
|
|
||||||
|
|
||||||
## Quality Layer
|
|
||||||
|
|
||||||
- **Backward compatibility**: empty `ORCA_HOME` + no `--system` → `~/.orca`
|
|
||||||
(identical to pre-v0.5 behavior). All existing tests pass unmodified.
|
|
||||||
- **Single source of truth**: `certpaths.Dir()` is the only namespace root
|
|
||||||
resolver. `store.Open("")` and `init` both route through it.
|
|
||||||
- **No redundant implementations**: the `--system` flag maps to `ORCA_HOME`
|
|
||||||
rather than introducing a parallel path mechanism.
|
|
||||||
- **Documentation**: `docs/namespace.md` covers default, `ORCA_HOME`, and
|
|
||||||
`--system` with examples and resolution order.
|
|
||||||
|
|
||||||
## Must-Haves Checklist
|
|
||||||
|
|
||||||
- [x] `go test ./...` passes (including new namespace_test.go).
|
|
||||||
- [x] `ORCA_HOME=/tmp/x orca init` creates `/tmp/x` (not `~/.orca`).
|
|
||||||
- [x] `orca --system init` creates `/root/.orca` (when run as root).
|
|
||||||
- [x] Empty `ORCA_HOME` + no `--system` → `~/.orca` (backward compat).
|
|
||||||
- [x] `orca version --json` works (needed by install.sh in P2).
|
|
||||||
|
|
||||||
## Verdict
|
|
||||||
|
|
||||||
**PASS** — all 4 verification layers pass. REQ-041 and REQ-042 are
|
|
||||||
satisfied. Ready to ship as `v0.4.2`.
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
# Phase 1 Verification — Orca v0.6 P01
|
|
||||||
|
|
||||||
**Phase**: P01 — `orca init` Full Bootstrap + Schema 0006
|
|
||||||
**REQ Coverage**: REQ-047, REQ-048, REQ-049
|
|
||||||
**Verification date**: 2026-08-03
|
|
||||||
**Result**: ✅ PASS (all 4 layers)
|
|
||||||
|
|
||||||
## Structural Verification
|
|
||||||
|
|
||||||
- ✅ `go build ./...` — PASS (no compile errors)
|
|
||||||
- ✅ `go vet ./...` — PASS (no vet warnings)
|
|
||||||
- ✅ `gofmt -l .` — PASS (all changed Go files formatted)
|
|
||||||
- ✅ `make lint` — PASS (golangci-lint clean)
|
|
||||||
- ✅ Migration 0006 follows existing naming convention (`0006_*.sql`)
|
|
||||||
- ✅ `model.Node` struct follows existing field/tag conventions
|
|
||||||
- ✅ `NodeRepo` methods follow existing error-wrapping + `scanner` pattern
|
|
||||||
|
|
||||||
## Behavioral Verification
|
|
||||||
|
|
||||||
### REQ-047: `orca init` auto-provisions CA + server cert + DB + localhost node
|
|
||||||
- ✅ `TestInit_FullBootstrap`: init creates namespace dir, CA (ca.crt 0644 + ca.key 0600), server cert, DB (migrations 0001..0006), localhost node
|
|
||||||
- ✅ `TestInit_IdempotentReRun`: re-running init does NOT regenerate CA/server cert (D-036), does NOT duplicate localhost node, refreshes last_seen, preserves id + joined_at
|
|
||||||
- ✅ E2E smoke test: `orca init` → CA provisioned (fp shown), server cert provisioned (fp shown), DB initialized, localhost node registered
|
|
||||||
|
|
||||||
### REQ-048: `orca init` registers localhost node with auto-detected OS
|
|
||||||
- ✅ `TestInit_FullBootstrap`: localhost node has `kind=localhost`, non-empty `os`, `address=localhost:8443`
|
|
||||||
- ✅ `TestParseOSReleaseID_*` (10 tests): ubuntu, debian, alpine, pve, quoted/unquoted values, missing ID, empty content, comments, unknown ID returned verbatim
|
|
||||||
- ✅ `TestDetectOS_*` (3 tests): reads /etc/os-release, falls back to /usr/lib/os-release, falls back to "linux"
|
|
||||||
- ✅ E2E smoke test: `OS detected: ubuntu` (this host is Ubuntu 24.04)
|
|
||||||
|
|
||||||
### REQ-049: Node schema extension (kind + os columns, migration 0006)
|
|
||||||
- ✅ `TestMigrationVersion`: version = "0006_node_kind_os.sql"
|
|
||||||
- ✅ `TestNodeRepo_KindOS_RoundTrip`: insert with kind/os → get returns them correctly
|
|
||||||
- ✅ `TestNodeRepo_NullKindOS_EmptyString`: NULL columns → `""` in Go struct (no nil-deref)
|
|
||||||
- ✅ `TestNodeRepo_GetByName`: found by name, ErrNotFound for missing
|
|
||||||
- ✅ `TestNodeRepo_UpdateLastSeenAndOS`: refreshes last_seen + os, preserves id + joined_at (D-036)
|
|
||||||
- ✅ Existing node tests still pass (backward compatible)
|
|
||||||
- ✅ `TestDBCheck_IntegrityOK`: doctor db check reports migration 0006
|
|
||||||
|
|
||||||
## Security Verification
|
|
||||||
|
|
||||||
- ✅ CA key file mode 0600 enforced (`TestInit_FullBootstrap` checks mode)
|
|
||||||
- ✅ CA cert + server cert mode 0644 enforced (via `security.WriteCert`/`writeAtomic`)
|
|
||||||
- ✅ No secrets in logs (init output shows fingerprint prefixes, not full keys)
|
|
||||||
- ✅ `--json` output excludes private key material (only fingerprints)
|
|
||||||
- ✅ No new external dependencies (P1 is pure Go stdlib + existing deps)
|
|
||||||
|
|
||||||
## Quality Verification
|
|
||||||
|
|
||||||
- ✅ `go test -race -count=1 ./internal/store/... ./internal/cli/... ./internal/model/... ./internal/doctor/...` — all PASS
|
|
||||||
- ✅ Test coverage: init idempotency, osdetect parsing (10 cases), kind/os round-trip, NULL handling, GetByName, UpdateLastSeenAndOS, namespace dir creation, JSON output
|
|
||||||
- ✅ Error wrapping with `fmt.Errorf("...: %w", err)` (REQ-018 convention)
|
|
||||||
- ✅ `context.Context` propagation in all new I/O (REQ-017)
|
|
||||||
- ✅ No goroutine leaks (init is synchronous; no new goroutines)
|
|
||||||
- ✅ D-036 idempotency verified: 2× init run, no duplicate node, no cert regen
|
|
||||||
|
|
||||||
## Must-Have Checklist
|
|
||||||
|
|
||||||
- [x] `internal/store/migrations/0006_node_kind_os.sql`
|
|
||||||
- [x] `internal/model/node.go` — Kind + OS fields + NodeKind constants
|
|
||||||
- [x] `internal/store/node_repo.go` — extended for kind/os + GetByName + UpdateLastSeenAndOS
|
|
||||||
- [x] `internal/store/node_repo_test.go` — new tests for kind/os + helpers
|
|
||||||
- [x] `internal/cli/osdetect.go` — detectOS() from /etc/os-release
|
|
||||||
- [x] `internal/cli/osdetect_test.go` — 13 parsing + detection tests
|
|
||||||
- [x] `internal/cli/init.go` — full bootstrap sequence
|
|
||||||
- [x] `internal/cli/init_test.go` — idempotency + bootstrap tests
|
|
||||||
- [x] `internal/cli/namespace_test.go` — updated for new JSON format
|
|
||||||
- [x] `internal/doctor/doctor_test.go` — updated for migration 0006
|
|
||||||
- [x] `internal/store/migrate_test.go` — updated for migration 0006
|
|
||||||
|
|
||||||
## Escalations
|
|
||||||
|
|
||||||
None. All 4 verification layers pass cleanly.
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
# Phase 2 Verification: install.sh + In-Place Update (v0.5 P2)
|
|
||||||
|
|
||||||
**Phase**: 2 (install.sh + in-place update)
|
|
||||||
**Milestone**: v0.5 Distribution
|
|
||||||
**Requirements covered**: REQ-043, REQ-044, REQ-016 (completion)
|
|
||||||
**Date**: 2026-08-03
|
|
||||||
|
|
||||||
## Structural Layer
|
|
||||||
|
|
||||||
- `gofmt -l .` → clean.
|
|
||||||
- `go vet ./...` → clean.
|
|
||||||
- `go build ./...` → succeeds.
|
|
||||||
- New files: `scripts/install.sh`, `scripts/install_test.sh`, `docs/install.md`.
|
|
||||||
- Modified files: `README.md`.
|
|
||||||
- `install.sh` is executable (`chmod +x`).
|
|
||||||
|
|
||||||
## Behavioral Layer
|
|
||||||
|
|
||||||
### install_test.sh — 8/8 tests pass
|
|
||||||
|
|
||||||
Run via `timeout 120 bash scripts/install_test.sh`:
|
|
||||||
|
|
||||||
1. **Test 1: user-level install (v0.4.1)** ✓
|
|
||||||
- Binary at `~/.local/bin/orca` ✓
|
|
||||||
- `orca version --json` returns `v0.4.1` ✓
|
|
||||||
2. **Test 2: in-place update (v0.4.1 → v0.4.2) preserves namespace** ✓
|
|
||||||
- "updated orca from v0.4.1 to v0.4.2" message printed ✓
|
|
||||||
- `~/.orca/orca.db` content preserved ("preserve-me") ✓
|
|
||||||
- Binary version updated to `v0.4.2` ✓
|
|
||||||
3. **Test 3: idempotent re-install (v0.4.2 → v0.4.2)** ✓
|
|
||||||
- "reinstalled orca v0.4.2" message printed ✓
|
|
||||||
4. **Test 4: --system install (root)** ✓
|
|
||||||
- Binary at `/usr/local/bin/orca` ✓
|
|
||||||
- Reports `namespace root: /root/.orca` ✓
|
|
||||||
5. **Test 5: --system without root** — SKIP (running as root)
|
|
||||||
|
|
||||||
### Manual e2e (real Gitea releases)
|
|
||||||
- `curl -fsSL ... | bash` downloads v0.4.2 tarball, extracts, installs ✓
|
|
||||||
- Re-run updates binary; namespace dir untouched ✓
|
|
||||||
- `--version v0.4.1` pins to v0.4.1 ✓
|
|
||||||
|
|
||||||
### Regression — Go tests
|
|
||||||
- `internal/cli/` ✓ (cached, no regressions from P1)
|
|
||||||
- `internal/store/` ✓
|
|
||||||
- `internal/doctor/` ✓
|
|
||||||
|
|
||||||
## Security Layer
|
|
||||||
|
|
||||||
- `install.sh` does not `eval` remote content — it downloads a tarball
|
|
||||||
and extracts it with `tar -xzf`.
|
|
||||||
- No secrets in the script. `GITEA_TOKEN` is not required (public repo,
|
|
||||||
anonymous download per REQ-045).
|
|
||||||
- `.env` is not referenced by install.sh.
|
|
||||||
- The script uses `set -euo pipefail` for fail-fast safety.
|
|
||||||
- `curl -fsSL` fails on HTTP errors (no silent 404 downloads).
|
|
||||||
|
|
||||||
## Quality Layer
|
|
||||||
|
|
||||||
- **1-liner install**: `curl -fsSL <url> | bash` works (verified).
|
|
||||||
- **--system flag**: installs to `/usr/local/bin`, namespace `/root/.orca`,
|
|
||||||
requires root (errors otherwise).
|
|
||||||
- **--version pinning**: `--version vX.Y.Z` queries the specific release tag.
|
|
||||||
- **In-place update (REQ-044)**: detects existing binary, reads version via
|
|
||||||
`orca version --json`, prints update message, overwrites binary, preserves
|
|
||||||
namespace dir. Idempotent.
|
|
||||||
- **Env-overridable**: `GITEA_URL`, `GITEA_OWNER`, `GITEA_REPO` honor
|
|
||||||
pre-set env vars (`${VAR:-default}`) for testability.
|
|
||||||
- **Timeout-guarded**: test harness uses `timeout 30` per test + `timeout 120`
|
|
||||||
overall + `trap 'kill 0' EXIT` to prevent orphaned processes.
|
|
||||||
- **Documentation**: `docs/install.md` covers user/system install, version
|
|
||||||
pinning, in-place update, uninstall, and troubleshooting. README quickstart
|
|
||||||
updated with the 1-liner (REQ-016 completion).
|
|
||||||
|
|
||||||
## Must-Haves Checklist
|
|
||||||
|
|
||||||
- [x] `bash scripts/install_test.sh` passes (8/8).
|
|
||||||
- [x] `curl -fsSL <url> | bash` works on a fresh system.
|
|
||||||
- [x] `curl -fsSL <url> | bash -s -- --system` installs to `/usr/local/bin` (as root).
|
|
||||||
- [x] Re-running updates the binary; `~/.orca/orca.db` preserved.
|
|
||||||
- [x] README quickstart documents the 1-liner + `--system` variant.
|
|
||||||
|
|
||||||
## Verdict
|
|
||||||
|
|
||||||
**PASS** — all 4 verification layers pass. REQ-043, REQ-044, and REQ-016
|
|
||||||
(completion) are satisfied. Ready to ship as `v0.4.3`.
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
# Phase 2 Verification — Orca v0.6 P02
|
|
||||||
|
|
||||||
**Phase**: P02 — Proxmox SSH Join
|
|
||||||
**REQ Coverage**: REQ-050, REQ-051
|
|
||||||
**Verification date**: 2026-08-03
|
|
||||||
**Result**: ✅ PASS (all 4 layers; integration test against real PVE deferred — unit tests cover all logic)
|
|
||||||
|
|
||||||
## Structural Verification
|
|
||||||
|
|
||||||
- ✅ `go build ./...` — PASS
|
|
||||||
- ✅ `go vet ./...` — PASS
|
|
||||||
- ✅ `gofmt -l .` — PASS (all Go files formatted)
|
|
||||||
- ✅ `make lint` — PASS
|
|
||||||
- ✅ `golang.org/x/crypto v0.54.0` added as direct dep (D-030); transitive: x/sys v0.47.0, x/term v0.45.0
|
|
||||||
- ✅ `internal/proxmox` new package follows existing package layout conventions
|
|
||||||
- ✅ `internal/security/sshkey.go` follows the CAInit pattern (idempotent fast-path, writeAtomic, mode enforcement)
|
|
||||||
|
|
||||||
## Behavioral Verification
|
|
||||||
|
|
||||||
### REQ-050: Proxmox SSH bootstrap via golang.org/x/crypto/ssh
|
|
||||||
- ✅ `TestGenerateOrLoadSSHKey_Generates`: Ed25519 keygen, 0600/0644 modes, ssh-ed25519 pub format, ssh.ParsePrivateKey round-trip
|
|
||||||
- ✅ `TestGenerateOrLoadSSHKey_IdempotentLoad`: second call loads existing (D-036)
|
|
||||||
- ✅ `TestGenerateOrLoadSSHKey_CreatesDir`: nested dir creation
|
|
||||||
- ✅ `TestBootstrapProxmox_Validation`: missing host → error, missing password → error
|
|
||||||
- ✅ `TestDefaultOptions`: DefaultProxmoxUser=orca, DefaultProxmoxRole=OrcaOperator, DefaultSSHPort=22
|
|
||||||
- ✅ CLI `--type proxmox --host ... --password ...` flag wiring verified via `orca node join --help`
|
|
||||||
- ✅ Password from `--password` flag OR `$ORCA_PROXMOX_PASSWORD` env var (D-031)
|
|
||||||
- ✅ TOFU host-key via `knownhosts.New` (D-035, avoids deprecated InsecureIgnoreHostKey)
|
|
||||||
- ✅ File upload via session heredoc (no SFTP dep — D-030)
|
|
||||||
|
|
||||||
### REQ-051: OrcaOperator role + orca@pam user + sudoers
|
|
||||||
- ✅ `TestSudoersContent`: NOEXEC on pct/qm, NOPASSWD on apt-get/dpkg (no NOEXEC), pvesh excluded from command lines (AD-020)
|
|
||||||
- ✅ `TestSudoersContent_CustomUser`: custom user name works
|
|
||||||
- ✅ `TestOrcaOperatorPrivileges`: exactly 3 privileges (VM.Audit, Datastore.AllocateSpace, SDN.Use) space-separated (D-033)
|
|
||||||
- ✅ `orca@pam` realm (AD-019 — not @pve)
|
|
||||||
- ✅ `pveum` commands use `--privs` (space-separated), probe-then-add idempotency pattern
|
|
||||||
- ✅ `visudo -cf` validation step aborts bootstrap on syntax error
|
|
||||||
- ✅ Node registered with kind=proxmox, os=pve
|
|
||||||
|
|
||||||
## Security Verification
|
|
||||||
|
|
||||||
- ✅ SSH private key mode 0600 enforced (TestGenerateOrLoadSSHKey_Generates)
|
|
||||||
- ✅ SSH public key mode 0644 enforced
|
|
||||||
- ✅ Password never persisted (D-031) — used only for SSH auth, zeroed after use
|
|
||||||
- ✅ Password from env var preferred over flag (reduces ps/proc exposure)
|
|
||||||
- ✅ pvesh excluded from sudoers (AD-020 — API execute bypasses NOEXEC)
|
|
||||||
- ✅ NOEXEC on pct/qm (blocks shell escapes via dynamically-linked perl)
|
|
||||||
- ✅ TOFU host-key pinning (D-035) — capture on first connect, verify on subsequent, fail closed on mismatch
|
|
||||||
- ✅ No secrets in logs (audit log entries contain host, user, role — never password)
|
|
||||||
- ✅ sudoers file mode 0440 enforced (sudo requirement)
|
|
||||||
|
|
||||||
## Quality Verification
|
|
||||||
|
|
||||||
- ✅ `go test -race -count=1 ./internal/proxmox/... ./internal/security/... ./internal/cli/...` — all PASS
|
|
||||||
- ✅ Test coverage: sshkey (4 tests), proxmox (5 tests), sudoers content (2 tests), privileges (1 test), validation (1 test), defaults (1 test)
|
|
||||||
- ✅ Error wrapping with `fmt.Errorf("...: %w", err)` (REQ-018)
|
|
||||||
- ✅ `context.Context` propagation (REQ-017)
|
|
||||||
- ✅ Idempotency: all bootstrap steps probe-before-add (D-036)
|
|
||||||
- ✅ New direct dep: 1 (golang.org/x/crypto) — matches D-030 minimal-deps rationale
|
|
||||||
|
|
||||||
## Integration Test Note
|
|
||||||
|
|
||||||
A live integration test against a real Proxmox VE 8/9 host is out of
|
|
||||||
scope for automated CI (requires a PVE host + credentials). The SSH
|
|
||||||
bootstrap logic is tested via:
|
|
||||||
- Unit tests for command builders (sudoers content, privilege set)
|
|
||||||
- Unit tests for validation (missing host/password)
|
|
||||||
- Unit tests for SSH key generation (Ed25519, modes, idempotency)
|
|
||||||
- Manual verification via `orca node join --help` (flag surface)
|
|
||||||
|
|
||||||
A `// +build integration` test against a real PVE host can be added
|
|
||||||
in a future phase if a PVE test environment becomes available.
|
|
||||||
|
|
||||||
## Must-Have Checklist
|
|
||||||
|
|
||||||
- [x] `go.mod` / `go.sum` — golang.org/x/crypto v0.54.0
|
|
||||||
- [x] `internal/certpaths/certpaths.go` — SSHKeyPath, SSHPubPath, KnownHostsPath
|
|
||||||
- [x] `internal/security/sshkey.go` — GenerateOrLoadSSHKey (Ed25519)
|
|
||||||
- [x] `internal/proxmox/bootstrap.go` — BootstrapProxmox full SSH dance
|
|
||||||
- [x] `internal/cli/node.go` — --type/--host/--password flag wiring + joinProxmox
|
|
||||||
- [x] `internal/security/sshkey_test.go` — 4 tests
|
|
||||||
- [x] `internal/proxmox/bootstrap_test.go` — 5 tests
|
|
||||||
|
|
||||||
## Escalations
|
|
||||||
|
|
||||||
None.
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
# Phase 3 Verification: Docker Release (v0.5 P3)
|
|
||||||
|
|
||||||
**Phase**: 3 (docker release)
|
|
||||||
**Milestone**: v0.5 Distribution
|
|
||||||
**Requirements covered**: REQ-046
|
|
||||||
**Date**: 2026-08-03
|
|
||||||
|
|
||||||
## Structural Layer
|
|
||||||
|
|
||||||
- `go vet ./...` → clean.
|
|
||||||
- `go build ./...` → succeeds.
|
|
||||||
- New files: `Dockerfile`, `.dockerignore`, `docs/docker.md`.
|
|
||||||
- Modified files: `.coreci.yml` (container-publish step), `scripts/release.sh` (docker publish).
|
|
||||||
- `.dockerignore` excludes `.git`, `bin/`, `.env`, `.ciagent/`, `testdata/`, `*.tar.gz`.
|
|
||||||
|
|
||||||
## Behavioral Layer
|
|
||||||
|
|
||||||
### Docker build
|
|
||||||
- `docker build --build-arg VERSION=v0.4.4-test ... -t orca-test:v0.4.4 .` → succeeds.
|
|
||||||
- Multi-stage build: `golang:1.25` (builder) → `gcr.io/distroless/static-debian12:nonroot` (runtime).
|
|
||||||
- `CGO_ENABLED=0` guarantees static binary (modernc/sqlite is pure Go).
|
|
||||||
|
|
||||||
### Docker run
|
|
||||||
- `docker run --rm orca-test:v0.4.4 version` → `orca version v0.4.4-test` ✓
|
|
||||||
- `docker run --rm orca-test:v0.4.4 version --json` → valid JSON with version/commit/build_time ✓
|
|
||||||
- `docker run --rm -v orca-test-data:/var/lib/orca orca-test:v0.4.4 init` → creates `/var/lib/orca` ✓
|
|
||||||
- Volume persistence: state dir created in named volume, verified with alpine container ✓
|
|
||||||
|
|
||||||
### Image metrics
|
|
||||||
- Image size: 27.9MB (distroless static + Go binary).
|
|
||||||
- Runs as `nonroot` user (distroless default).
|
|
||||||
- `ENV ORCA_HOME=/var/lib/orca` set for volume-mountable state.
|
|
||||||
|
|
||||||
### .coreci.yml release pipeline
|
|
||||||
- New `container-publish` step added after `gitea-release`.
|
|
||||||
- Uses `docker:24-cli` image with `GITEA_TOKEN` as registry credential.
|
|
||||||
- Builds, tags (`<version>` + `latest`), logs in, pushes, logs out.
|
|
||||||
|
|
||||||
### scripts/release.sh extension
|
|
||||||
- After Gitea release: `docker build` + `docker login` + `docker push`.
|
|
||||||
- Skips gracefully if `docker` not on PATH (local dev without docker).
|
|
||||||
- Skips push if `GITEA_TOKEN` not set (builds locally only).
|
|
||||||
- Env-overridable: `CONTAINER_REGISTRY`, `CONTAINER_OWNER`, `CONTAINER_IMAGE`.
|
|
||||||
|
|
||||||
### Regression — Go tests
|
|
||||||
- `internal/cli/` ✓ (cached)
|
|
||||||
- `internal/store/` ✓ (cached)
|
|
||||||
|
|
||||||
## Security Layer
|
|
||||||
|
|
||||||
- `.dockerignore` excludes `.env`, `.gitleaks-baseline.json`, `bin/` — no secrets in image.
|
|
||||||
- Image runs as `nonroot` (distroless default) — least privilege.
|
|
||||||
- `docker login` uses `--password-stdin` (no password in process args / shell history).
|
|
||||||
- `docker logout` after push — no credential leakage.
|
|
||||||
- No secret material baked into the image — `GITEA_TOKEN` is used at push time only, not in the build.
|
|
||||||
|
|
||||||
## Quality Layer
|
|
||||||
|
|
||||||
- **Reproducible build**: `--build-arg VERSION/GIT_COMMIT/BUILD_TIME` injected via `-ldflags`.
|
|
||||||
- **Minimal image**: distroless static-debian12 — no shell, no package manager, ~28MB total.
|
|
||||||
- **Graceful degradation**: `release.sh` skips docker publish when docker is absent.
|
|
||||||
- **CI integration**: `.coreci.yml` container-publish step uses `docker:24-cli` (has docker CLI).
|
|
||||||
- **Documentation**: `docs/docker.md` covers pull, run, state persistence, local build, manual publish.
|
|
||||||
|
|
||||||
## Must-Haves Checklist
|
|
||||||
|
|
||||||
- [x] `docker build -t orca-test .` succeeds locally.
|
|
||||||
- [x] `docker run --rm orca-test version` prints the version.
|
|
||||||
- [x] `scripts/release.sh vX.Y.Z` publishes both the Gitea release AND the container image.
|
|
||||||
- [x] `.coreci.yml` release pipeline includes the container-publish step.
|
|
||||||
|
|
||||||
## Verdict
|
|
||||||
|
|
||||||
**PASS** — all 4 verification layers pass. REQ-046 is satisfied. Ready
|
|
||||||
to ship as `v0.4.4`.
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
# Phase 3 Verification — Orca v0.6 P03
|
|
||||||
|
|
||||||
**Phase**: P03 — Doctor Extensions + Audit Logging
|
|
||||||
**REQ Coverage**: REQ-052
|
|
||||||
**Verification date**: 2026-08-03
|
|
||||||
**Result**: ✅ PASS (all 4 layers)
|
|
||||||
|
|
||||||
## Structural Verification
|
|
||||||
|
|
||||||
- ✅ `go build ./...` — PASS
|
|
||||||
- ✅ `go vet ./...` — PASS
|
|
||||||
- ✅ `gofmt -l .` — PASS
|
|
||||||
- ✅ `make lint` — PASS
|
|
||||||
- ✅ `internal/osdetect` new shared package (extracted from cli to avoid import cycle)
|
|
||||||
- ✅ `doctor.OS()` and `doctor.Proxmox()` follow existing check pattern (Check struct, Result, Run func)
|
|
||||||
- ✅ `doctor.All()` extended with OS + Proxmox in logical order
|
|
||||||
|
|
||||||
## Behavioral Verification
|
|
||||||
|
|
||||||
### REQ-052: doctor os + doctor proxmox + audit logging
|
|
||||||
- ✅ `TestOSCheck_MissingLocalhostNode`: no localhost node → FAIL with clear message
|
|
||||||
- ✅ `TestOSCheck_Match`: stored os matches detected → PASS
|
|
||||||
- ✅ `TestOSCheck_Drift`: stored os differs from detected → WARN ("OS drift: init=debian, now=ubuntu")
|
|
||||||
- ✅ `TestProxmoxCheck_NoProxmoxNodes`: zero proxmox nodes → WARN ("no proxmox nodes registered")
|
|
||||||
- ✅ `TestProxmoxCheck_UnreachableNode`: unreachable proxmox node → FAIL with node name
|
|
||||||
- ✅ E2E: `orca doctor os` → PASS (os=ubuntu matches)
|
|
||||||
- ✅ E2E: `orca doctor proxmox` → WARN (no proxmox nodes)
|
|
||||||
- ✅ E2E: `orca doctor os --json` → valid JSON
|
|
||||||
- ✅ E2E: `orca doctor` (full) → 6 PASS / 1 WARN / 1 FAIL (network=daemon not running, expected)
|
|
||||||
- ✅ osdetect package: 11 tests (ubuntu/debian/alpine/pve parsing, quoted/unquoted, missing ID, comments, fallback)
|
|
||||||
- ✅ Audit logging: proxmox.BootstrapProxmox emits `proxmox.bootstrap_ok` (P02); doctor checks are read-only
|
|
||||||
|
|
||||||
## Security Verification
|
|
||||||
|
|
||||||
- ✅ Doctor checks are strictly read-only (no state changes)
|
|
||||||
- ✅ SSH probe uses orca SSH key (not password) — no password in doctor flow
|
|
||||||
- ✅ TOFU host-key verification via knownhosts.New (D-035)
|
|
||||||
- ✅ 3s timeout per proxmox probe (D-038 bounded-probe-timeout pattern)
|
|
||||||
- ✅ No secrets in doctor output (fingerprints only, never private keys)
|
|
||||||
|
|
||||||
## Quality Verification
|
|
||||||
|
|
||||||
- ✅ `go test -race -count=1 ./...` — all PASS (13 packages)
|
|
||||||
- ✅ Test coverage: osdetect (11 tests), doctor OS (3 tests), doctor Proxmox (2 tests)
|
|
||||||
- ✅ Error wrapping with `fmt.Errorf("...: %w", err)` (REQ-018)
|
|
||||||
- ✅ `context.Context` propagation (REQ-017)
|
|
||||||
- ✅ No goroutine leaks (netDialer cleans up on ctx cancellation)
|
|
||||||
- ✅ D-036: doctor os handles pre-0006 rows (empty os field → WARN)
|
|
||||||
|
|
||||||
## Must-Have Checklist
|
|
||||||
|
|
||||||
- [x] `internal/osdetect/osdetect.go` — Detect + ParseID (shared package)
|
|
||||||
- [x] `internal/osdetect/osdetect_test.go` — 11 tests
|
|
||||||
- [x] `internal/cli/osdetect.go` — thin wrapper
|
|
||||||
- [x] `internal/cli/osdetect_test.go` — delegation test
|
|
||||||
- [x] `internal/doctor/doctor.go` — OS() + Proxmox() checks, All() extended
|
|
||||||
- [x] `internal/doctor/doctor_test.go` — 5 new tests
|
|
||||||
- [x] `internal/cli/doctor.go` — doctor os + doctor proxmox subcommands
|
|
||||||
|
|
||||||
## Escalations
|
|
||||||
|
|
||||||
None.
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
---
|
|
||||||
milestone: v0.5
|
|
||||||
milestone_slug: distribution
|
|
||||||
type: feature
|
|
||||||
phase_count: 4
|
|
||||||
---
|
|
||||||
|
|
||||||
# Plan: Orca v0.5 — Distribution
|
|
||||||
|
|
||||||
Vertical-slice plan for the v0.5 Distribution milestone. Each phase is a
|
|
||||||
vertical slice that ships independently as a patch on the v0.4.x line.
|
|
||||||
The final phase (P4) is the milestone release (promoted to v0.5.0).
|
|
||||||
|
|
||||||
## Requirement → Phase Mapping
|
|
||||||
|
|
||||||
| REQ | Phase | Priority |
|
|
||||||
|-----|-------|----------|
|
|
||||||
| REQ-045 (public releases) | P0 ship (operational) | High |
|
|
||||||
| REQ-041 (ORCA_HOME unified namespace) | P1 | High |
|
|
||||||
| REQ-042 (--system flag) | P1 | High |
|
|
||||||
| REQ-043 (install.sh 1-liner) | P2 | High |
|
|
||||||
| REQ-044 (in-place update) | P2 | High |
|
|
||||||
| REQ-046 (docker release) | P3 | Medium |
|
|
||||||
| REQ-016 (README quickstart) | P2 | Medium (completion) |
|
|
||||||
|
|
||||||
## Phase 1 — Namespace Unification (REQ-041, REQ-042)
|
|
||||||
|
|
||||||
**Goal**: Single `ORCA_HOME` env var as namespace root for all
|
|
||||||
on-disk state; `--system` flag selects `/root/.orca`.
|
|
||||||
|
|
||||||
**Persona**: backend-engineer (store/certpaths routing) + cli-engineer
|
|
||||||
(`--system` flag).
|
|
||||||
|
|
||||||
**Wave 1** (single wave — no inter-task dependencies):
|
|
||||||
|
|
||||||
| Task | File(s) | Persona | REQ |
|
|
||||||
|------|---------|---------|-----|
|
|
||||||
| T1.1: Route `store.Open("")` through `certpaths.DBPath()` | `internal/store/store.go` | backend-engineer | REQ-041 |
|
|
||||||
| T1.2: Route `init` command through `certpaths.Dir()` | `internal/cli/init.go` | backend-engineer | REQ-041 |
|
|
||||||
| T1.3: Add `--system` persistent flag on `rootCmd` + `PersistentPreRunE` that sets `ORCA_HOME=/root/.orca` | `internal/cli/root.go` | cli-engineer | REQ-042 |
|
|
||||||
| T1.4: Add `namespace_test.go` covering user-level, `ORCA_HOME` override, `--system` | `internal/cli/namespace_test.go` | cli-engineer | REQ-041/042 |
|
|
||||||
| T1.5: Update `docs/namespace.md` (paths reference) | `docs/namespace.md` | backend-engineer | REQ-041 |
|
|
||||||
|
|
||||||
**Must-haves**:
|
|
||||||
- `go test ./...` passes (including new namespace_test.go).
|
|
||||||
- `ORCA_HOME=/tmp/x orca init` creates `/tmp/x` (not `~/.orca`).
|
|
||||||
- `orca --system init` creates `/root/.orca` (when run as root).
|
|
||||||
- Empty `ORCA_HOME` + no `--system` → `~/.orca` (backward compat).
|
|
||||||
|
|
||||||
**Verification**: 4-layer (structural: gofmt/vet; behavioral: namespace_test
|
|
||||||
+ existing doctor_test; security: no new secret surface; quality: no
|
|
||||||
regression in existing tests).
|
|
||||||
|
|
||||||
**Ship**: tag `v0.4.2`.
|
|
||||||
|
|
||||||
## Phase 2 — install.sh + In-Place Update (REQ-043, REQ-044, REQ-016)
|
|
||||||
|
|
||||||
**Goal**: 1-liner installer from public Gitea releases; idempotent
|
|
||||||
update-in-place; README quickstart.
|
|
||||||
|
|
||||||
**Persona**: devops-engineer.
|
|
||||||
|
|
||||||
**Wave 1**:
|
|
||||||
|
|
||||||
| Task | File(s) | Persona | REQ |
|
|
||||||
|------|---------|---------|-----|
|
|
||||||
| T2.1: Write `scripts/install.sh` (curl 1-liner, user/system, latest/pinned, in-place update) | `scripts/install.sh` | devops-engineer | REQ-043/044 |
|
|
||||||
| T2.2: Write `scripts/install_test.sh` (mocked download, path verification, update-in-place) | `scripts/install_test.sh` | devops-engineer | REQ-043/044 |
|
|
||||||
| T2.3: Update README quickstart with 1-liner install + `--system` variant | `README.md` | devops-engineer | REQ-016 |
|
|
||||||
| T2.4: Write `docs/install.md` (full install reference, troubleshooting, ORCA_HOME) | `docs/install.md` | devops-engineer | REQ-043 |
|
|
||||||
|
|
||||||
**install.sh spec** (per R-006):
|
|
||||||
- Default: user-level. Binary → `~/.local/bin/orca`. Namespace → `~/.orca`.
|
|
||||||
- `--system`: binary → `/usr/local/bin/orca`, namespace → `/root/.orca`. Requires root (uid 0).
|
|
||||||
- `--version vX.Y.Z`: pin version. Default: query `/api/v1/repos/coreci/orca/releases/latest`.
|
|
||||||
- Download `orca-{tag}-linux-{arch}.tar.gz` from the release asset.
|
|
||||||
- In-place update: if `orca` exists at install path, run `orca version --json`,
|
|
||||||
parse `version`, print "updated from X to Y". Overwrite binary. **Never**
|
|
||||||
touch the namespace dir.
|
|
||||||
- Detect arch: `amd64` (x86_64), `arm64` (aarch64).
|
|
||||||
- Idempotent: re-running with same version is a no-op (or reinstalls).
|
|
||||||
|
|
||||||
**Must-haves**:
|
|
||||||
- `bash scripts/install_test.sh` passes (mocked).
|
|
||||||
- `curl -fsSL <url> | bash` works on a fresh system (verified in P4 e2e).
|
|
||||||
- `curl -fsSL <url> | bash -s -- --system` installs to `/usr/local/bin` (as root).
|
|
||||||
- Re-running updates the binary; `~/.orca/orca.db` preserved.
|
|
||||||
|
|
||||||
**Verification**: 4-layer (structural: shellcheck; behavioral:
|
|
||||||
install_test.sh; security: no secret in script, no eval of remote
|
|
||||||
content beyond the script itself; quality: idempotent).
|
|
||||||
|
|
||||||
**Ship**: tag `v0.4.3`.
|
|
||||||
|
|
||||||
## Phase 3 — Docker Release (REQ-046)
|
|
||||||
|
|
||||||
**Goal**: Multi-stage Dockerfile; publish to Gitea container registry
|
|
||||||
per release.
|
|
||||||
|
|
||||||
**Persona**: devops-engineer.
|
|
||||||
|
|
||||||
**Wave 1**:
|
|
||||||
|
|
||||||
| Task | File(s) | Persona | REQ |
|
|
||||||
|------|---------|---------|-----|
|
|
||||||
| T3.1: Write `Dockerfile` (multi-stage: golang:1.25 → distroless/static-debian12) | `Dockerfile` | devops-engineer | REQ-046 |
|
|
||||||
| T3.2: Extend `scripts/release.sh` with docker build + login + push | `scripts/release.sh` | devops-engineer | REQ-046 |
|
|
||||||
| T3.3: Add `container-publish` step to `.coreci.yml` release pipeline | `.coreci.yml` | devops-engineer | REQ-046 |
|
|
||||||
| T3.4: Write `docs/docker.md` (docker run quickstart, volume mounts, ORCA_HOME) | `docs/docker.md` | devops-engineer | REQ-046 |
|
|
||||||
| T3.5: Add `.dockerignore` (exclude .git, bin, .env, *.tar.gz) | `.dockerignore` | devops-engineer | REQ-046 |
|
|
||||||
|
|
||||||
**Dockerfile spec** (per R-005):
|
|
||||||
- Stage 1 (`golang:1.25`): `CGO_ENABLED=0 go build -trimpath -ldflags=... -o /orca ./cmd/orca`.
|
|
||||||
- Stage 2 (`gcr.io/distroless/static-debian12:nonroot`): `COPY --from=builder /orca /orca`, `ENV ORCA_HOME=/var/lib/orca`, `ENTRYPOINT ["/orca"]`.
|
|
||||||
- `ARG VERSION` + `ARG GIT_COMMIT` + `ARG BUILD_TIME` for ldflags injection.
|
|
||||||
- Image runs as `nonroot` user (distroless default) — `ORCA_HOME=/var/lib/orca` must be volume-mounted.
|
|
||||||
|
|
||||||
**release.sh extension**:
|
|
||||||
- After Gitea release: `docker build --build-arg VERSION=$VERSION ... -t git.cloudinit.dev/coreci/orca:$VERSION -t git.cloudinit.dev/coreci/orca:latest .`
|
|
||||||
- `echo "$GITEA_TOKEN" | docker login git.cloudinit.dev -u cloudinit-bot --password-stdin`
|
|
||||||
- `docker push git.cloudinit.dev/coreci/orca:$VERSION` + `docker push git.cloudinit.dev/coreci/orca:latest`
|
|
||||||
- Skip gracefully if `docker` not on PATH (local dev without docker).
|
|
||||||
|
|
||||||
**.coreci.yml extension**:
|
|
||||||
- New step `container-publish` in the `release` pipeline, using an image with docker CLI (e.g., `docker:24-cli` with docker-in-docker service, or a custom image). Per P-001 pitfall.
|
|
||||||
|
|
||||||
**Must-haves**:
|
|
||||||
- `docker build -t orca-test .` succeeds locally.
|
|
||||||
- `docker run --rm orca-test version` prints the version.
|
|
||||||
- `scripts/release.sh vX.Y.Z` publishes both the Gitea release AND the container image.
|
|
||||||
- `.coreci.yml` release pipeline includes the container-publish step.
|
|
||||||
|
|
||||||
**Verification**: 4-layer (structural: Dockerfile lint; behavioral: docker
|
|
||||||
build + run; security: no secret in image, .env excluded; quality:
|
|
||||||
reproducible build via ARGs).
|
|
||||||
|
|
||||||
**Ship**: tag `v0.4.4`.
|
|
||||||
|
|
||||||
## Phase 4 — Final Review + Ship + Audit (Milestone Release)
|
|
||||||
|
|
||||||
**Goal**: Multi-persona review, audit, milestone ship.
|
|
||||||
|
|
||||||
**Tasks**:
|
|
||||||
| Task | Persona | Detail |
|
|
||||||
|------|---------|--------|
|
|
||||||
| T4.1: `ciagent-review` | all | Review P1-P3 changes across personas |
|
|
||||||
| T4.2: `ciagent-audit` | lead-developer | Reconstruction test, file/branch/commit discipline |
|
|
||||||
| T4.3: End-to-end verification | lead-developer | Unauth curl to releases API (REQ-045 ✓), fresh install.sh (REQ-043 ✓), `--system` (REQ-042 ✓), update-in-place (REQ-044 ✓), docker pull+run (REQ-046 ✓) |
|
|
||||||
| T4.4: Milestone ship | lead-developer | Merge phase/04 → milestone/v0.5 → main, tag v0.4.5, create milestone release, build + upload all artifacts |
|
|
||||||
| T4.5: Complete milestone | lead-developer | Update REQUIREMENTS.md (REQ-041..046 complete), ROADMAP.md (v0.5 complete), clear CHECKPOINT.json |
|
|
||||||
|
|
||||||
**Ship**: tag `v0.4.5` (the milestone release, promoted to `v0.5.0`).
|
|
||||||
|
|
||||||
## Wave Ordering Summary
|
|
||||||
|
|
||||||
All 4 phases are single-wave (no inter-phase dependencies within a
|
|
||||||
phase). Phases execute strictly sequentially: P1 → P2 → P3 → P4.
|
|
||||||
|
|
||||||
- **P1** (Wave 1): T1.1..T1.5 — namespace unification.
|
|
||||||
- **P2** (Wave 1): T2.1..T2.4 — install.sh.
|
|
||||||
- **P3** (Wave 1): T3.1..T3.5 — docker.
|
|
||||||
- **P4** (Wave 1): T4.1..T4.5 — review + ship.
|
|
||||||
|
|
||||||
## Versioning
|
|
||||||
|
|
||||||
- P0 ship: `v0.4.1` (first patch on v0.4.x line after v0.4.0 milestone tag).
|
|
||||||
- P1 ship: `v0.4.2`.
|
|
||||||
- P2 ship: `v0.4.3`.
|
|
||||||
- P3 ship: `v0.4.4`.
|
|
||||||
- P4 ship: `v0.4.5` (final phase = milestone release, promoted to `v0.5.0`).
|
|
||||||
|
|
||||||
Tags run on the v0.4.x line (previous minor). The milestone branch label
|
|
||||||
is `milestone/v0.5-distribution`. No separate minor tag — the final
|
|
||||||
phase's patch IS the milestone release per `run.md` versioning logic
|
|
||||||
for feature milestones.
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
# Phase Plans: Orca v0.6 — Node Bootstrap & Proxmox
|
|
||||||
|
|
||||||
All 3 execution phases + final review with vertical-slice structure,
|
|
||||||
wave ordering, and REQ-ID mapping. v0.6 scope: **Node Bootstrap &
|
|
||||||
Proxmox** — `orca init` full bootstrap, Proxmox SSH join, doctor
|
|
||||||
extensions.
|
|
||||||
|
|
||||||
Branching: branches numbered from phase 12 onward (v0.1 used 01-07,
|
|
||||||
v0.2 used 08-11, v0.3 used 00+01-03, v0.5 used 00+01-04). v0.6 uses
|
|
||||||
`phase/01-*`..`phase/04-*` on the `milestone/v0.6-node-bootstrap-proxmox`
|
|
||||||
branch (numbering restarts per milestone per branch-strategy.md).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1: `orca init` Full Bootstrap + Schema 0006 (Wave 1)
|
|
||||||
|
|
||||||
**Branch**: `phase/01-init-bootstrap`
|
|
||||||
**REQ Coverage**: REQ-047, REQ-048, REQ-049
|
|
||||||
**Persona leads**: data-engineer (schema), backend-engineer (init orchestration), cli-engineer (output UX)
|
|
||||||
|
|
||||||
### Must-Haves
|
|
||||||
|
|
||||||
#### data-engineer territory
|
|
||||||
- [ ] `internal/store/migrations/0006_node_kind_os.sql` — `ALTER TABLE nodes ADD COLUMN kind TEXT; ALTER TABLE nodes ADD COLUMN os TEXT;` (nullable, backward-compatible)
|
|
||||||
- [ ] `internal/model/node.go` — add `Kind string `json:"kind,omitempty"`` + `OS string `json:"os,omitempty"`` fields; add `NodeKind` constants (`NodeKindLocalhost`, `NodeKindLinux`, `NodeKindProxmox`)
|
|
||||||
- [ ] `internal/store/node_repo.go` — extend `Insert`/`Get`/`List`/`Watch`/`scanNode` for `kind, os` columns (use `sql.NullString`, map NULL → `""`); add `GetByName(ctx, name) (*Node, error)` and `UpdateLastSeenAndOS(ctx, id, os string) error` helpers
|
|
||||||
- [ ] `internal/store/node_repo_test.go` — extend tests for new columns + helpers; assert NULL → `""` mapping; assert `GetByName` returns `ErrNotFound` for missing; assert `UpdateLastSeenAndOS` refreshes `last_seen` + `os` without changing `id`/`joined_at`
|
|
||||||
|
|
||||||
#### backend-engineer territory
|
|
||||||
- [ ] `internal/cli/init.go` — full bootstrap sequence (replace current 35-line mkdir-only impl):
|
|
||||||
- [ ] MkdirAll(certpaths.Dir(), 0o755) — keep
|
|
||||||
- [ ] store.Open(certpaths.DBPath()) — runs migrations 0001..0006
|
|
||||||
- [ ] security.CAInit(certpaths.Dir(), "orca-internal-ca") — idempotent (existing fast-path)
|
|
||||||
- [ ] if !exists(certpaths.ServerCertPath()): GenerateCSR("localhost", ["localhost","127.0.0.1"]) → ca.SignCSR → WriteCert + WriteKey
|
|
||||||
- [ ] detectOS() from /etc/os-release (see cli-engineer territory)
|
|
||||||
- [ ] localhost node upsert: GetByName("localhost") → if found UpdateLastSeenAndOS; else Insert with kind=localhost, os=<detected>, name="localhost", addr="localhost:8443"
|
|
||||||
- [ ] print summary (CA fp, server cert fp, os, node id, db path)
|
|
||||||
- [ ] `internal/cli/init_test.go` — idempotency test: run init twice, assert no duplicate localhost node, last_seen refreshed, os unchanged; assert CA/cert not regenerated on re-run; assert doctor passes after init
|
|
||||||
|
|
||||||
#### cli-engineer territory
|
|
||||||
- [ ] `internal/cli/osdetect.go` (NEW) — `detectOS() string`: read `/etc/os-release` then fall back to `/usr/lib/os-release`; parse `KEY=VALUE` lines via bufio.Scanner + strings.SplitN; strip surrounding quotes; return `ID` value or `"linux"` fallback. Map ubuntu/debian/alpine → verbatim; unknown values stored verbatim (not masked).
|
|
||||||
- [ ] `internal/cli/osdetect_test.go` — test parsing with sample os-release content (ubuntu, debian, alpine, missing file, missing ID=, unknown ID, quoted values)
|
|
||||||
- [ ] `internal/cli/init.go` output UX — multi-step progress lines: "✓ Namespace dir: ...", "✓ Database initialized: ...", "✓ CA provisioned: ... (fp=...)", "✓ Server cert provisioned: ... (fp=...)", "✓ OS detected: ubuntu", "✓ Localhost node registered: <id>"; `--json` outputs a single JSON summary object
|
|
||||||
|
|
||||||
### Verification
|
|
||||||
|
|
||||||
- `go build ./...` PASS
|
|
||||||
- `go test ./internal/store/... ./internal/cli/... ./internal/model/...` PASS
|
|
||||||
- `go test -race ./...` PASS
|
|
||||||
- `orca init` on a fresh namespace → creates dir, db, CA, server cert, localhost node; `orca doctor` passes with zero FAILs
|
|
||||||
- `orca init` re-run → no duplicate localhost node, last_seen refreshed, CA/cert not regenerated (idempotent, D-036)
|
|
||||||
- `orca init --json` → valid JSON summary
|
|
||||||
- `orca node list` shows the localhost node with kind=localhost, os=<detected>
|
|
||||||
- Migration 0006 applies cleanly on existing dbs (existing rows get NULL kind/os → scanned as `""`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2: Proxmox SSH Join (Wave 1)
|
|
||||||
|
|
||||||
**Branch**: `phase/02-proxmox-join`
|
|
||||||
**REQ Coverage**: REQ-050, REQ-051
|
|
||||||
**Persona leads**: security-engineer (SSH key, TOFU, sudoers, PVE role), backend-engineer (SSH session orchestration), cli-engineer (flag wiring)
|
|
||||||
**Depends on**: Phase 1 (migration 0006 + Node.Kind/OS fields)
|
|
||||||
|
|
||||||
### Must-Haves
|
|
||||||
|
|
||||||
#### dependency + security-engineer territory
|
|
||||||
- [ ] `go.mod` / `go.sum` — add `golang.org/x/crypto v0.54.0`; bump `golang.org/x/sys` to v0.47.0; add `golang.org/x/term v0.45.0` (indirect). Run `go mod tidy`.
|
|
||||||
- [ ] `internal/certpaths/certpaths.go` — add `SSHKeyPath() → Dir()/orca_ssh_key`, `SSHPubPath() → Dir()/orca_ssh_key.pub`, `KnownHostsPath() → Dir()/known_hosts`
|
|
||||||
- [ ] `internal/security/sshkey.go` (NEW) — `GenerateOrLoadSSHKey(dir string) (keyPEM, pubLine []byte, err error)`:
|
|
||||||
- [ ] If `orca_ssh_key` + `.pub` exist → load + return (idempotent)
|
|
||||||
- [ ] Else: `ed25519.GenerateKey(rand.Reader)` → `x509.MarshalPKCS8PrivateKey` → PEM encode → `writeAtomic(keyPath, 0600, keyPEM)`; `ssh.NewPublicKey(pub)` → `ssh.MarshalAuthorizedKey` → `writeAtomic(pubPath, 0644, pubLine)`
|
|
||||||
- [ ] Return keyPEM (for `ssh.ParsePrivateKey`) + pubLine (authorized_keys line)
|
|
||||||
- [ ] `internal/security/sshkey_test.go` — test generate → load round-trip; test idempotent re-load; test file modes (0600/0644); test `ssh.ParsePrivateKey` accepts the PKCS8 PEM
|
|
||||||
|
|
||||||
#### backend-engineer territory (with security-engineer co-own)
|
|
||||||
- [ ] `internal/proxmox/bootstrap.go` (NEW package) — `BootstrapProxmox(ctx context.Context, opts Options) (*Result, error)`:
|
|
||||||
- **Options**: `Host, SSHUser, Password, ProxmoxUser (default "orca"), ProxmoxRole (default "OrcaOperator"), Port (default 22)`, `Logger *slog.Logger`
|
|
||||||
- **Step 1**: `security.GenerateOrLoadSSHKey(certpaths.Dir())` → keyPEM, pubLine
|
|
||||||
- **Step 2**: Build `ssh.ClientConfig` with `ssh.Password(opts.Password)` auth + `knownhosts.New(certpaths.KnownHostsPath())` HostKeyCallback (TOFU: captures on first connect, verifies on subsequent)
|
|
||||||
- **Step 3**: `ssh.Dial("tcp", host:port, config)` with 10s timeout
|
|
||||||
- **Step 4**: Deploy pubkey — `session.CombinedOutput("mkdir -p ~orca/.ssh && touch ~orca/.ssh/authorized_keys && chmod 0700 ~orca/.ssh && chmod 0600 ~orca/.ssh/authorized_keys && grep -qF '<publine>' ~orca/.ssh/authorized_keys || echo '<publine>' >> ~orca/.ssh/authorized_keys")` (idempotent append)
|
|
||||||
- **Step 5**: Create orca system user — `session.CombinedOutput("id -u orca 2>/dev/null || useradd -m -s /bin/bash orca")` (idempotent)
|
|
||||||
- **Step 6**: Create PVE role — `session.CombinedOutput("pveum role list 2>/dev/null | grep -q '^OrcaOperator' || pveum role add OrcaOperator --privs 'VM.Audit Datastore.AllocateSpace SDN.Use'")` (idempotent; use opts.ProxmoxRole for the name)
|
|
||||||
- [ ] Step 7: Create PVE user — `session.CombinedOutput("pveum user list 2>/dev/null | grep -q 'orca@pam' || pveum user add orca@pam -comment 'Orca automation user'")` (idempotent; use opts.ProxmoxUser)
|
|
||||||
- [ ] Step 8: Assign ACL — `session.CombinedOutput("pveum acl modify / -user orca@pam -role OrcaOperator")` (idempotent)
|
|
||||||
- [ ] Step 9: Write sudoers — resolve binary paths via `command -v pct` etc.; write `/etc/sudoers.d/orca` (mode 0440) with NOEXEC on pct/qm, no NOEXEC on apt-get/dpkg; exclude pvesh (AD-020)
|
|
||||||
- [ ] Step 10: Validate sudoers — `session.CombinedOutput("visudo -cf /etc/sudoers.d/orca")`; abort + cleanup if validation fails
|
|
||||||
- [ ] Step 11: Audit log — `logger.Info("proxmox.bootstrap_ok", slog.String("host", opts.Host), slog.String("user", opts.ProxmoxUser), slog.String("role", opts.ProxmoxRole))`
|
|
||||||
- [ ] **Result**: `Node{Kind: "proxmox", OS: "pve", Name: opts.Host, Address: opts.Host + ":8443"}`
|
|
||||||
- [ ] `internal/proxmox/bootstrap_test.go` — unit tests with a mock SSH server (`httptest`-style or `net.Pipe` + manual SSH handshake) OR test the command-builder functions in isolation (probe commands, sudoers content, idempotency checks). Integration test against a real Proxmox host is out of scope for unit tests (flagged as `// +build integration`).
|
|
||||||
|
|
||||||
#### cli-engineer territory
|
|
||||||
- [ ] `internal/cli/node.go` — extend `nodeJoinCmd`:
|
|
||||||
- [ ] Add `--type` flag (values: `localhost` default, `linux`, `proxmox`)
|
|
||||||
- [ ] Add `--host`, `--ssh-user` (default `root`), `--password`, `--proxmox-user` (default `orca`), `--proxmox-role` (default `OrcaOperator`), `--ssh-port` (default `22`) flags
|
|
||||||
- [ ] When `--type proxmox`: validate `--host` + (`--password` or `$ORCA_PROXMOX_PASSWORD`) are set; call `proxmox.BootstrapProxmox(ctx, opts)`; insert the returned node via `NodeRepo.Insert`; print summary
|
|
||||||
- [ ] When `--type localhost` (default): existing flow (fingerprint check + registry.Join)
|
|
||||||
- [ ] Password from `--password` flag OR `$ORCA_PROXMOX_PASSWORD` env var (prefer env var per D-031; never log the password; zero the byte slice after use)
|
|
||||||
- [ ] `internal/cli/node_test.go` — test flag wiring; test `--type proxmox` validation (missing host/password → error); test env var fallback
|
|
||||||
|
|
||||||
### Verification
|
|
||||||
|
|
||||||
- `go build ./...` PASS
|
|
||||||
- `go test ./internal/proxmox/... ./internal/security/... ./internal/cli/...` PASS
|
|
||||||
- `go test -race ./...` PASS
|
|
||||||
- `go mod tidy` leaves no unused deps; `go.sum` has `golang.org/x/crypto v0.54.0`
|
|
||||||
- `orca node join --type proxmox --host <pve-host> --password <pw>` on a real Proxmox 8/9 host:
|
|
||||||
- Creates orcaOperator role, orca@pam user, ACL, sudoers file
|
|
||||||
- `orca@pam` can `sudo pct list`, `sudo qm list`, `sudo apt-get update` without password
|
|
||||||
- `orca@pam` CANNOT `sudo pvesh` (not in sudoers)
|
|
||||||
- `orca@pam` CANNOT `sudo bash` (not in sudoers)
|
|
||||||
- `visudo -cf /etc/sudoers.d/orca` passes
|
|
||||||
- Re-running the join command is idempotent (no duplicate role/user/ACL/sudoers/key)
|
|
||||||
- `orca node list` shows the proxmox node with kind=proxmox, os=pve
|
|
||||||
- Audit log contains `proxmox.bootstrap_ok` entry with host, user, role
|
|
||||||
- `~/.orca/orca_ssh_key` is 0600, `.pub` is 0644, `known_hosts` contains the PVE host key
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3: Doctor Extensions + Audit Logging (Wave 2)
|
|
||||||
|
|
||||||
**Branch**: `phase/03-doctor-extensions`
|
|
||||||
**REQ Coverage**: REQ-052
|
|
||||||
**Persona leads**: cli-engineer (subcommand wiring), backend-engineer (check logic), security-engineer (audit logging)
|
|
||||||
**Depends on**: Phase 1 (localhost node + os field), Phase 2 (proxmox nodes + SSH client)
|
|
||||||
|
|
||||||
### Must-Haves
|
|
||||||
|
|
||||||
#### backend-engineer territory
|
|
||||||
- [ ] `internal/doctor/doctor.go` — add `OS()` check:
|
|
||||||
- Re-run `detectOS()` (from `internal/cli/osdetect.go` — extract to shared package or pass as param)
|
|
||||||
- Load localhost node via `NodeRepo.GetByName("localhost")`
|
|
||||||
- Compare detected OS to stored `node.OS`; drift → WARN ("OS drift: init=ubuntu, now=debian — re-run `orca init` to refresh"); match → PASS
|
|
||||||
- Missing localhost node → FAIL ("no localhost node — run `orca init`")
|
|
||||||
- [ ] `internal/doctor/doctor.go` — add `Proxmox()` check (clone `Network()` pattern):
|
|
||||||
- List nodes from `NodeRepo`, filter `kind == "proxmox"`
|
|
||||||
- Zero proxmox nodes → WARN ("no proxmox nodes registered (single-node?)")
|
|
||||||
- Per node: load orca SSH key, build `ssh.ClientConfig` with `ssh.PublicKeys(signer)` + `knownhosts.New`, dial with 3s timeout, run `pveversion` via session
|
|
||||||
- PASS = reachable + pveversion exits 0; FAIL = unreachable or pveversion fails
|
|
||||||
- Accumulate per-node lines (clone `Network()`'s `lines []string` pattern)
|
|
||||||
- [ ] `internal/doctor/doctor.go` — extend `All()` to include `OS()` and `Proxmox()`
|
|
||||||
- [ ] `internal/doctor/doctor_test.go` — test `OS()` with mock node repo (drift, match, missing); test `Proxmox()` with mock nodes (zero nodes → WARN, reachable → PASS, unreachable → FAIL)
|
|
||||||
|
|
||||||
#### cli-engineer territory
|
|
||||||
- [ ] `internal/cli/doctor.go` — add `doctorOSCmd` + `doctorProxmoxCmd` subcommands wired to `doctor.OS()` / `doctor.Proxmox()`; add to `doctorCmd.AddCommand(...)`
|
|
||||||
- [ ] `internal/cli/doctor.go` — `doctor os` and `doctor proxmox` honor `--json` flag (reuse existing pattern)
|
|
||||||
|
|
||||||
#### security-engineer territory
|
|
||||||
- [ ] `internal/audit/audit.go` (extend) — emit `proxmox.bootstrap_ok`, `proxmox.bootstrap_fail`, `node.os_drift` events with structured slog fields
|
|
||||||
- [ ] Audit log entries for all bootstrap + join actions (REQ-052): `orca init` emits `init.bootstrap_ok` (os, node_id, ca_fp); `orca node join --type proxmox` emits `proxmox.bootstrap_ok` (host, user, role); `doctor os` drift emits `node.os_drift` (init_os, current_os)
|
|
||||||
|
|
||||||
### Verification
|
|
||||||
|
|
||||||
- `go build ./...` PASS
|
|
||||||
- `go test ./internal/doctor/... ./internal/cli/...` PASS
|
|
||||||
- `go test -race ./...` PASS
|
|
||||||
- `orca doctor` (after `orca init`) → all checks PASS (cert, db, os, network=zero peers WARN, proxmox=zero nodes WARN)
|
|
||||||
- `orca doctor os` → PASS (OS matches)
|
|
||||||
- `orca doctor proxmox` (no proxmox nodes) → WARN ("no proxmox nodes registered")
|
|
||||||
- `orca doctor proxmox` (after joining a PVE host) → PASS per node
|
|
||||||
- `orca doctor proxmox` (PVE host down) → FAIL per node with error message
|
|
||||||
- Audit log contains `init.bootstrap_ok` and `proxmox.bootstrap_ok` entries
|
|
||||||
- `--json` output for `doctor os` and `doctor proxmox` is valid JSON
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4: Final Review + Ship + Audit (Wave 3)
|
|
||||||
|
|
||||||
**Branch**: `phase/04-final-review-ship`
|
|
||||||
**REQ Coverage**: REQ-047, REQ-048, REQ-049, REQ-050, REQ-051, REQ-052 (all)
|
|
||||||
**Persona leads**: lead-developer (review + audit), all personas (post-hoc review)
|
|
||||||
|
|
||||||
### Must-Haves
|
|
||||||
|
|
||||||
- [ ] **Review** (delegate to `ciagent-review`): multi-persona code review across P01-P03
|
|
||||||
- Auto-apply P0 fixes; flag P1+ for post-hoc review
|
|
||||||
- Review territory discipline (warn mode)
|
|
||||||
- Review test coverage for all 6 REQs
|
|
||||||
- [ ] **Audit** (delegate to `ciagent-audit`):
|
|
||||||
- Reconstruction test: git log matches `.ciagent/` files
|
|
||||||
- Branch hygiene: phase branches merged cleanly to milestone
|
|
||||||
- Commit discipline: all commits have `---ci---` blocks
|
|
||||||
- File discipline: no stale `.ciagent/` files
|
|
||||||
- [ ] **Ship** (delegate to `ciagent-ship`):
|
|
||||||
- Merge `phase/04` → `milestone/v0.6`
|
|
||||||
- Merge `milestone/v0.6` → `main` (rebase-then-fast-forward per config.json)
|
|
||||||
- Tag `v0.5.4` (final phase patch = milestone release per feature-milestone promotion)
|
|
||||||
- Create Gitea release with full milestone summary (all phases, all REQs)
|
|
||||||
- [ ] **Complete milestone**:
|
|
||||||
- Update `.ciagent/REQUIREMENTS.md` — mark REQ-047..052 as Complete
|
|
||||||
- Update `.ciagent/ROADMAP.md` — mark v0.6 as complete
|
|
||||||
- Update `.ciagent/CHECKPOINT.json` — `milestone_complete: true`
|
|
||||||
- Commit: `docs(milestone): complete node-bootstrap-proxmox`
|
|
||||||
|
|
||||||
### Verification
|
|
||||||
|
|
||||||
- `git log --oneline main..milestone/v0.6` shows all phase commits in order
|
|
||||||
- `git tag --list v0.5.*` shows v0.5.0..v0.5.4
|
|
||||||
- `main` branch contains all v0.6 work (fast-forward merge)
|
|
||||||
- `orca init && orca doctor` on a fresh checkout passes end-to-end
|
|
||||||
- Gitea release `v0.5.4` exists with milestone summary
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Wave Ordering
|
|
||||||
|
|
||||||
- **Wave 1** (Phases 1-2): Schema + init bootstrap (P01) is a hard
|
|
||||||
prerequisite for Proxmox join (P02) — P02 depends on the `Node.Kind`/
|
|
||||||
`OS` fields + migration 0006 from P01. `parallelization.enabled=false`
|
|
||||||
→ sequential.
|
|
||||||
- **Wave 2** (Phase 3): Doctor extensions depend on both P01 (localhost
|
|
||||||
node + os field for `doctor os`) and P02 (proxmox nodes + SSH client
|
|
||||||
for `doctor proxmox`).
|
|
||||||
- **Wave 3** (Phase 4): Final review + ship + audit — covers all
|
|
||||||
execution phases.
|
|
||||||
|
|
||||||
For v0.6, `parallelization.enabled=false` — phases run sequentially.
|
|
||||||
|
|
||||||
## Versioning
|
|
||||||
|
|
||||||
- **Milestone type**: `feature` (P01/P02/P03 ship `feat` phases)
|
|
||||||
- **Patch per phase**: `v0.5.0` (P0), `v0.5.1` (P01), `v0.5.2` (P02), `v0.5.3` (P03), `v0.5.4` (P04 final = milestone release)
|
|
||||||
- Tags run on the previous minor's patch line (v0.5.x) per branch-strategy.md
|
|
||||||
- Milestone branch label: `milestone/v0.6-node-bootstrap-proxmox` (uses milestone number, not tag line)
|
|
||||||
|
|
||||||
## Requirement Coverage Matrix
|
|
||||||
|
|
||||||
| REQ | Phase | Persona lead | Must-haves |
|
|
||||||
|-----|-------|-------------|------------|
|
|
||||||
| REQ-047 | P01 | backend-engineer | init.go full bootstrap (CA + cert + db + localhost node, idempotent) |
|
|
||||||
| REQ-048 | P01 | backend-engineer + cli-engineer | detectOS() from /etc/os-release + localhost node registration |
|
|
||||||
| REQ-049 | P01 | data-engineer | migration 0006 + Node.Kind/OS + NodeRepo schema extension |
|
|
||||||
| REQ-050 | P02 | security-engineer + backend-engineer | proxmox.BootstrapProxmox SSH dance + sshkey.go + certpaths SSH paths |
|
|
||||||
| REQ-051 | P02 | security-engineer | OrcaOperator PVE role + orca@pam user + sudoers NOEXEC design |
|
|
||||||
| REQ-052 | P03 | backend-engineer + security-engineer | doctor OS() + Proxmox() + audit logging of all bootstrap/join actions |
|
|
||||||
@@ -141,137 +141,3 @@ despite stale REQUIREMENTS.md marking them Pending. The remaining work:
|
|||||||
The vision ("minimalist, offline-first, CLI-first orchestration
|
The vision ("minimalist, offline-first, CLI-first orchestration
|
||||||
engine") is unchanged. v0.3 is a completion milestone, not a direction
|
engine") is unchanged. v0.3 is a completion milestone, not a direction
|
||||||
change.
|
change.
|
||||||
|
|
||||||
## v0.5 Scope Summary — Distribution
|
|
||||||
|
|
||||||
v0.5 is a 3-execution-phase milestone that makes Orca installable,
|
|
||||||
distributable, and containerized. The engine functionality from
|
|
||||||
v0.1–v0.3 is unchanged; this milestone is purely about **delivery
|
|
||||||
surface**:
|
|
||||||
|
|
||||||
- **P01 — Namespace unification.** A single `ORCA_HOME` environment
|
|
||||||
variable becomes the namespace root for *all* on-disk state (db,
|
|
||||||
certs, init, daemon). A `--system` flag on the root command selects
|
|
||||||
the system-level namespace root `/root/.orca`. Backward compatible:
|
|
||||||
empty `ORCA_HOME` → `~/.orca`. Covers REQ-041, REQ-042.
|
|
||||||
- **P02 — `install.sh` + in-place update.** A 1-liner installer pulls
|
|
||||||
the release binary from the public Gitea release URL, installs at
|
|
||||||
user level by default (`~/.local/bin/orca`) or system level
|
|
||||||
(`/usr/local/bin/orca`) with `--system`. Re-running updates the
|
|
||||||
binary in place while preserving config/db/certs in the namespace
|
|
||||||
dir. Idempotent. Covers REQ-043, REQ-044. Also updates README
|
|
||||||
quickstart (REQ-016 completion).
|
|
||||||
- **P03 — Docker release.** A multi-stage `Dockerfile` builds a
|
|
||||||
distroless image; `scripts/release.sh` and `.coreci.yml` publish the
|
|
||||||
image to the Gitea container registry per release. Covers REQ-046.
|
|
||||||
- **P04 — Final review + ship + audit.** Milestone release.
|
|
||||||
|
|
||||||
The vision ("minimalist, offline-first, CLI-first orchestration
|
|
||||||
engine") is unchanged. v0.5 is a distribution milestone, not a
|
|
||||||
direction change.
|
|
||||||
|
|
||||||
## v0.5 Clarified Decisions (D-series, full autonomy)
|
|
||||||
|
|
||||||
The 5 v0.5 decisions (D-025..D-029) were auto-resolved under full
|
|
||||||
autonomy during the CLARIFY stage:
|
|
||||||
|
|
||||||
| ID | Question | Decision | Rationale | Confidence |
|
|
||||||
|----|----------|----------|-----------|------------|
|
|
||||||
| D-025 | System-level namespace path layout? | **`/root/.orca`** (mirror of user-level `~/.orca`) | Consistent shape with user-level; just a different root. Matches the user's "starts at /root" wording. Single dir keeps it simple. | 0.90 |
|
|
||||||
| D-026 | Namespace override mechanism at runtime? | **Unify on `ORCA_HOME`** as single namespace root for all components (db, certs, init, daemon). Add `--system` flag that sets root to `/root/.orca`. | `ORCA_HOME` already exists for certs; extend to all components. Backward compatible (empty → `~/.orca`). One knob, not many. | 0.92 |
|
|
||||||
| D-027 | Docker registry target? | **Gitea built-in container registry** (`git.cloudinit.dev/coreci/orca`) | Keeps everything in one forge; uses Gitea's native registry. Consistent with REQ-045 (public repo → public image pulls). | 0.88 |
|
|
||||||
| D-028 | How to make releases publicly accessible (REQ-045)? | **Flip repo visibility to public** via `tea repos edit coreci/orca --private=false` during P0 ship | Simplest path to anonymous downloads; enables both install.sh pulls and docker pulls. Pre-existing `.env` leak already suppressed via gitleaks baseline + rotate-forward (commit 00127ce). | 0.85 |
|
|
||||||
| D-029 | install.sh default version? | **Latest release** (query Gitea releases API), optional `--version vX.Y.Z` to pin | Matches typical 1-liner installer UX; users get newest by default, can pin for reproducibility. | 0.92 |
|
|
||||||
|
|
||||||
### v0.5 Operational prerequisite (P0 ship)
|
|
||||||
|
|
||||||
The Gitea repo `coreci/orca` is currently **private** (returns 404
|
|
||||||
unauthenticated). P0 ship flips visibility to public via `tea repos
|
|
||||||
edit coreci/orca --private=false` so that `install.sh` can pull
|
|
||||||
release binaries unauthenticated (REQ-045). This is an operational
|
|
||||||
step performed during the P0 ship, verified by an unauth `curl`
|
|
||||||
against the releases API.
|
|
||||||
|
|
||||||
## v0.6 Scope Summary — Node Bootstrap & Proxmox
|
|
||||||
|
|
||||||
v0.6 is a 3-execution-phase milestone that turns `orca init` from a
|
|
||||||
bare `mkdir` into a full single-node cluster bootstrap, and adds
|
|
||||||
Proxmox 8 & 9 as a first-class remote node type joined over SSH with
|
|
||||||
least-privilege role delegation. The engine functionality from
|
|
||||||
v0.1–v0.5 is unchanged; this milestone is about **bootstrap
|
|
||||||
ergonomics** and **heterogeneous node support**:
|
|
||||||
|
|
||||||
- **P01 — `orca init` full bootstrap.** A single `orca init` call now:
|
|
||||||
(a) creates the namespace dir (`~/.orca` or `/root/.orca` with
|
|
||||||
`--system`); (b) runs all DB migrations including the new 0006
|
|
||||||
(`nodes.kind`, `nodes.os` — backward-compatible nullable columns);
|
|
||||||
(c) bootstraps the internal CA via `security.CAInit` if `ca.crt` is
|
|
||||||
absent; (d) generates the server cert via `security.GenerateCSR` +
|
|
||||||
`ca.SignCSR` if `server.crt` is absent; (e) auto-detects the local
|
|
||||||
OS via `/etc/os-release` `ID=` field (ubuntu/debian/alpine); (f)
|
|
||||||
registers a `localhost` node with `kind=localhost`, `os=<detected>`,
|
|
||||||
`addr=localhost:8443` if no localhost node exists yet. After
|
|
||||||
`orca init`, `orca doctor` MUST pass with zero FAILs. Idempotent:
|
|
||||||
re-running `orca init` is a no-op (or refresh) for already-provisioned
|
|
||||||
artifacts. Covers REQ-047, REQ-048, REQ-049.
|
|
||||||
- **P02 — Proxmox SSH join.** `orca node join --type proxmox --host
|
|
||||||
<addr> --user root --password <pw>` (password via flag or
|
|
||||||
`$ORCA_PROXMOX_PASSWORD`, **never persisted**) bootstraps a remote
|
|
||||||
Proxmox 8/9 host via `golang.org/x/crypto/ssh` (new direct dep).
|
|
||||||
Steps: (1) SSH password-auth; (2) generate or load orca's SSH
|
|
||||||
keypair (`~/.orca/orca_ssh_key` / `.pub`, 0600/0644); (3) deploy
|
|
||||||
pubkey to remote `~orca/.ssh/authorized_keys`; (4) create `orca`
|
|
||||||
user (config-overridable name via `--proxmox-user`, default `orca`);
|
|
||||||
(5) create PVE custom role `OrcaOperator` (config-overridable via
|
|
||||||
`--proxmox-role`) with privileges `VM.Audit`,
|
|
||||||
`Datastore.AllocateSpace`, `SDN.Use`; (6) assign role to `orca`
|
|
||||||
user on `/`; (7) drop `/etc/sudoers.d/orca` allowlist (`pct`, `qm`,
|
|
||||||
`pvesh`, `apt-get`, `dpkg` — no shell-escape commands); (8) record
|
|
||||||
node row `kind=proxmox`, `os=pve`, audit log. Idempotent re-run.
|
|
||||||
Covers REQ-050, REQ-051.
|
|
||||||
- **P03 — `doctor os` + `doctor proxmox`.** Extends `orca doctor`
|
|
||||||
with two new checks: `doctor os` re-runs `/etc/os-release` detection
|
|
||||||
and verifies it matches the stored localhost node row's `os` field
|
|
||||||
(drift = WARN); `doctor proxmox` iterates `kind=proxmox` nodes and
|
|
||||||
SSH-probes each with `pveversion` / `pvecmd status` (3s timeout per
|
|
||||||
peer per D-038 pattern), reporting PASS/WARN/FAIL per node. All
|
|
||||||
bootstrap + join actions emit structured audit-log entries. Covers
|
|
||||||
REQ-052.
|
|
||||||
- **P04 — Final review + ship + audit.** Milestone release.
|
|
||||||
|
|
||||||
The vision ("minimalist, offline-first, CLI-first orchestration
|
|
||||||
engine") is unchanged. v0.6 is a bootstrap-ergonomics + heterogeneous-
|
|
||||||
nodes milestone, not a direction change.
|
|
||||||
|
|
||||||
## v0.6 Clarified Decisions (D-series, full autonomy)
|
|
||||||
|
|
||||||
The 8 v0.6 decisions (D-030..D-037) were resolved during the CLARIFY
|
|
||||||
stage — D-030..D-034 confirmed by the operator in plan mode, D-035..D-037
|
|
||||||
auto-resolved at full autonomy within the `clarify_budget`:
|
|
||||||
|
|
||||||
| ID | Question | Decision | Rationale | Confidence |
|
|
||||||
|----|----------|----------|-----------|------------|
|
|
||||||
| D-030 | SSH library for Proxmox join? | **`golang.org/x/crypto/ssh`** | Stdlib-adjacent, well-maintained, single new direct dep. Matches orca's minimal-deps ethos. Shell-out to `/usr/bin/ssh` would require openssh-client on the orca host and complicate password-auth + idempotent pubkey deploy. | 0.92 (operator-confirmed) |
|
|
||||||
| D-031 | Proxmox join password handling? | **Flag/env only, never persisted** | `--password` flag or `$ORCA_PROXMOX_PASSWORD` is used once to deploy the orca pubkey + create the `orca` user; the password is never written to SQLite. Subsequent orca→Proxmox access uses the deployed SSH key. | 0.95 (operator-confirmed) |
|
|
||||||
| D-032 | Localhost OS auto-detect signal? | **`/etc/os-release` `ID=` field** | Parse `ID=` from `/etc/os-release`; map `ubuntu`/`debian`/`alpine` → node `os`. Falls back to `linux` (unknown) if none match. Simplest reliable signal across the three target distros. | 0.93 (operator-confirmed) |
|
|
||||||
| D-033 | Least-privilege Proxmox role granularity? | **Custom PVE role `OrcaOperator`** with `VM.Audit`, `Datastore.AllocateSpace`, `SDN.Use` + `/etc/sudoers.d/orca` allowlist (`pct`, `qm`, `pvesh`, `apt-get`, `dpkg`) | Config-overridable role + user names. Sufficient for "manage the host, VMs/CTs, storage, packages" without granting root shell. Built-in `PVEAuditor` is too read-only; full `Administrator` is too broad. | 0.88 (operator-confirmed) |
|
|
||||||
| D-034 | Node kind/os schema? | **Add `nodes.kind` + `nodes.os` columns via migration 0006** | Schema-first, queryable, doctor can branch on kind. Nullable with `localhost`/`""` defaults for existing rows (backward-compatible). data-engineer owns the migration. | 0.94 (operator-confirmed) |
|
|
||||||
| D-035 | SSH host-key verification on first Proxmox connect? | **TOFU: pin on first connect, refuse on mismatch thereafter** | First connect uses `ssh.InsecureIgnoreHostKey` to capture the host key; it is then persisted to `~/.orca/known_hosts` (or the nodes metadata) and all subsequent connects require a match. Balances first-run ergonomics against MITM risk on subsequent runs. Switching to pre-pinned keys is a future enhancement. | 0.82 (auto) |
|
|
||||||
| D-036 | `orca init` idempotency semantics for already-provisioned artifacts? | **Skip-and-refresh, never overwrite** | If `ca.crt` exists → load it (no regen). If `server.crt` exists → keep it (no reissue). If a localhost node row exists → update `last_seen` + re-detect `os`, never insert a duplicate. If DB migrations are ahead → no-op. If `~/.orca` exists → MkdirAll is a no-op. Idempotent re-run is a hard requirement (REQ-047). | 0.95 (auto) |
|
|
||||||
| D-037 | orca SSH keypair location + algorithm? | **`~/.orca/orca_ssh_key` (0600) + `~/.orca/orca_ssh_key.pub` (0644), Ed25519** | Ed25519 keys are smaller, faster, and more secure than RSA for SSH auth. Stored in the orca namespace dir alongside ca.crt/server.crt so `ORCA_HOME` relocation works. File modes mirror the cert file-mode discipline (REQ-033 spirit). Generated lazily on first `orca node join --type proxmox`, not at `orca init` (localhost doesn't need SSH). | 0.90 (auto) |
|
|
||||||
|
|
||||||
### v0.6 clarification notes
|
|
||||||
|
|
||||||
- **D-035 TOFU caveat**: TOFU (trust-on-first-use) is the standard SSH
|
|
||||||
UX and matches the operator-mediated model from D-012 (CA cert
|
|
||||||
distribution). The operator is expected to verify the host key
|
|
||||||
fingerprint out-of-band on first connect if the network is
|
|
||||||
untrusted. A future milestone may add `--host-key-fingerprint` pin
|
|
||||||
flag to `orca node join --type proxmox` for pre-pinned deployments.
|
|
||||||
- **D-036 idempotency**: re-running `orca init` on a node that already
|
|
||||||
has a localhost row updates `last_seen` and re-detects `os` (in case
|
|
||||||
the host OS was upgraded) but does NOT change the node `ID` or
|
|
||||||
`joined_at`. This makes `orca init` safe to put in a systemd
|
|
||||||
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.
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ earlier versions of this file.
|
|||||||
| REQ-019 | Cobra CLI framework | High | v0.1 P01 | **Complete** |
|
| REQ-019 | Cobra CLI framework | High | v0.1 P01 | **Complete** |
|
||||||
| REQ-020 | HCL parser integration (`hashicorp/hcl`) | Medium | v0.1 P03 | **Complete** |
|
| REQ-020 | HCL parser integration (`hashicorp/hcl`) | Medium | v0.1 P03 | **Complete** |
|
||||||
| REQ-021 | `os/exec` with `WaitDelay` (Go 1.25+) | Medium | v0.1 P03 | **Complete** |
|
| REQ-021 | `os/exec` with `WaitDelay` (Go 1.25+) | Medium | v0.1 P03 | **Complete** |
|
||||||
| REQ-022 | `iter.Seq` for streaming job lists (Go 1.25+) | Low | **v0.3 P01** | **Complete** (v0.3 P01 shipped v0.3.1) |
|
| REQ-022 | `iter.Seq` for streaming job lists (Go 1.25+) | Low | **v0.3 P01** | Pending (v0.3 P01) |
|
||||||
| REQ-023 | Self-signed mTLS cert generation | Medium | **v0.2 P01** | **Complete** (P01 shipped v0.2.1) |
|
| REQ-023 | Self-signed mTLS cert generation | Medium | **v0.2 P01** | **Complete** (P01 shipped v0.2.1) |
|
||||||
| REQ-024 | `Makefile` with standard targets | High | v0.1 P01 | **Complete** |
|
| REQ-024 | `Makefile` with standard targets | High | v0.1 P01 | **Complete** |
|
||||||
| REQ-025 | Bounded cert rotation history: retain last N=3 server certs per node for rollback | Medium | **v0.2 P01** | **Complete** (P01 shipped v0.2.1) |
|
| REQ-025 | Bounded cert rotation history: retain last N=3 server certs per node for rollback | Medium | **v0.2 P01** | **Complete** (P01 shipped v0.2.1) |
|
||||||
@@ -37,9 +37,9 @@ earlier versions of this file.
|
|||||||
| REQ-027 | `govulncheck` runs in offline mode in CI (no `vuln.go.dev` calls; pre-mirrored DB or `-format json` + `jq` gate) | High | v0.2 P03 | **Complete** (P10 shipped v0.2.3) |
|
| REQ-027 | `govulncheck` runs in offline mode in CI (no `vuln.go.dev` calls; pre-mirrored DB or `-format json` + `jq` gate) | High | v0.2 P03 | **Complete** (P10 shipped v0.2.3) |
|
||||||
| REQ-028 | HCL/YAML schema for `NodeCapacity` declaration (`orca node join` flag and/or `~/.orca/node.hcl`) | High | v0.2 P02 | **Complete** (P09 shipped v0.2.2; `orca node capacity` CLI) |
|
| REQ-028 | HCL/YAML schema for `NodeCapacity` declaration (`orca node join` flag and/or `~/.orca/node.hcl`) | High | v0.2 P02 | **Complete** (P09 shipped v0.2.2; `orca node capacity` CLI) |
|
||||||
| REQ-029 | `gitleaks` baseline file committed to repo to suppress pre-existing `.env` SHA-1 leak in git history | Medium | v0.2 P03 | **Complete** (P10 shipped v0.2.3) |
|
| REQ-029 | `gitleaks` baseline file committed to repo to suppress pre-existing `.env` SHA-1 leak in git history | Medium | v0.2 P03 | **Complete** (P10 shipped v0.2.3) |
|
||||||
| REQ-030 | `--watch` output format mode: table (default) vs streaming one-line JSON per event | Low | **v0.3 P01** | **Complete** (v0.3 P01 shipped v0.3.1) |
|
| REQ-030 | `--watch` output format mode: table (default) vs streaming one-line JSON per event | Low | **v0.3 P01** | Pending (v0.3 P01) |
|
||||||
| REQ-031 | `go test -race` enabled in CI for all v0.2 packages | High | v0.2 P01–P04 | **Complete** (P10; `.coreci.yml` test pipeline runs `-race`) |
|
| REQ-031 | `go test -race` enabled in CI for all v0.2 packages | High | v0.2 P01–P04 | **Complete** (P10; `.coreci.yml` test pipeline runs `-race`) |
|
||||||
| REQ-032 | `orca doctor` subcommand for diagnostics (CA/cert health, db integrity, peer reachability) | Medium | **v0.2 P01 / v0.3 P02** | **Complete** (cert checks P01 v0.2.1; network + db P02 v0.3.2) |
|
| REQ-032 | `orca doctor` subcommand for diagnostics (CA/cert health, db integrity, peer reachability) | Medium | **v0.2 P01 / v0.3 P02** | **Partial** — cert checks complete (P01); network/db are stubs, full impl in v0.3 P02 |
|
||||||
| REQ-033 | Cert file mode enforcement: 0600 for keys, 0644 for certs (refuses to start on violation) | High | **v0.2 P01** | **Complete** (P01 shipped v0.2.1) |
|
| REQ-033 | Cert file mode enforcement: 0600 for keys, 0644 for certs (refuses to start on violation) | High | **v0.2 P01** | **Complete** (P01 shipped v0.2.1) |
|
||||||
| REQ-034 | Cert proactive rotation alarm: structured slog WARN 30 days before `not_after` | Medium | **v0.2 P01** | **Complete** (P01 shipped v0.2.1) |
|
| REQ-034 | Cert proactive rotation alarm: structured slog WARN 30 days before `not_after` | Medium | **v0.2 P01** | **Complete** (P01 shipped v0.2.1) |
|
||||||
| REQ-035 | `orca cert show` redacts private key material from default and `--json` output | High | **v0.2 P01** | **Complete** (P01 shipped v0.2.1) |
|
| REQ-035 | `orca cert show` redacts private key material from default and `--json` output | High | **v0.2 P01** | **Complete** (P01 shipped v0.2.1) |
|
||||||
@@ -48,12 +48,6 @@ earlier versions of this file.
|
|||||||
| REQ-038 | Structured slog fields for mTLS failures: `event=mtls.handshake`, `peer`, `cert_fp`, `err` | Medium | **v0.2 P01** | **Complete** (P01 shipped v0.2.1) |
|
| REQ-038 | Structured slog fields for mTLS failures: `event=mtls.handshake`, `peer`, `cert_fp`, `err` | Medium | **v0.2 P01** | **Complete** (P01 shipped v0.2.1) |
|
||||||
| REQ-039 | `.gitleaks.toml` extended with stopwords for test data paths and CA cert PEM blocks | Medium | v0.2 P03 | **Complete** (P10 shipped v0.2.3) |
|
| REQ-039 | `.gitleaks.toml` extended with stopwords for test data paths and CA cert PEM blocks | Medium | v0.2 P03 | **Complete** (P10 shipped v0.2.3) |
|
||||||
| REQ-040 | `.golangci.yml` unified lint config superseding per-tool invocations | Low | v0.2 P03 | **Complete** (P10 shipped v0.2.3) |
|
| REQ-040 | `.golangci.yml` unified lint config superseding per-tool invocations | Low | v0.2 P03 | **Complete** (P10 shipped v0.2.3) |
|
||||||
| REQ-041 | Unified namespace root via `ORCA_HOME` for all components (db, certs, init, daemon) | High | **v0.5 P1** | **Complete** (P1 shipped v0.4.2) |
|
|
||||||
| REQ-042 | `--system` flag selects system-level namespace root `/root/.orca` | High | **v0.5 P1** | **Complete** (P1 shipped v0.4.2) |
|
|
||||||
| REQ-043 | `install.sh` 1-liner pulling release binary from public Gitea URL; user-level default, `--system` for system-level | High | **v0.5 P2** | **Complete** (P2 shipped v0.4.3) |
|
|
||||||
| REQ-044 | `install.sh` in-place update preserves config/state; idempotent re-run | High | **v0.5 P2** | **Complete** (P2 shipped v0.4.3) |
|
|
||||||
| REQ-045 | Gitea repo + releases publicly accessible (unauthenticated download) | High | **v0.5 P0** | **Complete** (P0 ship: repo + org visibility public) |
|
|
||||||
| REQ-046 | Docker image published to Gitea container registry per release | Medium | **v0.5 P3** | **Complete** (P3 shipped v0.4.4) |
|
|
||||||
|
|
||||||
## v0.1 Milestone Summary
|
## v0.1 Milestone Summary
|
||||||
|
|
||||||
@@ -77,36 +71,13 @@ deferred to v0.3.
|
|||||||
|
|
||||||
## v0.3 Milestone Summary
|
## v0.3 Milestone Summary
|
||||||
|
|
||||||
**Status: Complete** — P01 (iter.Seq streaming, v0.3.1) and P02 (doctor
|
**Status: In Progress** — 2 execution phases planned (P01 iter.Seq
|
||||||
network+db, v0.3.2) both shipped. REQ-022, REQ-030, REQ-032 all complete.
|
streaming, P02 doctor completion). Covers REQ-022, REQ-030, REQ-032
|
||||||
Re-init SPECIFY audit confirmed all other v0.2-deferred REQs (014, 027,
|
(completion). Re-init SPECIFY audit confirmed all other v0.2-deferred
|
||||||
028, 029, 031, 037, 039, 040) already shipped in P08-P10.
|
REQs (014, 027, 028, 029, 031, 037, 039, 040) already shipped in
|
||||||
|
P08-P10.
|
||||||
|
|
||||||
## Deferred to v0.4
|
## Deferred to v0.3
|
||||||
|
|
||||||
- pprof endpoint on `orca daemon` (idea I-308, 0.70 confidence): deferred
|
- pprof endpoint on `orca daemon` (idea I-308, 0.70 confidence): deferred
|
||||||
to keep v0.2 lean; revisit in v0.3 once P02's dispatcher is stable.
|
to keep v0.2 lean; revisit in v0.3 once P02's dispatcher is stable.
|
||||||
|
|
||||||
## v0.5 Milestone Summary
|
|
||||||
|
|
||||||
**Status: Complete** — all 3 execution phases + final review shipped.
|
|
||||||
P0 (v0.4.1), P1 (v0.4.2), P2 (v0.4.3), P3 (v0.4.4), P4 final (v0.4.5).
|
|
||||||
REQ-041..046 all complete. Repo + releases publicly accessible (REQ-045).
|
|
||||||
Docker image published to Gitea container registry (REQ-046).
|
|
||||||
|
|
||||||
- **P0** (v0.4.1): pre-execution + repo visibility flipped to public (REQ-045).
|
|
||||||
- **P1** (v0.4.2): namespace unification — `ORCA_HOME` + `--system` (REQ-041/042).
|
|
||||||
- **P2** (v0.4.3): `install.sh` 1-liner + in-place update (REQ-043/044) + README quickstart (REQ-016).
|
|
||||||
- **P3** (v0.4.4): Docker release — distroless image + Gitea container registry (REQ-046).
|
|
||||||
- **P4** (v0.4.5): final review + audit + milestone release.
|
|
||||||
|
|
||||||
## v0.6 Requirements — Node Bootstrap & Proxmox
|
|
||||||
|
|
||||||
| 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 |
|
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
# Research: Orca v0.5 — Distribution
|
|
||||||
|
|
||||||
Research findings for the v0.5 Distribution milestone (install, namespace,
|
|
||||||
docker, public releases). Conducted during P0 RESEARCH under full autonomy.
|
|
||||||
|
|
||||||
## R-001: Gitea Container Registry
|
|
||||||
|
|
||||||
**Source**: https://docs.gitea.com/usage/packages/container (Gitea 1.27.1 docs)
|
|
||||||
|
|
||||||
**Findings**:
|
|
||||||
- Gitea ships a built-in OCI-compliant container registry.
|
|
||||||
- Image naming convention: `{registry}/{owner}/{image}:{tag}`.
|
|
||||||
For orca: `git.cloudinit.dev/coreci/orca:{tag}`.
|
|
||||||
- Auth: `docker login git.cloudinit.dev` with username + personal access
|
|
||||||
token (or password if no 2FA). The `GITEA_TOKEN` env var already used
|
|
||||||
for release publishing works as the password.
|
|
||||||
- Push: `docker push git.cloudinit.dev/coreci/orca:v0.4.4`.
|
|
||||||
- Pull: anonymous pull works **if the repo is public** (REQ-045 flips
|
|
||||||
this). For private repos, pull requires auth.
|
|
||||||
- Tags are case-insensitive — use lowercase image names.
|
|
||||||
- The registry supports multi-arch manifests via `docker buildx`.
|
|
||||||
|
|
||||||
**Implication for P03**: `scripts/release.sh` must add a `docker build`
|
|
||||||
+ `docker login` + `docker push` step. The `.coreci.yml` release
|
|
||||||
pipeline needs a `container-publish` step. Credential is `GITEA_TOKEN`
|
|
||||||
(reused from the existing release flow — no new secret needed).
|
|
||||||
|
|
||||||
## R-002: `tea repos edit` — Repo Visibility
|
|
||||||
|
|
||||||
**Source**: `tea repos edit --help` (tea 0.14.1 installed locally)
|
|
||||||
|
|
||||||
**Findings**:
|
|
||||||
- Command: `tea repos edit --private false --repo coreci/orca`
|
|
||||||
- The `--private` flag accepts `true`/`false` (string, not bool).
|
|
||||||
- Default login `bot` (cloudinit-bot) is already configured and is the
|
|
||||||
default login. No extra auth needed.
|
|
||||||
- The change is immediate and reversible (re-run with `--private true`).
|
|
||||||
|
|
||||||
**Implication for P0 ship**: Run this as an operational step during the
|
|
||||||
P0 ship. Verify with unauth `curl` against the releases API afterward.
|
|
||||||
|
|
||||||
## R-003: Gitea Releases API — Asset Download URLs
|
|
||||||
|
|
||||||
**Source**: `/api/v1/repos/coreci/orca/releases/latest` (authed probe)
|
|
||||||
|
|
||||||
**Findings**:
|
|
||||||
- Auth header format: `Authorization: token <GITEA_TOKEN>` (NOT basic
|
|
||||||
auth — basic auth returns "invalid username, password or token").
|
|
||||||
- Latest release endpoint: `GET /api/v1/repos/coreci/orca/releases/latest`
|
|
||||||
→ JSON with `tag_name`, `name`, `body`, `assets[]`.
|
|
||||||
- Each asset has `browser_download_url` — the direct download URL.
|
|
||||||
- **Public access**: once the repo is public (R-002), the releases API
|
|
||||||
and asset downloads work **without authentication**. This is what
|
|
||||||
`install.sh` relies on (REQ-043).
|
|
||||||
- Asset naming convention from existing releases:
|
|
||||||
`orca-{version}-linux-amd64.tar.gz` (per `scripts/release.sh`).
|
|
||||||
|
|
||||||
**Implication for P02 install.sh**:
|
|
||||||
1. Query `GET /api/v1/repos/coreci/orca/releases/latest` (unauth, post-R-002).
|
|
||||||
2. Parse `tag_name` for the version.
|
|
||||||
3. Find the asset with `name` matching `orca-{tag}-linux-{arch}.tar.gz`.
|
|
||||||
4. Download `browser_download_url` with `curl -fsSL`.
|
|
||||||
5. Extract and install.
|
|
||||||
|
|
||||||
## R-004: ORCA_HOME Propagation Points (Codebase Audit)
|
|
||||||
|
|
||||||
**Source**: `grep` for `UserHomeDir|os.Getenv("ORCA|\.orca` across `*.go`
|
|
||||||
|
|
||||||
**Findings** — exactly 3 production code sites determine the namespace
|
|
||||||
root today:
|
|
||||||
|
|
||||||
| File | Current behavior | Needs change? |
|
|
||||||
|------|-----------------|----------------|
|
|
||||||
| `internal/certpaths/certpaths.go:21-26` | `Dir()` honors `ORCA_HOME` → `~/.orca` | **No** — this is the single source of truth. Already correct. |
|
|
||||||
| `internal/store/store.go:13-19` | `Open("")` hardcodes `~/.orca/orca.db` (ignores `ORCA_HOME`) | **Yes** — route through `certpaths.DBPath()` instead. |
|
|
||||||
| `internal/cli/init.go:16-22` | Hardcodes `~/.orca` via `os.UserHomeDir()` | **Yes** — route through `certpaths.Dir()`. |
|
|
||||||
|
|
||||||
All other call sites (`node.go:openDB`, `daemon.go`, `job.go`, `doctor.go`,
|
|
||||||
`cert.go`) already go through `certpaths.DBPath()` or `certpaths.Dir()`
|
|
||||||
indirectly. **No other files need changes for REQ-041.**
|
|
||||||
|
|
||||||
**For REQ-042 (`--system`)**: Add a `--system` persistent flag on
|
|
||||||
`rootCmd`. When set, `rootCmd.PersistentPreRunE` sets
|
|
||||||
`os.Setenv("ORCA_HOME", "/root/.orca")` before any subcommand runs.
|
|
||||||
This is the minimal-touch approach — all downstream code already
|
|
||||||
honors `ORCA_HOME`. The flag is a CLI convenience that maps to the
|
|
||||||
env var, not a parallel mechanism.
|
|
||||||
|
|
||||||
**Backward compatibility**: empty `ORCA_HOME` + no `--system` →
|
|
||||||
`~/.orca` (unchanged). Existing tests that `t.Setenv("ORCA_HOME", ...)`
|
|
||||||
continue to work.
|
|
||||||
|
|
||||||
## R-005: Distroless Base Image for CGO-free Go Binaries
|
|
||||||
|
|
||||||
**Source**: Go module audit — `modernc.org/sqlite` (pure Go, CGO-free),
|
|
||||||
`go.mod` has no CGO dependencies.
|
|
||||||
|
|
||||||
**Findings**:
|
|
||||||
- `gcr.io/distroless/static-debian12` is the correct base for static
|
|
||||||
Go binaries with no CGO and no libc dependency. ~2MB image.
|
|
||||||
- orca uses `modernc.org/sqlite` (pure Go) — no CGO, no libc. ✓
|
|
||||||
- Multi-stage Dockerfile:
|
|
||||||
- Stage 1 (`golang:1.25`): build with `-trimpath -ldflags` (same as
|
|
||||||
Makefile), output `bin/orca`.
|
|
||||||
- Stage 2 (`gcr.io/distroless/static-debian12`): `COPY bin/orca /orca`,
|
|
||||||
`ENTRYPOINT ["/orca"]`.
|
|
||||||
- `CGO_ENABLED=0` must be set in the build stage to guarantee a static
|
|
||||||
binary (Go defaults to CGO_ENABLED=1 on platforms with a C compiler).
|
|
||||||
- The image runs as `nonroot` user by default in distroless — but orca
|
|
||||||
writes to `~/.orca` (or `/root/.orca` for `--system`). For the
|
|
||||||
container image, default `ORCA_HOME=/var/lib/orca` and document
|
|
||||||
volume mount at that path.
|
|
||||||
|
|
||||||
**Implication for P03**: Dockerfile is ~15 lines. The `.coreci.yml`
|
|
||||||
release pipeline adds a `docker build --build-arg VERSION=$VERSION -t
|
|
||||||
git.cloudinit.dev/coreci/orca:$VERSION .` step + login + push.
|
|
||||||
|
|
||||||
## R-006: install.sh Conventions (curl|sh pattern)
|
|
||||||
|
|
||||||
**Source**: Common patterns from deno, rustup, homebrew installers.
|
|
||||||
|
|
||||||
**Findings**:
|
|
||||||
- 1-liner: `curl -fsSL <url> | bash` (or `| bash -s -- --system`).
|
|
||||||
- The script must be downloadable from a stable URL. orca's script
|
|
||||||
lives at `scripts/install.sh` in the repo, accessible via
|
|
||||||
`https://git.cloudinit.dev/coreci/orca/raw/branch/main/scripts/install.sh`
|
|
||||||
(once repo is public per R-002).
|
|
||||||
- Args passed via `bash -s -- --system --version v0.4.4`.
|
|
||||||
- In-place update: detect existing binary at install path, read its
|
|
||||||
version via `orca version --json` (parse `version` field), print
|
|
||||||
"updated from X to Y", overwrite binary. **Never** touch the
|
|
||||||
namespace dir (`~/.orca` or `/root/.orca`) — that's user state.
|
|
||||||
- User-level default: `~/.local/bin/orca` (XDG-ish, on PATH on most
|
|
||||||
modern distros). System-level: `/usr/local/bin/orca` (requires root).
|
|
||||||
|
|
||||||
**Implication for P02**: install.sh is ~80-100 lines of bash. Idempotent.
|
|
||||||
Tested via a `scripts/install_test.sh` that mocks the download and
|
|
||||||
verifies path selection + update-in-place.
|
|
||||||
|
|
||||||
## Pitfalls (P-001..P-003)
|
|
||||||
|
|
||||||
- **P-001**: `docker` may not be available in the CoreCI release
|
|
||||||
pipeline container. The `.coreci.yml` release step uses
|
|
||||||
`image: golang:1.25` which does NOT include docker. **Mitigation**:
|
|
||||||
the release pipeline must use a `docker:dind` sidecar or a step image
|
|
||||||
that has the docker CLI. Alternatively, `scripts/release.sh` handles
|
|
||||||
docker publish only when run locally or in a CI step that has docker.
|
|
||||||
The `.coreci.yml` container step must use an image with docker CLI
|
|
||||||
(e.g., `catthehacker/docker:docker-latest` or a custom image).
|
|
||||||
|
|
||||||
- **P-002**: Making the repo public exposes git history including the
|
|
||||||
pre-existing `.env` SHA-1 leak (commit `00127ce` documented the
|
|
||||||
rotate-forward decision; `.gitleaks-baseline.json` suppresses it for
|
|
||||||
scanning). The leak is a **non-secret** (the token was rotated). This
|
|
||||||
is an accepted risk per the existing decision — no new action needed,
|
|
||||||
but document it in the P0 ship commit.
|
|
||||||
|
|
||||||
- **P-003**: `CGO_ENABLED=0` must be explicit in the Dockerfile build
|
|
||||||
stage. Without it, `go build` in `golang:1.25` may produce a
|
|
||||||
dynamically-linked binary that won't run in distroless. Verified:
|
|
||||||
orca has no CGO deps, but `CGO_ENABLED=0` is belt-and-suspenders.
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
# Research: Orca v0.6 — Node Bootstrap & Proxmox
|
|
||||||
|
|
||||||
Findings grounded in codebase analysis (8 key files read) + verified
|
|
||||||
against `golang.org/x/crypto` v0.54.0 (probe built clean), Proxmox VE
|
|
||||||
9.2.3 admin guide (§14.7-14.8 pveum + privileges), sudoers(5) man
|
|
||||||
page (NOEXEC/NOPASSWD), and freedesktop.org os-release spec.
|
|
||||||
|
|
||||||
## A. SSH library — `golang.org/x/crypto/ssh`
|
|
||||||
|
|
||||||
### A.1 go.mod addition
|
|
||||||
|
|
||||||
```
|
|
||||||
require golang.org/x/crypto v0.54.0
|
|
||||||
```
|
|
||||||
|
|
||||||
Latest available, compatible with go 1.25. Transitive deps (verified
|
|
||||||
by probe build):
|
|
||||||
- `golang.org/x/crypto v0.54.0` (direct)
|
|
||||||
- `golang.org/x/sys v0.47.0` (indirect — bumps from v0.42.0)
|
|
||||||
- `golang.org/x/term v0.45.0` (indirect — pulled by ssh for PTY)
|
|
||||||
|
|
||||||
**3 module entries, 0 new heavy deps.** Matches D-030 minimal-deps
|
|
||||||
rationale. `go.sum` gains ~6 lines.
|
|
||||||
|
|
||||||
### A.2 Minimal API surface
|
|
||||||
|
|
||||||
```go
|
|
||||||
import (
|
|
||||||
"crypto/ed25519"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/x509"
|
|
||||||
"encoding/pem"
|
|
||||||
"net"
|
|
||||||
"time"
|
|
||||||
"golang.org/x/crypto/ssh"
|
|
||||||
"golang.org/x/crypto/ssh/knownhosts"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Key functions:
|
|
||||||
- `ssh.Dial(network, addr, config) (*ssh.Client, error)` — high-level dialer
|
|
||||||
- `(*ssh.Client).NewSession() (*ssh.Session, error)`
|
|
||||||
- `(*ssh.Session).CombinedOutput(cmd) ([]byte, error)` — run + capture
|
|
||||||
- `ssh.ClientConfig{User, Auth, HostKeyCallback, Timeout}`
|
|
||||||
- `ssh.Password(secret) ssh.AuthMethod` — password auth
|
|
||||||
- `ssh.PublicKeys(signer) ssh.AuthMethod` — pubkey auth
|
|
||||||
- `ssh.ParsePrivateKey(pem) (ssh.Signer, error)` — parse PKCS8 PEM (works with orca's existing key format)
|
|
||||||
- `ssh.NewPublicKey(pub) (ssh.PublicKey, error)` + `ssh.MarshalAuthorizedKey(pub) []byte` — authorized_keys line
|
|
||||||
- `ssh.FixedHostKey(key) ssh.HostKeyCallback` — strict pin (subsequent connects)
|
|
||||||
- `knownhosts.New(path) (ssh.HostKeyCallback, error)` — TOFU via known_hosts file (cleaner than custom callback; avoids deprecated `InsecureIgnoreHostKey`)
|
|
||||||
|
|
||||||
### A.3 Ed25519 keygen (D-037)
|
|
||||||
|
|
||||||
Verified end-to-end: `ed25519.GenerateKey(rand.Reader)` →
|
|
||||||
`x509.MarshalPKCS8PrivateKey(priv)` → PEM encode → `ssh.ParsePrivateKey`
|
|
||||||
round-trips cleanly. `ssh.MarshalAuthorizedKey` produces valid
|
|
||||||
`ssh-ed25519 AAAA...` line. **PKCS8 PEM (orca's existing format)
|
|
||||||
parses with `ssh.ParsePrivateKey` — no OpenSSH-format marshaller
|
|
||||||
needed.** Reuse `security.WriteKey`/`writeAtomic` for persistence.
|
|
||||||
|
|
||||||
### A.4 File upload — `cat > file` via session, NOT SFTP
|
|
||||||
|
|
||||||
SFTP lives in separate module `github.com/pkg/sftp` — would add a 4th
|
|
||||||
direct dep beyond D-030. The only files orca uploads are:
|
|
||||||
- `~orca/.ssh/authorized_keys` (1-line append)
|
|
||||||
- `/etc/sudoers.d/orca` (few lines)
|
|
||||||
|
|
||||||
Both are text. Use `session.CombinedOutput` with heredoc / `tee -a`.
|
|
||||||
Keeps everything within `x/crypto/ssh`.
|
|
||||||
|
|
||||||
### A.5 TOFU host-key handling (D-035)
|
|
||||||
|
|
||||||
Use `golang.org/x/crypto/ssh/knownhosts.New(path)` as the
|
|
||||||
`HostKeyCallback`. On first connect, the callback writes the host key
|
|
||||||
to `~/.orca/known_hosts` (OpenSSH format). On subsequent connects, it
|
|
||||||
verifies and returns an error on mismatch. **Avoids
|
|
||||||
`ssh.InsecureIgnoreHostKey` deprecation** — `knownhosts.New` handles
|
|
||||||
both capture and verify in one callback. On host-key change
|
|
||||||
(reinstall), fail closed with a clear error; operator runs
|
|
||||||
`orca node key-reset <node>` (future) or manually edits `known_hosts`.
|
|
||||||
|
|
||||||
## B. `/etc/os-release` parsing (D-032)
|
|
||||||
|
|
||||||
### B.1 Confirmed `ID=` values
|
|
||||||
|
|
||||||
| Distro | `ID=` | `ID_LIKE=` | Verified |
|
|
||||||
|--------|-------|-----------|----------|
|
|
||||||
| Ubuntu | `ubuntu` | `debian` | ✅ (this host: Ubuntu 24.04) |
|
|
||||||
| Debian | `debian` | — | ✅ (freedesktop spec) |
|
|
||||||
| Alpine | `alpine` | — | ✅ (Alpine policy) |
|
|
||||||
| Proxmox VE | `pve` | `debian` | ✅ (PVE ships own os-release) |
|
|
||||||
|
|
||||||
`VARIANT_ID` absent on all four target distros — not worth capturing
|
|
||||||
for v0.6.
|
|
||||||
|
|
||||||
### B.2 Parsing approach
|
|
||||||
|
|
||||||
No Go stdlib helper. Trivial: `bufio.Scanner` +
|
|
||||||
`strings.SplitN(line, "=", 2)` + strip surrounding quotes. ~15 lines.
|
|
||||||
Returns `map[string]string`; read `ID` field. Fallback `"linux"` if
|
|
||||||
file missing or `ID` absent (D-032). Read `/etc/os-release` first;
|
|
||||||
fall back to `/usr/lib/os-release` for minimal containers. Unknown `ID`
|
|
||||||
values stored verbatim (not masked) — `doctor os` can warn.
|
|
||||||
|
|
||||||
## C. Proxmox VE role & user management
|
|
||||||
|
|
||||||
### C.1 Realm: `orca@pam` (NOT `orca@pve`)
|
|
||||||
|
|
||||||
Confirmed by both researchers + PVE User Management docs: since
|
|
||||||
`orca node join` SSHes in and creates a Linux system user via
|
|
||||||
`useradd`, the PVE user must be `orca@pam` (PAM realm maps to host
|
|
||||||
system users). `orca@pve` would require a separate PVE-internal
|
|
||||||
password and interactive `-password` prompt over non-PTY SSH (hangs).
|
|
||||||
`@pam` sidesteps both issues. **D-033 refined: `orca@pam`.**
|
|
||||||
|
|
||||||
### C.2 OrcaOperator PVE role — privilege set
|
|
||||||
|
|
||||||
Per D-033 (operator-confirmed): `VM.Audit`, `Datastore.AllocateSpace`,
|
|
||||||
`SDN.Use`. This is a **minimal API-level role** — the actual management
|
|
||||||
capability comes from the sudoers allowlist (sudo runs as root, bypassing
|
|
||||||
PVE RBAC). The PVE role governs non-sudo API access (future REST client).
|
|
||||||
|
|
||||||
**Refinement from research**: `VM.Audit` covers containers (CTs) as well
|
|
||||||
as VMs (both live under `/vms/{vmid}` path; no separate `CT.*` family).
|
|
||||||
PVE 8→9: privilege set valid on both (no breaking changes to pveum or
|
|
||||||
the core privilege names).
|
|
||||||
|
|
||||||
Researcher 2 proposed an expanded 21-privilege set for fuller API-level
|
|
||||||
management. **Decision: keep D-033's 3-priv minimal set for v0.6** — the
|
|
||||||
operator explicitly confirmed it, and the sudoers allowlist is the
|
|
||||||
primary management path. The expanded set is noted as a v0.7+
|
|
||||||
enhancement option if orca adds a direct PVE REST client.
|
|
||||||
|
|
||||||
### C.3 pveum command sequence (idempotent)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Role — probe-then-add (pveum role add fails if exists)
|
|
||||||
pveum role list | grep -q '^OrcaOperator' || \
|
|
||||||
pveum role add OrcaOperator --privs "VM.Audit Datastore.AllocateSpace SDN.Use"
|
|
||||||
|
|
||||||
# 2. User — probe-then-add (maps to existing Linux system user)
|
|
||||||
pveum user list | grep -q 'orca@pam' || \
|
|
||||||
pveum user add orca@pam -comment "Orca automation user"
|
|
||||||
|
|
||||||
# 3. ACL — modify is idempotent (creates or updates)
|
|
||||||
pveum acl modify / -user orca@pam -role OrcaOperator
|
|
||||||
```
|
|
||||||
|
|
||||||
Flag syntax: both `-privs` and `--privs` work (Perl Getopt::Long). Use
|
|
||||||
`--privs` (canonical). Privs are **space-separated** inside quotes
|
|
||||||
(NOT comma-separated).
|
|
||||||
|
|
||||||
### C.4 sudoers file `/etc/sudoers.d/orca` (D-033 refined)
|
|
||||||
|
|
||||||
**Research refinement**: exclude `pvesh` from sudoers — `pvesh` can
|
|
||||||
reach the `/nodes/{node}/execute` API endpoint which spawns shell
|
|
||||||
commands server-side, bypassing sudo's `NOEXEC` tag. Keep `pct`/`qm`
|
|
||||||
with `NOEXEC`; `apt-get`/`dpkg` without `NOEXEC` (they need to spawn
|
|
||||||
child processes for maintainer scripts).
|
|
||||||
|
|
||||||
```
|
|
||||||
# /etc/sudoers.d/orca — mode 0440, owner root:root
|
|
||||||
# Orca automation: VM/CT management + package management, no shell escape
|
|
||||||
orca ALL=(root) NOPASSWD: NOEXEC: /usr/bin/pct, /usr/bin/qm
|
|
||||||
orca ALL=(root) NOPASSWD: /usr/bin/apt-get, /usr/bin/dpkg
|
|
||||||
```
|
|
||||||
|
|
||||||
`NOEXEC` works via Linux seccomp (sudoers man page). `pct`/`qm` are
|
|
||||||
Perl scripts run via dynamically-linked `/usr/bin/perl` → NOEXEC
|
|
||||||
effective. `apt-get`/`dpkg` need exec for postinst scripts → no
|
|
||||||
NOEXEC. File mode **0440** or sudo refuses to load. Validate with
|
|
||||||
`visudo -cf /etc/sudoers.d/orca` after writing; abort bootstrap on
|
|
||||||
validation failure.
|
|
||||||
|
|
||||||
**Resolve binary paths at runtime** via `command -v pct` etc. before
|
|
||||||
writing the sudoers file (cheap insurance against non-standard installs).
|
|
||||||
|
|
||||||
### C.5 PVE 8 vs 9
|
|
||||||
|
|
||||||
No breaking changes to pveum, privilege names, or sudo defaults
|
|
||||||
between 8 and 9. `VM.Monitor` removed in 9.0 (OrcaOperator doesn't
|
|
||||||
use it). Privileged container creation needs `Sys.Modify` in 9.0
|
|
||||||
(OrcaOperator doesn't have it → intended). Both versions: `orca@pam`
|
|
||||||
flow identical. Binary paths identical (`/usr/bin/{pct,qm,pvesh}`).
|
|
||||||
|
|
||||||
## D. Codebase integration points (confirmed by reading files)
|
|
||||||
|
|
||||||
### D.1 Files to modify/create per requirement
|
|
||||||
|
|
||||||
| File | Change | REQ |
|
|
||||||
|------|--------|-----|
|
|
||||||
| `go.mod` / `go.sum` | Add `golang.org/x/crypto v0.54.0`; bump sys, add term | REQ-050 |
|
|
||||||
| `internal/model/node.go` | Add `Kind`, `OS` string fields + `NodeKind` constants | REQ-049 |
|
|
||||||
| `internal/store/migrations/0006_node_kind_os.sql` | **NEW**: `ALTER TABLE nodes ADD COLUMN kind TEXT; ADD COLUMN os TEXT;` (nullable, backward-compatible) | REQ-049 |
|
|
||||||
| `internal/store/node_repo.go` | Extend INSERT/SELECT/scanNode for `kind, os`; add `GetByName`, `UpdateLastSeenAndOS` helpers | REQ-049 |
|
|
||||||
| `internal/cli/init.go` | Full bootstrap: MkdirAll → store.Open (runs migrations) → CAInit → server cert gen (if absent) → detectOS → localhost node upsert | REQ-047,048 |
|
|
||||||
| `internal/cli/node.go` | Add `--type`, `--host`, `--user`, `--password`, `--proxmox-user`, `--proxmox-role` flags; `bootstrapProxmox` branch | REQ-050,051 |
|
|
||||||
| `internal/security/sshkey.go` | **NEW**: `GenerateOrLoadSSHKey(dir)` — Ed25519 keygen, PKCS8 PEM, 0600/0644 modes | REQ-050 |
|
|
||||||
| `internal/proxmox/bootstrap.go` | **NEW package**: `BootstrapProxmox(ctx, opts)` — SSH dial, pubkey deploy, useradd, pveum role/user/acl, sudoers write, visudo validate | REQ-050,051 |
|
|
||||||
| `internal/doctor/doctor.go` | Add `OS()` and `Proxmox()` checks; extend `All()` | REQ-052 |
|
|
||||||
| `internal/cli/doctor.go` | Add `doctor os` + `doctor proxmox` subcommands | REQ-052 |
|
|
||||||
| `internal/certpaths/certpaths.go` | Add `SSHKeyPath`, `SSHPubPath`, `KnownHostsPath` | REQ-050 |
|
|
||||||
|
|
||||||
### D.2 Reuse opportunities (confirmed)
|
|
||||||
|
|
||||||
- `security.CAInit` (ca.go:63) — **already idempotent** (fast-path loads existing). `orca init` calls it directly.
|
|
||||||
- `security.GenerateCSR` (csr.go) — signature fits: `GenerateCSR("localhost", []string{"localhost","127.0.0.1"})`.
|
|
||||||
- `security.WriteCert`/`WriteKey` (ca.go:292) — enforce 0644/0600 via `writeAtomic`; reuse for SSH key.
|
|
||||||
- `store.Open` (migrate.go) — runs migrations on open; calling it in `orca init` auto-applies 0006.
|
|
||||||
- Migration runner — FS-embedded, sorts lexicographically, idempotent per-file. Adding `0006_*.sql` is the entire change.
|
|
||||||
- `doctor.Network()` (doctor.go:222) — exact pattern to clone for `doctor.Proxmox()` (list nodes, filter by kind, 3s timeout per peer, PASS/WARN/FAIL).
|
|
||||||
|
|
||||||
### D.3 No changes needed
|
|
||||||
|
|
||||||
- `internal/security/ca.go`, `csr.go` — idempotent already, signatures fit.
|
|
||||||
- `internal/store/migrate.go` — runner is generic.
|
|
||||||
- `internal/transport/*` — mTLS transport not involved in SSH bootstrap.
|
|
||||||
- `internal/engine/*` — NodeRegistry.Join works; new fields are metadata.
|
|
||||||
|
|
||||||
## E. Pitfalls & gotchas
|
|
||||||
|
|
||||||
1. **`pveum` flag is `--privs` (space-separated)**, not `--privs "a,b,c"`. Confirmed by both researchers + official docs.
|
|
||||||
2. **`orca@pam` not `orca@pve`** — PVE-internal realm requires interactive password prompt over non-PTY SSH (hangs). PAM realm maps to the Linux system user orca creates.
|
|
||||||
3. **Exclude `pvesh` from sudoers** — `pvesh` can trigger API `execute` endpoint spawning shell commands server-side, bypassing `NOEXEC`. Use PVE API via OrcaOperator role for API access instead.
|
|
||||||
4. **`NOEXEC` only on dynamically-linked binaries** — `pct`/`qm` are Perl scripts via dynamically-linked `/usr/bin/perl` → effective. `apt-get`/`dpkg` need exec → no NOEXEC.
|
|
||||||
5. **sudoers file mode 0440** — or sudo silently refuses to load it. `chmod 0440` + `visudo -cf` validate after write.
|
|
||||||
6. **Migration 0006 NULL handling** — `scanNode` must use `sql.NullString` for `kind`/`os` and map NULL → `""` (Go struct fields are `string`, not `*string`).
|
|
||||||
7. **localhost node idempotency** — `NodeRepo.Insert` fails on UNIQUE constraint if `orca init` re-runs. Need `GetByName("localhost")` check first; if found, `UpdateLastSeenAndOS` instead of `Insert`. Don't change `id` or `joined_at` (D-036).
|
|
||||||
8. **`orca init` must not regenerate server cert** (D-036) — check `certpaths.ServerCertPath()` existence before `GenerateCSR`. `CAInit` has a fast-path; server cert gen needs an explicit existence check.
|
|
||||||
9. **Password handling (D-031)** — `--password` flag visible in `ps`/`/proc` briefly. Prefer `$ORCA_PROXMOX_PASSWORD` env var. Never log the password (slog redaction). Zero the byte slice after use.
|
|
||||||
10. **`knownhosts.New` for TOFU** — avoids deprecated `ssh.InsecureIgnoreHostKey`. Handles both capture and verify in one callback.
|
|
||||||
11. **PKCS8 PEM parses with `ssh.ParsePrivateKey`** — no need for OpenSSH-format marshaller. Consistent with `ca.key`/`server.key` format.
|
|
||||||
12. **`/etc/os-release` is a symlink** on most distros → `os.ReadFile` follows it. Fall back to `/usr/lib/os-release` for minimal containers.
|
|
||||||
|
|
||||||
## F. Persona recommendations (v0.6 roster)
|
|
||||||
|
|
||||||
| Persona | Active | Reason |
|
|
||||||
|---------|--------|--------|
|
|
||||||
| `lead-developer` | ✅ | Coordination across P01/P02/P03; SSH/bootstrap touches security + cli + store + doctor |
|
|
||||||
| `backend-engineer` | ✅ | Owns `internal/cli/init.go` full-bootstrap orchestration + `internal/proxmox/bootstrap.go` SSH logic |
|
|
||||||
| `cli-engineer` | ✅ | Owns `--type`/`--host`/`--password` flag wiring, `doctor os`/`doctor proxmox` subcommands, init output UX |
|
|
||||||
| `data-engineer` | ✅ **REACTIVATE** | Owns migration 0006 + `NodeRepo` schema extension (kind/os columns, new helpers) |
|
|
||||||
| `security-engineer` | ✅ **REACTIVATE** | Owns `internal/security/sshkey.go`, TOFU host-key, sudoers design, password redaction, audit logging |
|
|
||||||
| `devops-engineer` | ❌ **DEACTIVATE** | No install.sh/Dockerfile/.coreci.yml surface in v0.6 |
|
|
||||||
| `network-engineer` | ❌ | No transport/mTLS surface (SSH is point-to-point bootstrap, not mesh) |
|
|
||||||
| `frontend-engineer` | ❌ | No web UI |
|
|
||||||
|
|
||||||
**Territory overlaps to adjudicate (lead-developer)**:
|
|
||||||
- `internal/proxmox/bootstrap.go` (security-engineer SSH/sudoers logic) vs `internal/cli/node.go` (cli-engineer flag wiring) — boundary: security package exposes `BootstrapProxmox(ctx, opts) error`, CLI just calls it.
|
|
||||||
- `internal/doctor/doctor.go` `Proxmox()` reuses SSH client from `internal/proxmox` (security) but check scaffolding clones `doctor.Network()` pattern (backend adjudicates since network-engineer deactivated).
|
|
||||||
+9
-50
@@ -20,7 +20,7 @@
|
|||||||
- `iter.Seq` streaming job lists (REQ-022)
|
- `iter.Seq` streaming job lists (REQ-022)
|
||||||
- Frontend / devops personas (no web UI; CoreCI handles release)
|
- Frontend / devops personas (no web UI; CoreCI handles release)
|
||||||
|
|
||||||
## Milestone v0.2: Networking, Observability, Security Hardening — **COMPLETE (merged to main via v0.3)**
|
## Milestone v0.2: Networking, Observability, Security Hardening — **FUNCTIONALLY COMPLETE (pending merge to main)**
|
||||||
|
|
||||||
Scope: extend v0.1 with secure cross-node transport, multi-node scheduling,
|
Scope: extend v0.1 with secure cross-node transport, multi-node scheduling,
|
||||||
richer CI security scanning, and streaming I/O.
|
richer CI security scanning, and streaming I/O.
|
||||||
@@ -28,25 +28,25 @@ richer CI security scanning, and streaming I/O.
|
|||||||
- [x] Phase 8: mTLS handshake + internal CA with CSR join (Wave 1) — shipped v0.2.1
|
- [x] Phase 8: mTLS handshake + internal CA with CSR join (Wave 1) — shipped v0.2.1
|
||||||
- [x] Phase 9: Multi-node scheduling & job dispatch (Wave 1) — shipped v0.2.2
|
- [x] Phase 9: Multi-node scheduling & job dispatch (Wave 1) — shipped v0.2.2
|
||||||
- [x] Phase 10: `gosec` + `govulncheck` + gitleaks in CI (Wave 2) — shipped v0.2.3
|
- [x] Phase 10: `gosec` + `govulncheck` + gitleaks in CI (Wave 2) — shipped v0.2.3
|
||||||
- [x] Phase 11: `iter.Seq` streaming job/node lists (Wave 2) — **completed in v0.3 P01** (shipped v0.3.1)
|
- [ ] Phase 11: `iter.Seq` streaming job/node lists (Wave 2) — **deferred to v0.3 P01**
|
||||||
|
|
||||||
**Milestone tag**: `v0.4.0` (shipped — v0.2 work merged to main via v0.3 milestone).
|
**Milestone tag**: `v0.3.0` (next-minor per feature-milestone promotion rule) — pending merge to main.
|
||||||
|
|
||||||
Per-phase tags: `v0.2.1` (P01), `v0.2.2` (P02), `v0.2.3` (P03) — all shipped.
|
Per-phase tags: `v0.2.1` (P01), `v0.2.2` (P02), `v0.2.3` (P03) — all shipped.
|
||||||
|
|
||||||
## Milestone v0.3: Scheduling & Streaming Completion — **COMPLETE**
|
## Milestone v0.3: Scheduling & Streaming Completion — **IN PROGRESS**
|
||||||
|
|
||||||
Scope: complete the two work items deferred from v0.2 that were not
|
Scope: complete the two work items deferred from v0.2 that were not
|
||||||
already shipped in P08-P10. A re-init SPECIFY codebase audit confirmed
|
already shipped in P08-P10. A re-init SPECIFY codebase audit confirmed
|
||||||
that REQ-014/027/028/029/031/037/039/040 all shipped in P08-P10 despite
|
that REQ-014/027/028/029/031/037/039/040 all shipped in P08-P10 despite
|
||||||
stale REQUIREMENTS.md marking them Pending. The remaining work is lean:
|
stale REQUIREMENTS.md marking them Pending. The remaining work is lean:
|
||||||
|
|
||||||
- [x] Phase 0: Pre-execution (specify → clarify → research → plan → grill) — shipped v0.3.0
|
- [ ] Phase 0: Pre-execution (specify → clarify → research → plan)
|
||||||
- [x] Phase 1: `iter.Seq` streaming for `--watch` flags (REQ-022, REQ-030) — shipped v0.3.1
|
- [ ] Phase 1: `iter.Seq` streaming for `--watch` flags (REQ-022, REQ-030)
|
||||||
- [x] Phase 2: `orca doctor` network + db full implementation (REQ-032 completion) — shipped v0.3.2
|
- [ ] Phase 2: `orca doctor` network + db full implementation (REQ-032 completion)
|
||||||
- [x] Phase 3: Final review + ship + audit (milestone release) — shipped v0.3.3
|
- [ ] Phase 3: Final review + ship + audit (milestone release)
|
||||||
|
|
||||||
**Milestone tag**: `v0.4.0` (next-minor per feature-milestone promotion rule).
|
**Target milestone tag**: `v0.4.0` (next-minor per feature-milestone promotion rule).
|
||||||
|
|
||||||
Per-phase tags: `v0.3.0` (P0), `v0.3.1` (P01), `v0.3.2` (P02), `v0.3.3` (P03 final = milestone release).
|
Per-phase tags: `v0.3.0` (P0), `v0.3.1` (P01), `v0.3.2` (P02), `v0.3.3` (P03 final = milestone release).
|
||||||
Per `.ciagent/RELEASE_POLICY.md`, every phase tag produces a Gitea release.
|
Per `.ciagent/RELEASE_POLICY.md`, every phase tag produces a Gitea release.
|
||||||
@@ -69,44 +69,3 @@ Per `.ciagent/RELEASE_POLICY.md`, every phase tag produces a Gitea release.
|
|||||||
The vision ("minimalist, offline-first, CLI-first orchestration
|
The vision ("minimalist, offline-first, CLI-first orchestration
|
||||||
engine") is unchanged. v0.3 closes out the v0.2 deferrals and merges
|
engine") is unchanged. v0.3 closes out the v0.2 deferrals and merges
|
||||||
the accumulated v0.2 work to main.
|
the accumulated v0.2 work to main.
|
||||||
|
|
||||||
## Milestone v0.5: Distribution — **COMPLETE**
|
|
||||||
|
|
||||||
Scope: make Orca installable, distributable, and containerized. The
|
|
||||||
engine functionality from v0.1–v0.3 is unchanged; this milestone is
|
|
||||||
purely about delivery surface.
|
|
||||||
|
|
||||||
- [x] Phase 0: Pre-execution (specify → clarify → research → plan) — shipped `v0.4.1` (+ repo public)
|
|
||||||
- [x] Phase 1: Namespace unification (`ORCA_HOME` + `--system`) (REQ-041, REQ-042) — shipped `v0.4.2`
|
|
||||||
- [x] Phase 2: `install.sh` + in-place update + README quickstart (REQ-043, REQ-044) — shipped `v0.4.3`
|
|
||||||
- [x] Phase 3: Docker release (Dockerfile + Gitea container registry) (REQ-046) — shipped `v0.4.4`
|
|
||||||
- [x] Phase 4: Final review + ship + audit (milestone release) — shipped `v0.4.5`
|
|
||||||
|
|
||||||
**Operational prerequisite (P0 ship)**: repo + org visibility flipped to
|
|
||||||
public (REQ-045) — unauth releases API + asset download + docker pull all
|
|
||||||
verified HTTP 200.
|
|
||||||
|
|
||||||
**Milestone tag**: `v0.4.5` (final phase patch = milestone release per
|
|
||||||
feature-milestone promotion rule). Per-phase tags: `v0.4.1`…`v0.4.5`.
|
|
||||||
|
|
||||||
## Milestone v0.6: Node Bootstrap & Proxmox
|
|
||||||
|
|
||||||
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`
|
|
||||||
|
|
||||||
**Milestone type**: feature (P1/P2/P3 ship `feat` phases).
|
|
||||||
**Milestone tag**: `v0.5.4` (final phase patch = milestone release per
|
|
||||||
feature-milestone promotion rule). Per-phase tags: `v0.5.0`…`v0.5.4`.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|||||||
+1
-13
@@ -5,7 +5,7 @@
|
|||||||
"slug": "orca",
|
"slug": "orca",
|
||||||
"name": "Orca",
|
"name": "Orca",
|
||||||
"description": "Offline/CLI-first orchestration engine (Orca) — Nomad-inspired, far simpler than Kubernetes",
|
"description": "Offline/CLI-first orchestration engine (Orca) — Nomad-inspired, far simpler than Kubernetes",
|
||||||
"milestone": "v0.6",
|
"milestone": "v0.3",
|
||||||
"phase": 0,
|
"phase": 0,
|
||||||
"milestone_type": "feature",
|
"milestone_type": "feature",
|
||||||
"default_branch": "main",
|
"default_branch": "main",
|
||||||
@@ -24,11 +24,6 @@
|
|||||||
],
|
],
|
||||||
"active_project": "orca",
|
"active_project": "orca",
|
||||||
"active_projects": ["orca"],
|
"active_projects": ["orca"],
|
||||||
"ship": {
|
|
||||||
"per_phase": true,
|
|
||||||
"allow_skip": false,
|
|
||||||
"max_release_retries": 3
|
|
||||||
},
|
|
||||||
"autonomy": {
|
"autonomy": {
|
||||||
"level": "full",
|
"level": "full",
|
||||||
"decision_confidence_threshold": 0.60,
|
"decision_confidence_threshold": 0.60,
|
||||||
@@ -127,13 +122,6 @@
|
|||||||
"owner": "coreci",
|
"owner": "coreci",
|
||||||
"repo": "orca",
|
"repo": "orca",
|
||||||
"token_env": "GITEA_TOKEN"
|
"token_env": "GITEA_TOKEN"
|
||||||
},
|
|
||||||
"container_registry": {
|
|
||||||
"forge": "gitea",
|
|
||||||
"registry": "git.cloudinit.dev",
|
|
||||||
"owner": "coreci",
|
|
||||||
"image": "orca",
|
|
||||||
"credential_env": "GITEA_TOKEN"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"secrets": {
|
"secrets": {
|
||||||
|
|||||||
-20
@@ -112,23 +112,3 @@ pipelines:
|
|||||||
--title "Orca ${VERSION}"
|
--title "Orca ${VERSION}"
|
||||||
--note-file CHANGELOG.md
|
--note-file CHANGELOG.md
|
||||||
--asset orca-${VERSION}-linux-amd64.tar.gz
|
--asset orca-${VERSION}-linux-amd64.tar.gz
|
||||||
- name: container-publish
|
|
||||||
description: Build and publish OCI image to Gitea container registry (REQ-046)
|
|
||||||
image: docker:24-cli
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${GITEA_TOKEN}
|
|
||||||
VERSION: ${CI_COMMIT_TAG}
|
|
||||||
GIT_COMMIT: ${CI_COMMIT_SHA}
|
|
||||||
BUILD_TIME: ${CI_BUILD_TIME}
|
|
||||||
commands:
|
|
||||||
- docker build
|
|
||||||
--build-arg VERSION=${VERSION}
|
|
||||||
--build-arg GIT_COMMIT=${GIT_COMMIT}
|
|
||||||
--build-arg BUILD_TIME=${BUILD_TIME}
|
|
||||||
-t git.cloudinit.dev/coreci/orca:${VERSION}
|
|
||||||
-t git.cloudinit.dev/coreci/orca:latest
|
|
||||||
.
|
|
||||||
- echo "${GITEA_TOKEN}" | docker login git.cloudinit.dev -u cloudinit-bot --password-stdin
|
|
||||||
- docker push git.cloudinit.dev/coreci/orca:${VERSION}
|
|
||||||
- docker push git.cloudinit.dev/coreci/orca:latest
|
|
||||||
- docker logout git.cloudinit.dev
|
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
.git
|
|
||||||
.githooks
|
|
||||||
.bin
|
|
||||||
bin/
|
|
||||||
*.tar.gz
|
|
||||||
*.tar.gz.asc
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
.gitleaks-baseline.json
|
|
||||||
.gitleaks.toml
|
|
||||||
.golangci.yml
|
|
||||||
.ciagent/
|
|
||||||
testdata/
|
|
||||||
docs/
|
|
||||||
*.md
|
|
||||||
!README.md
|
|
||||||
LICENSE
|
|
||||||
coverage.out
|
|
||||||
orca
|
|
||||||
orca-v*
|
|
||||||
-56
@@ -1,56 +0,0 @@
|
|||||||
# Dockerfile — multi-stage build for orca
|
|
||||||
#
|
|
||||||
# Stage 1: build the static binary with golang:1.25
|
|
||||||
# Stage 2: distroless static runtime (CGO-free, ~2MB image)
|
|
||||||
#
|
|
||||||
# Build args:
|
|
||||||
# VERSION — semver tag injected via -ldflags (e.g. v0.4.4)
|
|
||||||
# GIT_COMMIT — short commit hash
|
|
||||||
# BUILD_TIME — ISO 8601 build timestamp
|
|
||||||
#
|
|
||||||
# Build:
|
|
||||||
# docker build --build-arg VERSION=v0.4.4 -t git.cloudinit.dev/coreci/orca:v0.4.4 .
|
|
||||||
#
|
|
||||||
# Run:
|
|
||||||
# docker run --rm git.cloudinit.dev/coreci/orca:v0.4.4 version
|
|
||||||
# docker run --rm -v orca-data:/var/lib/orca git.cloudinit.dev/coreci/orca:v0.4.4 init
|
|
||||||
|
|
||||||
ARG VERSION=dev
|
|
||||||
ARG GIT_COMMIT=unknown
|
|
||||||
ARG BUILD_TIME=unknown
|
|
||||||
|
|
||||||
# --- Stage 1: build -------------------------------------------------------
|
|
||||||
|
|
||||||
FROM golang:1.25 AS builder
|
|
||||||
|
|
||||||
ARG VERSION
|
|
||||||
ARG GIT_COMMIT
|
|
||||||
ARG BUILD_TIME
|
|
||||||
|
|
||||||
WORKDIR /src
|
|
||||||
|
|
||||||
# Cache module downloads — copy go.mod/go.sum first, download, then copy source.
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# CGO_ENABLED=0 guarantees a static binary (modernc/sqlite is pure Go).
|
|
||||||
RUN CGO_ENABLED=0 go build -trimpath \
|
|
||||||
-ldflags="-s -w \
|
|
||||||
-X git.cloudinit.dev/coreci/orca/internal/cli.version=${VERSION} \
|
|
||||||
-X git.cloudinit.dev/coreci/orca/internal/cli.gitCommit=${GIT_COMMIT} \
|
|
||||||
-X git.cloudinit.dev/coreci/orca/internal/cli.buildTime=${BUILD_TIME}" \
|
|
||||||
-o /orca ./cmd/orca
|
|
||||||
|
|
||||||
# --- Stage 2: runtime -----------------------------------------------------
|
|
||||||
|
|
||||||
FROM gcr.io/distroless/static-debian12:nonroot
|
|
||||||
|
|
||||||
# ORCA_HOME points to a volume-mountable path inside the container.
|
|
||||||
# Mount a volume at /var/lib/orca to persist state across container restarts.
|
|
||||||
ENV ORCA_HOME=/var/lib/orca
|
|
||||||
|
|
||||||
COPY --from=builder /orca /orca
|
|
||||||
|
|
||||||
ENTRYPOINT ["/orca"]
|
|
||||||
@@ -18,43 +18,16 @@ Offline/CLI-first orchestration engine inspired by HashiCorp Nomad, far simpler
|
|||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
### Install (1-liner)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# User-level install (binary at ~/.local/bin/orca, state at ~/.orca)
|
# Build
|
||||||
curl -fsSL https://git.cloudinit.dev/coreci/orca/raw/branch/main/scripts/install.sh | bash
|
make build
|
||||||
|
|
||||||
# System-level install (binary at /usr/local/bin/orca, state at /root/.orca)
|
# Run
|
||||||
curl -fsSL https://git.cloudinit.dev/coreci/orca/raw/branch/main/scripts/install.sh | sudo bash -s -- --system
|
./bin/orca version
|
||||||
|
./bin/orca --help
|
||||||
|
|
||||||
# Pin a specific version
|
# Initialize local state
|
||||||
curl -fsSL https://git.cloudinit.dev/coreci/orca/raw/branch/main/scripts/install.sh | bash -s -- --version v0.4.2
|
./bin/orca init
|
||||||
```
|
|
||||||
|
|
||||||
Then initialize local state and verify:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
orca init # creates ~/.orca/ (or /root/.orca with --system)
|
|
||||||
orca version # prints version info
|
|
||||||
orca --help # show all subcommands
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build from source
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make build # Build binary to ./bin/orca
|
|
||||||
./bin/orca init # Initialize local state
|
|
||||||
./bin/orca version # Verify
|
|
||||||
```
|
|
||||||
|
|
||||||
### Update in place
|
|
||||||
|
|
||||||
Re-running the installer updates the binary while preserving your
|
|
||||||
config, database, and certificates in the namespace dir:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://git.cloudinit.dev/coreci/orca/raw/branch/main/scripts/install.sh | bash
|
|
||||||
# → "updated orca from v0.4.1 to v0.4.2"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Subcommands
|
## Subcommands
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
# Docker Guide
|
|
||||||
|
|
||||||
Orca is available as a container image on the Gitea container registry.
|
|
||||||
The image is a minimal distroless static build (~2MB runtime layer)
|
|
||||||
that runs the orca binary directly.
|
|
||||||
|
|
||||||
## Image
|
|
||||||
|
|
||||||
```
|
|
||||||
git.cloudinit.dev/coreci/orca:<version>
|
|
||||||
git.cloudinit.dev/coreci/orca:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
The image is built from the `Dockerfile` in the repo root:
|
|
||||||
- **Build stage**: `golang:1.25` — compiles a static binary with
|
|
||||||
`CGO_ENABLED=0` (modernc/sqlite is pure Go, no CGO).
|
|
||||||
- **Runtime stage**: `gcr.io/distroless/static-debian12:nonroot` —
|
|
||||||
~2MB, no shell, runs as `nonroot` user.
|
|
||||||
|
|
||||||
## Pull
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker pull git.cloudinit.dev/coreci/orca:latest
|
|
||||||
# or pin a version
|
|
||||||
docker pull git.cloudinit.dev/coreci/orca:v0.4.4
|
|
||||||
```
|
|
||||||
|
|
||||||
The repo is public (REQ-045), so anonymous pull works without login.
|
|
||||||
|
|
||||||
## Run
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Print version
|
|
||||||
docker run --rm git.cloudinit.dev/coreci/orca:v0.4.4 version
|
|
||||||
|
|
||||||
# Initialize state (creates /var/lib/orca/ inside the container)
|
|
||||||
docker run --rm -v orca-data:/var/lib/orca git.cloudinit.dev/coreci/orca:v0.4.4 init
|
|
||||||
|
|
||||||
# Run the daemon (persist state via volume)
|
|
||||||
docker run -d --name orca \
|
|
||||||
-p 8080:8080 \
|
|
||||||
-v orca-data:/var/lib/orca \
|
|
||||||
git.cloudinit.dev/coreci/orca:v0.4.4 daemon --addr=:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
## State Persistence
|
|
||||||
|
|
||||||
The image sets `ENV ORCA_HOME=/var/lib/orca`. All orca state (SQLite
|
|
||||||
database, CA certs, server certs) is written under this path. To
|
|
||||||
persist state across container restarts, mount a volume:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker volume create orca-data
|
|
||||||
docker run --rm -v orca-data:/var/lib/orca git.cloudinit.dev/coreci/orca:v0.4.4 init
|
|
||||||
docker run -d --name orca -p 8080:8080 -v orca-data:/var/lib/orca git.cloudinit.dev/coreci/orca:v0.4.4 daemon
|
|
||||||
```
|
|
||||||
|
|
||||||
Without a volume, state is lost when the container exits.
|
|
||||||
|
|
||||||
## System-Level Namespace Inside Containers
|
|
||||||
|
|
||||||
The `--system` flag is not needed inside containers — the image already
|
|
||||||
sets `ORCA_HOME=/var/lib/orca`. Use `--system` only if you want a
|
|
||||||
different namespace root (e.g., `/root/.orca`), which requires running
|
|
||||||
as root (the distroless image runs as `nonroot` by default).
|
|
||||||
|
|
||||||
## Build Locally
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build --build-arg VERSION=v0.4.4 -t orca-local:v0.4.4 .
|
|
||||||
docker run --rm orca-local:v0.4.4 version
|
|
||||||
```
|
|
||||||
|
|
||||||
Build args:
|
|
||||||
- `VERSION` — semver tag (injected via `-ldflags`)
|
|
||||||
- `GIT_COMMIT` — short commit hash
|
|
||||||
- `BUILD_TIME` — ISO 8601 build timestamp
|
|
||||||
|
|
||||||
## Publish (for maintainers)
|
|
||||||
|
|
||||||
The `.coreci.yml` release pipeline includes a `container-publish` step
|
|
||||||
that builds and pushes the image on every tag release. To publish
|
|
||||||
manually:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export GITEA_TOKEN=<token>
|
|
||||||
docker build --build-arg VERSION=v0.4.4 -t git.cloudinit.dev/coreci/orca:v0.4.4 -t git.cloudinit.dev/coreci/orca:latest .
|
|
||||||
echo "$GITEA_TOKEN" | docker login git.cloudinit.dev -u cloudinit-bot --password-stdin
|
|
||||||
docker push git.cloudinit.dev/coreci/orca:v0.4.4
|
|
||||||
docker push git.cloudinit.dev/coreci/orca:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
## See Also
|
|
||||||
|
|
||||||
- [Install Guide](install.md) — binary install (alternative to Docker).
|
|
||||||
- [Namespace and Paths](namespace.md) — `ORCA_HOME` and `--system` flag.
|
|
||||||
-139
@@ -1,139 +0,0 @@
|
|||||||
# Install Guide
|
|
||||||
|
|
||||||
Orca is distributed as a single binary via a 1-liner installer that
|
|
||||||
pulls from the public Gitea release artifacts. This guide covers
|
|
||||||
user-level install, system-level install, in-place updates, version
|
|
||||||
pinning, and troubleshooting.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- A Linux system with `curl` and `tar` installed.
|
|
||||||
- For user-level install: write access to `~/.local/bin/`.
|
|
||||||
- For system-level install: root (`sudo`) access.
|
|
||||||
|
|
||||||
## User-Level Install (Default)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://git.cloudinit.dev/coreci/orca/raw/branch/main/scripts/install.sh | bash
|
|
||||||
```
|
|
||||||
|
|
||||||
This installs:
|
|
||||||
- Binary: `~/.local/bin/orca`
|
|
||||||
- Namespace root: `~/.orca/` (created by `orca init`)
|
|
||||||
|
|
||||||
If `~/.local/bin` is not on your `PATH`, add it:
|
|
||||||
```bash
|
|
||||||
echo 'export PATH="$PATH:$HOME/.local/bin"' >> ~/.bashrc
|
|
||||||
source ~/.bashrc
|
|
||||||
```
|
|
||||||
|
|
||||||
## System-Level Install
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://git.cloudinit.dev/coreci/orca/raw/branch/main/scripts/install.sh | sudo bash -s -- --system
|
|
||||||
```
|
|
||||||
|
|
||||||
This installs:
|
|
||||||
- Binary: `/usr/local/bin/orca`
|
|
||||||
- Namespace root: `/root/.orca/` (created by `orca --system init`)
|
|
||||||
|
|
||||||
The `--system` flag requires root (uid 0). It errors if `ORCA_HOME` is
|
|
||||||
already set to a conflicting value.
|
|
||||||
|
|
||||||
## Initialize State
|
|
||||||
|
|
||||||
After installing, initialize the local state directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# User-level
|
|
||||||
orca init
|
|
||||||
|
|
||||||
# System-level
|
|
||||||
orca --system init
|
|
||||||
```
|
|
||||||
|
|
||||||
This creates the namespace root directory (`~/.orca` or `/root/.orca`).
|
|
||||||
|
|
||||||
## Version Pinning
|
|
||||||
|
|
||||||
By default, the installer fetches the **latest** release. To pin a
|
|
||||||
specific version:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://git.cloudinit.dev/coreci/orca/raw/branch/main/scripts/install.sh | bash -s -- --version v0.4.2
|
|
||||||
```
|
|
||||||
|
|
||||||
## In-Place Update
|
|
||||||
|
|
||||||
Re-running the installer updates the binary in place while **preserving**
|
|
||||||
your config, database, and certificates in the namespace dir:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://git.cloudinit.dev/coreci/orca/raw/branch/main/scripts/install.sh | bash
|
|
||||||
```
|
|
||||||
|
|
||||||
Output:
|
|
||||||
```
|
|
||||||
install: ✓ updated orca from v0.4.1 to v0.4.2 at /home/user/.local/bin/orca
|
|
||||||
```
|
|
||||||
|
|
||||||
The installer:
|
|
||||||
1. Detects the existing binary at the install path.
|
|
||||||
2. Reads its version via `orca version --json`.
|
|
||||||
3. Downloads the new release.
|
|
||||||
4. Overwrites the binary.
|
|
||||||
5. **Never touches** the namespace dir (`~/.orca` or `/root/.orca`).
|
|
||||||
|
|
||||||
## Uninstall
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Remove the binary
|
|
||||||
rm ~/.local/bin/orca # user-level
|
|
||||||
sudo rm /usr/local/bin/orca # system-level
|
|
||||||
|
|
||||||
# Optionally remove state (THIS DELETES YOUR DATABASE + CERTS)
|
|
||||||
rm -rf ~/.orca # user-level
|
|
||||||
sudo rm -rf /root/.orca # system-level
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### `install: error: --system requires root`
|
|
||||||
|
|
||||||
The `--system` flag requires root. Re-run with `sudo`:
|
|
||||||
```bash
|
|
||||||
curl -fsSL ... | sudo bash -s -- --system
|
|
||||||
```
|
|
||||||
|
|
||||||
### `install: error: --system conflicts with ORCA_HOME=...`
|
|
||||||
|
|
||||||
`ORCA_HOME` is set to a non-system path. Either unset it or drop `--system`:
|
|
||||||
```bash
|
|
||||||
unset ORCA_HOME
|
|
||||||
curl -fsSL ... | sudo bash -s -- --system
|
|
||||||
```
|
|
||||||
|
|
||||||
### `install: error: could not find asset orca-vX.Y.Z-linux-amd64.tar.gz`
|
|
||||||
|
|
||||||
The requested version does not have a Linux release asset. Check
|
|
||||||
available releases at
|
|
||||||
`https://git.cloudinit.dev/coreci/orca/releases`.
|
|
||||||
|
|
||||||
### `install: error: unsupported architecture: ...`
|
|
||||||
|
|
||||||
The installer supports `amd64` (x86_64), `arm64` (aarch64), and `armv7`.
|
|
||||||
Contact the maintainers if you need another architecture.
|
|
||||||
|
|
||||||
### `~/.local/bin is not on your PATH`
|
|
||||||
|
|
||||||
Add it to your shell profile:
|
|
||||||
```bash
|
|
||||||
echo 'export PATH="$PATH:$HOME/.local/bin"' >> ~/.bashrc
|
|
||||||
source ~/.bashrc
|
|
||||||
```
|
|
||||||
|
|
||||||
## See Also
|
|
||||||
|
|
||||||
- [Namespace and Paths](namespace.md) — `ORCA_HOME`, `--system`, path layout.
|
|
||||||
- [Docker Guide](docker.md) — running orca in a container.
|
|
||||||
- [Development](../README.md#development) — building from source.
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
# Namespace and Paths
|
|
||||||
|
|
||||||
Orca stores all on-disk state (SQLite database, CA certs, server certs,
|
|
||||||
config) under a single **namespace root** directory. This document
|
|
||||||
describes how that root is resolved and how to override it.
|
|
||||||
|
|
||||||
## Default: User-Level (`~/.orca`)
|
|
||||||
|
|
||||||
By default, the namespace root is `~/.orca` (i.e., `$HOME/.orca`).
|
|
||||||
All orca state lives under this directory:
|
|
||||||
|
|
||||||
| Path | Contents |
|
|
||||||
|------|----------|
|
|
||||||
| `~/.orca/orca.db` | SQLite database (jobs, nodes, tasks, audit log, capacity) |
|
|
||||||
| `~/.orca/ca.crt` | CA certificate (PEM, mode 0644) |
|
|
||||||
| `~/.orca/ca.key` | CA private key (PEM, mode 0600) |
|
|
||||||
| `~/.orca/server.crt` | Server certificate (PEM, mode 0644) |
|
|
||||||
| `~/.orca/server.key` | Server private key (PEM, mode 0600) |
|
|
||||||
|
|
||||||
## Override: `ORCA_HOME` Environment Variable (REQ-041)
|
|
||||||
|
|
||||||
Set the `ORCA_HOME` environment variable to change the namespace root
|
|
||||||
for **all** orca components (database, certs, init, daemon):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export ORCA_HOME=/var/lib/orca
|
|
||||||
orca init # creates /var/lib/orca/
|
|
||||||
orca daemon # reads /var/lib/orca/orca.db
|
|
||||||
orca cert ca-init # writes CA to /var/lib/orca/
|
|
||||||
```
|
|
||||||
|
|
||||||
This is the single source of truth for the namespace root. Every
|
|
||||||
component that reads or writes on-disk state resolves the root via
|
|
||||||
`ORCA_HOME` (falling back to `~/.orca` when unset).
|
|
||||||
|
|
||||||
### Use cases
|
|
||||||
|
|
||||||
- **Testing**: point `ORCA_HOME` at a temp directory.
|
|
||||||
- **Multi-instance**: run multiple orca daemons on the same host with
|
|
||||||
different `ORCA_HOME` values.
|
|
||||||
- **Custom layout**: store state on a mounted volume
|
|
||||||
(`ORCA_HOME=/mnt/orca-data`).
|
|
||||||
|
|
||||||
## System-Level: `--system` Flag (REQ-042)
|
|
||||||
|
|
||||||
The `--system` persistent flag selects the system-level namespace root
|
|
||||||
`/root/.orca`. This is intended for root-owned system deployments
|
|
||||||
(where orca runs as a system service under root):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo orca --system init # creates /root/.orca/
|
|
||||||
sudo orca --system daemon # reads /root/.orca/orca.db
|
|
||||||
sudo orca --system cert ca-init # writes CA to /root/.orca/
|
|
||||||
```
|
|
||||||
|
|
||||||
The `--system` flag is equivalent to setting `ORCA_HOME=/root/.orca`,
|
|
||||||
but it is a CLI convenience that does not require exporting an env var.
|
|
||||||
If `ORCA_HOME` is already set to a different value, `--system` returns
|
|
||||||
an error (to avoid silent namespace mismatches).
|
|
||||||
|
|
||||||
### Path layout
|
|
||||||
|
|
||||||
System-level uses the same directory shape as user-level, just under
|
|
||||||
`/root/.orca` instead of `~/.orca`:
|
|
||||||
|
|
||||||
| Path | Contents |
|
|
||||||
|------|----------|
|
|
||||||
| `/root/.orca/orca.db` | SQLite database |
|
|
||||||
| `/root/.orca/ca.crt` | CA certificate |
|
|
||||||
| `/root/.orca/ca.key` | CA private key |
|
|
||||||
| `/root/.orca/server.crt` | Server certificate |
|
|
||||||
| `/root/.orca/server.key` | Server private key |
|
|
||||||
|
|
||||||
## Resolution Order
|
|
||||||
|
|
||||||
1. If `--system` flag is passed → root is `/root/.orca` (errors if
|
|
||||||
`ORCA_HOME` is set to a conflicting value).
|
|
||||||
2. Else if `ORCA_HOME` is set → root is `$ORCA_HOME`.
|
|
||||||
3. Else → root is `~/.orca` (`$HOME/.orca`).
|
|
||||||
|
|
||||||
## `ORCA_DB` Override
|
|
||||||
|
|
||||||
For finer-grained control, `ORCA_DB` overrides **only** the database
|
|
||||||
path (not the cert paths). This is primarily a testing affordance. When
|
|
||||||
`ORCA_DB` is set, certs still resolve under `ORCA_HOME` (or `~/.orca`).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export ORCA_DB=/tmp/test.db
|
|
||||||
orca daemon # uses /tmp/test.db for the DB, ~/.orca/ for certs
|
|
||||||
```
|
|
||||||
|
|
||||||
## See Also
|
|
||||||
|
|
||||||
- [Install Guide](install.md) — 1-liner install with `install.sh`.
|
|
||||||
- [Docker Guide](docker.md) — running orca in a container (uses
|
|
||||||
`ORCA_HOME=/var/lib/orca` inside the image).
|
|
||||||
@@ -6,7 +6,6 @@ require (
|
|||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/hashicorp/hcl/v2 v2.24.0
|
github.com/hashicorp/hcl/v2 v2.24.0
|
||||||
github.com/spf13/cobra v1.8.1
|
github.com/spf13/cobra v1.8.1
|
||||||
golang.org/x/crypto v0.54.0
|
|
||||||
modernc.org/sqlite v1.51.0
|
modernc.org/sqlite v1.51.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,11 +21,11 @@ require (
|
|||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
github.com/zclconf/go-cty v1.16.3 // indirect
|
github.com/zclconf/go-cty v1.16.3 // indirect
|
||||||
golang.org/x/mod v0.37.0 // indirect
|
golang.org/x/mod v0.33.0 // indirect
|
||||||
golang.org/x/sync v0.22.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/text v0.40.0 // indirect
|
golang.org/x/text v0.25.0 // indirect
|
||||||
golang.org/x/tools v0.47.0 // indirect
|
golang.org/x/tools v0.42.0 // indirect
|
||||||
modernc.org/libc v1.72.3 // indirect
|
modernc.org/libc v1.72.3 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
|||||||
@@ -38,21 +38,17 @@ github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk
|
|||||||
github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
|
github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
|
||||||
github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo=
|
github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo=
|
||||||
github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM=
|
github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM=
|
||||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
|
||||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||||
|
|||||||
@@ -36,29 +36,3 @@ func ServerCertPath() string { return filepath.Join(Dir(), "server.crt") }
|
|||||||
|
|
||||||
// ServerKeyPath returns the path to server.key.
|
// ServerKeyPath returns the path to server.key.
|
||||||
func ServerKeyPath() string { return filepath.Join(Dir(), "server.key") }
|
func ServerKeyPath() string { return filepath.Join(Dir(), "server.key") }
|
||||||
|
|
||||||
// DBPath returns the path to the orca SQLite database. Honors $ORCA_DB
|
|
||||||
// for testability and explicit override; otherwise defaults to
|
|
||||||
// ~/.orca/orca.db under the same Dir() as the cert files.
|
|
||||||
func DBPath() string {
|
|
||||||
if p := os.Getenv("ORCA_DB"); p != "" {
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
return filepath.Join(Dir(), "orca.db")
|
|
||||||
}
|
|
||||||
|
|
||||||
// SSHKeyPath returns the path to the orca SSH private key (Ed25519,
|
|
||||||
// D-037). Used by `orca node join --type proxmox` to authenticate
|
|
||||||
// to remote Proxmox hosts after the initial password-based bootstrap.
|
|
||||||
// File mode 0600 (enforced by security.WriteKey).
|
|
||||||
func SSHKeyPath() string { return filepath.Join(Dir(), "orca_ssh_key") }
|
|
||||||
|
|
||||||
// SSHPubPath returns the path to the orca SSH public key (authorized_keys
|
|
||||||
// format). Deployed to remote Proxmox hosts during `orca node join`.
|
|
||||||
// File mode 0644 (enforced by security.WriteCert).
|
|
||||||
func SSHPubPath() string { return filepath.Join(Dir(), "orca_ssh_key.pub") }
|
|
||||||
|
|
||||||
// KnownHostsPath returns the path to the SSH known_hosts file used for
|
|
||||||
// TOFU host-key pinning (D-035). Captured on first connect, verified
|
|
||||||
// on all subsequent connects via golang.org/x/crypto/ssh/knownhosts.
|
|
||||||
func KnownHostsPath() string { return filepath.Join(Dir(), "known_hosts") }
|
|
||||||
|
|||||||
+3
-31
@@ -51,7 +51,7 @@ var doctorNetworkCmd = &cobra.Command{
|
|||||||
Use: "network",
|
Use: "network",
|
||||||
Short: "Run the network self-check (P02 impl)",
|
Short: "Run the network self-check (P02 impl)",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c := doctor.Network()
|
c := doctor.NetworkStub()
|
||||||
r, msg := c.Run(cmd.Context())
|
r, msg := c.Run(cmd.Context())
|
||||||
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-5s %s\n", c.Name, r, msg)
|
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-5s %s\n", c.Name, r, msg)
|
||||||
return nil
|
return nil
|
||||||
@@ -62,42 +62,14 @@ var doctorDBCmd = &cobra.Command{
|
|||||||
Use: "db",
|
Use: "db",
|
||||||
Short: "Run the database self-check (P02 impl)",
|
Short: "Run the database self-check (P02 impl)",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c := doctor.DB()
|
c := doctor.DBStub()
|
||||||
r, msg := c.Run(cmd.Context())
|
r, msg := c.Run(cmd.Context())
|
||||||
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-5s %s\n", c.Name, r, msg)
|
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-5s %s\n", c.Name, r, msg)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
var doctorOSCmd = &cobra.Command{
|
|
||||||
Use: "os",
|
|
||||||
Short: "Run the OS detection self-check (v0.6 P03)",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
c := doctor.OS()
|
|
||||||
r, msg := c.Run(cmd.Context())
|
|
||||||
if jsonOutput {
|
|
||||||
return printJSON(doctor.CheckResult{Name: c.Name, Result: r, Message: msg})
|
|
||||||
}
|
|
||||||
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-5s %s\n", c.Name, r, msg)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var doctorProxmoxCmd = &cobra.Command{
|
|
||||||
Use: "proxmox",
|
|
||||||
Short: "Run the proxmox node reachability self-check (v0.6 P03)",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
c := doctor.Proxmox()
|
|
||||||
r, msg := c.Run(cmd.Context())
|
|
||||||
if jsonOutput {
|
|
||||||
return printJSON(doctor.CheckResult{Name: c.Name, Result: r, Message: msg})
|
|
||||||
}
|
|
||||||
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-5s %s\n", c.Name, r, msg)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
doctorCmd.AddCommand(doctorCertCmd, doctorNetworkCmd, doctorDBCmd, doctorOSCmd, doctorProxmoxCmd)
|
doctorCmd.AddCommand(doctorCertCmd, doctorNetworkCmd, doctorDBCmd)
|
||||||
rootCmd.AddCommand(doctorCmd)
|
rootCmd.AddCommand(doctorCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-176
@@ -1,194 +1,38 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/security"
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
initCAN = "orca-internal-ca"
|
|
||||||
localhostName = "localhost"
|
|
||||||
localhostAddr = "localhost:8443"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var initCmd = &cobra.Command{
|
var initCmd = &cobra.Command{
|
||||||
Use: "init",
|
Use: "init",
|
||||||
Short: "Initialize local orca state with full bootstrap",
|
Short: "Initialize local orca state directory",
|
||||||
Long: `Initialize the local orca state directory and provision all
|
Long: "Create the local orca state directory at ~/.orca/ and write a default config file.",
|
||||||
dependencies required for ` + "`orca doctor`" + ` to pass:
|
|
||||||
|
|
||||||
1. Create the namespace directory (honors $ORCA_HOME; defaults to ~/.orca)
|
|
||||||
2. Open and migrate the SQLite database (migrations 0001..0006)
|
|
||||||
3. Bootstrap the internal CA (ca.crt + ca.key) if not already present
|
|
||||||
4. Generate the server cert (server.crt + server.key) if not already present
|
|
||||||
5. Auto-detect the local OS via /etc/os-release
|
|
||||||
6. Register a localhost node (kind=localhost, os=<detected>)
|
|
||||||
|
|
||||||
Idempotent: re-running is safe and will refresh last_seen + os on the
|
|
||||||
localhost node without regenerating certs or changing the node ID.`,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
return runInit(cmd.OutOrStdout())
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get home dir: %w", err)
|
||||||
|
}
|
||||||
|
orcaDir := filepath.Join(home, ".orca")
|
||||||
|
if err := os.MkdirAll(orcaDir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create orca dir: %w", err)
|
||||||
|
}
|
||||||
|
result := map[string]string{
|
||||||
|
"path": orcaDir,
|
||||||
|
"status": "initialized",
|
||||||
|
}
|
||||||
|
if jsonOutput {
|
||||||
|
return printJSON(result)
|
||||||
|
}
|
||||||
|
printText("✓ Initialized orca state at %s\n", orcaDir)
|
||||||
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func runInit(out interface{ Write([]byte) (int, error) }) error {
|
|
||||||
dir := certpaths.Dir()
|
|
||||||
|
|
||||||
type stepResult struct {
|
|
||||||
Label string `json:"label"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Detail string `json:"detail,omitempty"`
|
|
||||||
}
|
|
||||||
type initSummary struct {
|
|
||||||
Namespace string `json:"namespace"`
|
|
||||||
Database string `json:"database"`
|
|
||||||
CAFingerprint string `json:"ca_fingerprint,omitempty"`
|
|
||||||
CertFingerprint string `json:"cert_fingerprint,omitempty"`
|
|
||||||
OS string `json:"os"`
|
|
||||||
NodeID string `json:"node_id"`
|
|
||||||
NodeName string `json:"node_name"`
|
|
||||||
Steps []stepResult `json:"steps"`
|
|
||||||
}
|
|
||||||
summary := initSummary{Namespace: dir}
|
|
||||||
|
|
||||||
// Step 1: namespace dir.
|
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
||||||
return fmt.Errorf("create orca dir: %w", err)
|
|
||||||
}
|
|
||||||
summary.Steps = append(summary.Steps, stepResult{Label: "namespace", Status: "ok", Detail: dir})
|
|
||||||
if !jsonOutput {
|
|
||||||
fmt.Fprintf(out, "✓ Namespace dir: %s\n", dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: database + migrations.
|
|
||||||
dbPath := certpaths.DBPath()
|
|
||||||
db, err := store.Open(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("open database: %w", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
summary.Database = dbPath
|
|
||||||
summary.Steps = append(summary.Steps, stepResult{Label: "database", Status: "ok", Detail: dbPath})
|
|
||||||
if !jsonOutput {
|
|
||||||
fmt.Fprintf(out, "✓ Database initialized: %s\n", dbPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: CA bootstrap (idempotent — CAInit has a fast-path).
|
|
||||||
ca, err := security.CAInit(dir, initCAN)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("bootstrap CA: %w", err)
|
|
||||||
}
|
|
||||||
caFp := ca.Fingerprint()
|
|
||||||
summary.CAFingerprint = caFp
|
|
||||||
summary.Steps = append(summary.Steps, stepResult{Label: "ca", Status: "ok", Detail: caFp[:16] + "..."})
|
|
||||||
if !jsonOutput {
|
|
||||||
fmt.Fprintf(out, "✓ CA provisioned: fp=%s\n", caFp[:16]+"...")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: server cert (only if absent — D-036 idempotency).
|
|
||||||
certPath := certpaths.ServerCertPath()
|
|
||||||
certFp := ""
|
|
||||||
if _, err := os.Stat(certPath); err == nil {
|
|
||||||
// Already exists — load fingerprint for the summary.
|
|
||||||
if fp, err := security.Fingerprint(certPath); err == nil {
|
|
||||||
certFp = fp
|
|
||||||
}
|
|
||||||
summary.Steps = append(summary.Steps, stepResult{Label: "server-cert", Status: "skipped", Detail: "already present"})
|
|
||||||
} else if os.IsNotExist(err) {
|
|
||||||
keyPEM, csrPEM, err := security.GenerateCSR("localhost", []string{"localhost", "127.0.0.1"})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("generate server CSR: %w", err)
|
|
||||||
}
|
|
||||||
certPEM, err := ca.SignCSR(csrPEM)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("sign server CSR: %w", err)
|
|
||||||
}
|
|
||||||
if err := security.WriteCert(certPath, certPEM); err != nil {
|
|
||||||
return fmt.Errorf("write server cert: %w", err)
|
|
||||||
}
|
|
||||||
if err := security.WriteKey(certpaths.ServerKeyPath(), keyPEM); err != nil {
|
|
||||||
return fmt.Errorf("write server key: %w", err)
|
|
||||||
}
|
|
||||||
certFp = security.FingerprintOf(parseFirstCertDER(certPEM))
|
|
||||||
summary.Steps = append(summary.Steps, stepResult{Label: "server-cert", Status: "ok", Detail: certFp[:16] + "..."})
|
|
||||||
} else {
|
|
||||||
return fmt.Errorf("stat server cert: %w", err)
|
|
||||||
}
|
|
||||||
summary.CertFingerprint = certFp
|
|
||||||
if !jsonOutput {
|
|
||||||
if certFp != "" {
|
|
||||||
fmt.Fprintf(out, "✓ Server cert provisioned: fp=%s\n", certFp[:16]+"...")
|
|
||||||
} else {
|
|
||||||
fmt.Fprintf(out, "✓ Server cert: already present\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 5: OS detection.
|
|
||||||
osDetected := detectOS()
|
|
||||||
summary.OS = osDetected
|
|
||||||
summary.Steps = append(summary.Steps, stepResult{Label: "os", Status: "ok", Detail: osDetected})
|
|
||||||
if !jsonOutput {
|
|
||||||
fmt.Fprintf(out, "✓ OS detected: %s\n", osDetected)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 6: localhost node upsert (idempotent per D-036).
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
repo := store.NewNodeRepo(db)
|
|
||||||
existing, err := repo.GetByName(ctx, localhostName)
|
|
||||||
if err == nil {
|
|
||||||
// Refresh last_seen + os; keep id and joined_at.
|
|
||||||
if err := repo.UpdateLastSeenAndOS(ctx, existing.ID, osDetected); err != nil {
|
|
||||||
return fmt.Errorf("refresh localhost node: %w", err)
|
|
||||||
}
|
|
||||||
summary.NodeID = existing.ID
|
|
||||||
summary.NodeName = existing.Name
|
|
||||||
summary.Steps = append(summary.Steps, stepResult{Label: "localhost-node", Status: "refreshed", Detail: existing.ID})
|
|
||||||
if !jsonOutput {
|
|
||||||
fmt.Fprintf(out, "✓ Localhost node refreshed: %s (os=%s)\n", existing.ID, osDetected)
|
|
||||||
}
|
|
||||||
} else if err == store.ErrNotFound {
|
|
||||||
node := &model.Node{
|
|
||||||
ID: uuid.NewString(),
|
|
||||||
Name: localhostName,
|
|
||||||
Address: localhostAddr,
|
|
||||||
State: model.NodeStateReady,
|
|
||||||
JoinedAt: time.Now().UTC(),
|
|
||||||
LastSeen: time.Now().UTC(),
|
|
||||||
Kind: string(model.NodeKindLocalhost),
|
|
||||||
OS: osDetected,
|
|
||||||
}
|
|
||||||
if err := repo.Insert(ctx, node); err != nil {
|
|
||||||
return fmt.Errorf("insert localhost node: %w", err)
|
|
||||||
}
|
|
||||||
summary.NodeID = node.ID
|
|
||||||
summary.NodeName = node.Name
|
|
||||||
summary.Steps = append(summary.Steps, stepResult{Label: "localhost-node", Status: "ok", Detail: node.ID})
|
|
||||||
if !jsonOutput {
|
|
||||||
fmt.Fprintf(out, "✓ Localhost node registered: %s (os=%s)\n", node.ID, osDetected)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return fmt.Errorf("lookup localhost node: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if jsonOutput {
|
|
||||||
return printJSON(summary)
|
|
||||||
}
|
|
||||||
fmt.Fprintf(out, "\n✓ orca init complete — run `orca doctor` to verify.\n")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
rootCmd.AddCommand(initCmd)
|
rootCmd.AddCommand(initCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,205 +0,0 @@
|
|||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// initTestEnv sets ORCA_HOME to a temp dir and returns a cleanup func.
|
|
||||||
func initTestEnv(t *testing.T) (string, func()) {
|
|
||||||
t.Helper()
|
|
||||||
dir := t.TempDir()
|
|
||||||
orig := os.Getenv("ORCA_HOME")
|
|
||||||
if err := os.Setenv("ORCA_HOME", dir); err != nil {
|
|
||||||
t.Fatalf("set ORCA_HOME: %v", err)
|
|
||||||
}
|
|
||||||
return dir, func() {
|
|
||||||
if err := os.Setenv("ORCA_HOME", orig); err != nil {
|
|
||||||
t.Fatalf("restore ORCA_HOME: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// discardWriter is an io.Writer that discards all output (for tests
|
|
||||||
// that don't need to inspect init stdout).
|
|
||||||
type discardWriter struct{}
|
|
||||||
|
|
||||||
func (discardWriter) Write(p []byte) (int, error) { return len(p), nil }
|
|
||||||
|
|
||||||
var _ io.Writer = discardWriter{}
|
|
||||||
|
|
||||||
func TestInit_FullBootstrap(t *testing.T) {
|
|
||||||
dir, cleanup := initTestEnv(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
if err := runInit(discardWriter{}); err != nil {
|
|
||||||
t.Fatalf("init: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify namespace dir exists.
|
|
||||||
if _, err := os.Stat(dir); err != nil {
|
|
||||||
t.Errorf("namespace dir missing: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify CA files exist with correct modes.
|
|
||||||
caCert := certpaths.CACertPath()
|
|
||||||
caKey := certpaths.CAKeyPath()
|
|
||||||
if _, err := os.Stat(caCert); err != nil {
|
|
||||||
t.Errorf("ca.crt missing: %v", err)
|
|
||||||
}
|
|
||||||
if info, err := os.Stat(caKey); err == nil {
|
|
||||||
if info.Mode().Perm() != 0o600 {
|
|
||||||
t.Errorf("ca.key mode = %04o, want 0600", info.Mode().Perm())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
t.Errorf("ca.key missing: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify server cert exists.
|
|
||||||
if _, err := os.Stat(certpaths.ServerCertPath()); err != nil {
|
|
||||||
t.Errorf("server.crt missing: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify DB exists and has migrations applied.
|
|
||||||
db, err := store.Open(certpaths.DBPath())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open db: %v", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
version, err := store.MigrationVersion(ctx, db)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify localhost node registered with kind=localhost.
|
|
||||||
repo := store.NewNodeRepo(db)
|
|
||||||
node, err := repo.GetByName(ctx, "localhost")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get localhost node: %v", err)
|
|
||||||
}
|
|
||||||
if node.Kind != string(model.NodeKindLocalhost) {
|
|
||||||
t.Errorf("node kind = %q, want localhost", node.Kind)
|
|
||||||
}
|
|
||||||
if node.OS == "" {
|
|
||||||
t.Errorf("node os is empty, expected detected value")
|
|
||||||
}
|
|
||||||
if node.Address != "localhost:8443" {
|
|
||||||
t.Errorf("node address = %q, want localhost:8443", node.Address)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInit_IdempotentReRun(t *testing.T) {
|
|
||||||
_, cleanup := initTestEnv(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
// First init.
|
|
||||||
if err := runInit(discardWriter{}); err != nil {
|
|
||||||
t.Fatalf("first init: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture first-run state.
|
|
||||||
caCertBefore, _ := os.ReadFile(certpaths.CACertPath())
|
|
||||||
serverCertBefore, _ := os.ReadFile(certpaths.ServerCertPath())
|
|
||||||
|
|
||||||
db, err := store.Open(certpaths.DBPath())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open db: %v", err)
|
|
||||||
}
|
|
||||||
repo := store.NewNodeRepo(db)
|
|
||||||
ctx := context.Background()
|
|
||||||
nodeBefore, err := repo.GetByName(ctx, "localhost")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get node before: %v", err)
|
|
||||||
}
|
|
||||||
nodeIDBefore := nodeBefore.ID
|
|
||||||
joinedAtBefore := nodeBefore.JoinedAt
|
|
||||||
if err := db.Close(); err != nil {
|
|
||||||
t.Fatalf("close db: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait a moment so last_seen can differ.
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
|
|
||||||
// Second init (should be idempotent).
|
|
||||||
if err := runInit(discardWriter{}); err != nil {
|
|
||||||
t.Fatalf("second init: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CA and server cert must NOT have been regenerated.
|
|
||||||
caCertAfter, _ := os.ReadFile(certpaths.CACertPath())
|
|
||||||
serverCertAfter, _ := os.ReadFile(certpaths.ServerCertPath())
|
|
||||||
if string(caCertBefore) != string(caCertAfter) {
|
|
||||||
t.Error("CA was regenerated on re-run (D-036 violation)")
|
|
||||||
}
|
|
||||||
if string(serverCertBefore) != string(serverCertAfter) {
|
|
||||||
t.Error("server cert was regenerated on re-run (D-036 violation)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Node ID and joined_at must be unchanged; last_seen should be refreshed.
|
|
||||||
db, err = store.Open(certpaths.DBPath())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reopen db: %v", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
repo = store.NewNodeRepo(db)
|
|
||||||
nodeAfter, err := repo.GetByName(ctx, "localhost")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get node after: %v", err)
|
|
||||||
}
|
|
||||||
if nodeAfter.ID != nodeIDBefore {
|
|
||||||
t.Errorf("node id changed: was %s, now %s (D-036 violation)", nodeIDBefore, nodeAfter.ID)
|
|
||||||
}
|
|
||||||
if !nodeAfter.JoinedAt.Equal(joinedAtBefore) {
|
|
||||||
t.Errorf("joined_at changed: was %v, now %v (D-036 violation)", joinedAtBefore, nodeAfter.JoinedAt)
|
|
||||||
}
|
|
||||||
if !nodeAfter.LastSeen.After(joinedAtBefore) {
|
|
||||||
t.Errorf("last_seen not refreshed: was %v, now %v", joinedAtBefore, nodeAfter.LastSeen)
|
|
||||||
}
|
|
||||||
|
|
||||||
// No duplicate localhost nodes.
|
|
||||||
nodes, err := repo.List(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list nodes: %v", err)
|
|
||||||
}
|
|
||||||
localhostCount := 0
|
|
||||||
for _, n := range nodes {
|
|
||||||
if n.Name == "localhost" {
|
|
||||||
localhostCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if localhostCount != 1 {
|
|
||||||
t.Errorf("found %d localhost nodes, want 1 (idempotency)", localhostCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInit_NamespaceDirCreation(t *testing.T) {
|
|
||||||
dir, cleanup := initTestEnv(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
// The namespace dir is the ORCA_HOME temp dir itself — but let's
|
|
||||||
// point at a non-existent subdir to test MkdirAll.
|
|
||||||
subDir := filepath.Join(dir, "nested", "orca-state")
|
|
||||||
if err := os.Setenv("ORCA_HOME", subDir); err != nil {
|
|
||||||
t.Fatalf("set ORCA_HOME: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := runInit(discardWriter{}); err != nil {
|
|
||||||
t.Fatalf("init with nested dir: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(subDir); err != nil {
|
|
||||||
t.Errorf("nested namespace dir not created: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-76
@@ -5,9 +5,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -39,7 +36,6 @@ var (
|
|||||||
stopID string
|
stopID string
|
||||||
runTarget string
|
runTarget string
|
||||||
runIDKey string
|
runIDKey string
|
||||||
jobWatch bool
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var jobRunCmd = &cobra.Command{
|
var jobRunCmd = &cobra.Command{
|
||||||
@@ -116,11 +112,8 @@ var jobRunCmd = &cobra.Command{
|
|||||||
var jobListCmd = &cobra.Command{
|
var jobListCmd = &cobra.Command{
|
||||||
Use: "list",
|
Use: "list",
|
||||||
Short: "List all jobs",
|
Short: "List all jobs",
|
||||||
Long: "Display all jobs and their status. Use --watch to stream updates until Ctrl-C.",
|
Long: "Display all jobs and their status.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if jobWatch {
|
|
||||||
return watchJobs(cmd)
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -149,73 +142,6 @@ var jobListCmd = &cobra.Command{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func watchJobs(cmd *cobra.Command) error {
|
|
||||||
ctx, cancel := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM)
|
|
||||||
defer cancel()
|
|
||||||
return watchJobsCtx(cmd, ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func watchJobsCtx(cmd *cobra.Command, ctx context.Context) error {
|
|
||||||
db, closer, err := openDB()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer closer()
|
|
||||||
|
|
||||||
out := cmd.OutOrStdout()
|
|
||||||
|
|
||||||
if jsonOutput {
|
|
||||||
seen := make(map[string]string)
|
|
||||||
for snapshot := range store.NewJobRepo(db).Watch(ctx) {
|
|
||||||
current := make(map[string]bool, len(snapshot))
|
|
||||||
for _, j := range snapshot {
|
|
||||||
current[j.ID] = true
|
|
||||||
compact, _ := json.Marshal(j)
|
|
||||||
key := string(compact)
|
|
||||||
if prev, ok := seen[j.ID]; !ok || prev != key {
|
|
||||||
event := "init"
|
|
||||||
if ok {
|
|
||||||
event = "update"
|
|
||||||
}
|
|
||||||
line, _ := json.Marshal(map[string]any{"event": event, "job": j})
|
|
||||||
fmt.Fprintln(out, string(line))
|
|
||||||
seen[j.ID] = key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for id := range seen {
|
|
||||||
if !current[id] {
|
|
||||||
line, _ := json.Marshal(map[string]any{"event": "delete", "id": id})
|
|
||||||
fmt.Fprintln(out, string(line))
|
|
||||||
delete(seen, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
prevTable := ""
|
|
||||||
for snapshot := range store.NewJobRepo(db).Watch(ctx) {
|
|
||||||
table := renderJobTable(snapshot)
|
|
||||||
if table != prevTable {
|
|
||||||
fmt.Fprint(out, "\033[2J\033[H")
|
|
||||||
fmt.Fprint(out, table)
|
|
||||||
prevTable = table
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderJobTable(jobs []*model.Job) string {
|
|
||||||
if len(jobs) == 0 {
|
|
||||||
return "No jobs.\n"
|
|
||||||
}
|
|
||||||
out := fmt.Sprintf("%-36s %-20s %-12s %-8s\n", "ID", "NAME", "STATUS", "EXIT")
|
|
||||||
for _, j := range jobs {
|
|
||||||
out += fmt.Sprintf("%-36s %-20s %-12s %-8d\n", j.ID, j.Name, j.Status, j.ExitCode)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
var jobStopCmd = &cobra.Command{
|
var jobStopCmd = &cobra.Command{
|
||||||
Use: "stop [job-id]",
|
Use: "stop [job-id]",
|
||||||
Short: "Stop a running job",
|
Short: "Stop a running job",
|
||||||
@@ -309,7 +235,6 @@ func init() {
|
|||||||
jobLogsCmd.Flags().StringVar(&stopID, "id", "", "job id")
|
jobLogsCmd.Flags().StringVar(&stopID, "id", "", "job id")
|
||||||
jobRunCmd.Flags().StringVar(&runTarget, "target", "", "pin job to a specific node id (overrides bin-packing)")
|
jobRunCmd.Flags().StringVar(&runTarget, "target", "", "pin job to a specific node id (overrides bin-packing)")
|
||||||
jobRunCmd.Flags().StringVar(&runIDKey, "idempotency-key", "", "X-Orca-Idempotency-Key for cross-node dispatch dedupe")
|
jobRunCmd.Flags().StringVar(&runIDKey, "idempotency-key", "", "X-Orca-Idempotency-Key for cross-node dispatch dedupe")
|
||||||
jobListCmd.Flags().BoolVar(&jobWatch, "watch", false, "stream jobs until Ctrl-C (table refresh or --json per-event)")
|
|
||||||
|
|
||||||
jobCmd.AddCommand(jobRunCmd)
|
jobCmd.AddCommand(jobRunCmd)
|
||||||
jobCmd.AddCommand(jobListCmd)
|
jobCmd.AddCommand(jobListCmd)
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
|
||||||
)
|
|
||||||
|
|
||||||
func resetRootFlags(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
rootCmd.SetArgs(nil)
|
|
||||||
var buf bytes.Buffer
|
|
||||||
rootCmd.SetOut(&buf)
|
|
||||||
rootCmd.SetErr(&buf)
|
|
||||||
_ = rootCmd.PersistentFlags().Set("system", "false")
|
|
||||||
_ = rootCmd.PersistentFlags().Set("json", "false")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNamespaceDefaultsToUserHome(t *testing.T) {
|
|
||||||
t.Setenv("ORCA_HOME", "")
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("UserHomeDir: %v", err)
|
|
||||||
}
|
|
||||||
want := filepath.Join(home, ".orca")
|
|
||||||
if got := certpaths.Dir(); got != want {
|
|
||||||
t.Errorf("certpaths.Dir() = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNamespaceHonorsORCAHOME(t *testing.T) {
|
|
||||||
tmp := t.TempDir()
|
|
||||||
t.Setenv("ORCA_HOME", tmp)
|
|
||||||
if got := certpaths.Dir(); got != tmp {
|
|
||||||
t.Errorf("certpaths.Dir() = %q, want %q", got, tmp)
|
|
||||||
}
|
|
||||||
if got := certpaths.DBPath(); got != filepath.Join(tmp, "orca.db") {
|
|
||||||
t.Errorf("certpaths.DBPath() = %q, want %q", got, filepath.Join(tmp, "orca.db"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInitHonorsORCAHOME(t *testing.T) {
|
|
||||||
tmp := t.TempDir()
|
|
||||||
t.Setenv("ORCA_HOME", tmp)
|
|
||||||
resetRootFlags(t)
|
|
||||||
|
|
||||||
rootCmd.SetArgs([]string{"init"})
|
|
||||||
if err := rootCmd.Execute(); err != nil {
|
|
||||||
t.Fatalf("init: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := os.Stat(tmp)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("stat %s: %v", tmp, err)
|
|
||||||
}
|
|
||||||
if !info.IsDir() {
|
|
||||||
t.Errorf("%s is not a directory", tmp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemFlagSetsORCAHOME(t *testing.T) {
|
|
||||||
t.Setenv("ORCA_HOME", "")
|
|
||||||
resetRootFlags(t)
|
|
||||||
|
|
||||||
rootCmd.SetArgs([]string{"--system", "init"})
|
|
||||||
if err := rootCmd.Execute(); err != nil {
|
|
||||||
t.Fatalf("--system init: %v", err)
|
|
||||||
}
|
|
||||||
if got := os.Getenv("ORCA_HOME"); got != systemNamespaceRoot {
|
|
||||||
t.Errorf("ORCA_HOME = %q, want %q", got, systemNamespaceRoot)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemFlagConflictsWithORCAHOME(t *testing.T) {
|
|
||||||
t.Setenv("ORCA_HOME", "/custom/path")
|
|
||||||
resetRootFlags(t)
|
|
||||||
|
|
||||||
rootCmd.SetArgs([]string{"--system", "init"})
|
|
||||||
err := rootCmd.Execute()
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for --system + ORCA_HOME conflict, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInitJSONOutput(t *testing.T) {
|
|
||||||
tmp := t.TempDir()
|
|
||||||
t.Setenv("ORCA_HOME", tmp)
|
|
||||||
resetRootFlags(t)
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
rootCmd.SetOut(&buf)
|
|
||||||
rootCmd.SetArgs([]string{"init", "--json"})
|
|
||||||
if err := rootCmd.Execute(); err != nil {
|
|
||||||
t.Fatalf("init --json: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// v0.6: init --json now outputs a full bootstrap summary object.
|
|
||||||
var result map[string]any
|
|
||||||
if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &result); err != nil {
|
|
||||||
t.Fatalf("unmarshal init output: %v\noutput: %s", err, buf.String())
|
|
||||||
}
|
|
||||||
if result["namespace"] != tmp {
|
|
||||||
t.Errorf("init --json namespace = %q, want %q", result["namespace"], tmp)
|
|
||||||
}
|
|
||||||
if result["os"] == nil || result["os"] == "" {
|
|
||||||
t.Errorf("init --json os is missing/empty")
|
|
||||||
}
|
|
||||||
if result["node_id"] == nil || result["node_id"] == "" {
|
|
||||||
t.Errorf("init --json node_id is missing/empty")
|
|
||||||
}
|
|
||||||
steps, ok := result["steps"].([]any)
|
|
||||||
if !ok || len(steps) < 6 {
|
|
||||||
t.Errorf("init --json steps: expected 6+ entries, got %v", result["steps"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemFlagIsPersistent(t *testing.T) {
|
|
||||||
for _, name := range []string{"system", "json"} {
|
|
||||||
f := rootCmd.PersistentFlags().Lookup(name)
|
|
||||||
if f == nil {
|
|
||||||
t.Errorf("persistent flag %q not found", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+57
-221
@@ -3,12 +3,10 @@ package cli
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"path/filepath"
|
||||||
"syscall"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -17,13 +15,20 @@ import (
|
|||||||
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
||||||
"git.cloudinit.dev/coreci/orca/internal/engine"
|
"git.cloudinit.dev/coreci/orca/internal/engine"
|
||||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||||
"git.cloudinit.dev/coreci/orca/internal/proxmox"
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/security"
|
"git.cloudinit.dev/coreci/orca/internal/security"
|
||||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func dbPath() string {
|
||||||
|
if p := os.Getenv("ORCA_DB"); p != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
return filepath.Join(home, ".orca", "orca.db")
|
||||||
|
}
|
||||||
|
|
||||||
func openDB() (*sql.DB, func() error, error) {
|
func openDB() (*sql.DB, func() error, error) {
|
||||||
db, err := store.Open(certpaths.DBPath())
|
db, err := store.Open(dbPath())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
@@ -48,15 +53,7 @@ var (
|
|||||||
joinName string
|
joinName string
|
||||||
joinAddr string
|
joinAddr string
|
||||||
joinCAFinger string
|
joinCAFinger string
|
||||||
joinType string
|
|
||||||
joinHost string
|
|
||||||
joinSSHUser string
|
|
||||||
joinPassword string
|
|
||||||
joinSSHPort int
|
|
||||||
proxmoxUser string
|
|
||||||
proxmoxRole string
|
|
||||||
leaveID string
|
leaveID string
|
||||||
nodeWatch bool
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var nodeCmd = &cobra.Command{
|
var nodeCmd = &cobra.Command{
|
||||||
@@ -68,141 +65,58 @@ var nodeCmd = &cobra.Command{
|
|||||||
var nodeJoinCmd = &cobra.Command{
|
var nodeJoinCmd = &cobra.Command{
|
||||||
Use: "join",
|
Use: "join",
|
||||||
Short: "Join a node to the orca registry",
|
Short: "Join a node to the orca registry",
|
||||||
Long: `Register a node in the local orca registry. Persisted to SQLite.
|
Long: "Register a node in the local orca registry. Persisted to SQLite.",
|
||||||
|
|
||||||
Node types (via --type):
|
|
||||||
localhost (default): register a local or Linux node (existing behavior)
|
|
||||||
proxmox: SSH-bootstrap a remote Proxmox VE 8/9 host
|
|
||||||
(deploys orca pubkey, creates orca user + PVE role +
|
|
||||||
sudoers allowlist; requires --host + --password)`,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if joinType == "proxmox" {
|
if joinName == "" {
|
||||||
return joinProxmox(cmd)
|
return fmt.Errorf("--name is required")
|
||||||
|
}
|
||||||
|
if joinAddr == "" {
|
||||||
|
joinAddr = "localhost:8443"
|
||||||
}
|
}
|
||||||
return joinLocal(cmd)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// joinLocal is the existing localhost/Linux node join flow (fingerprint
|
// REQ-026: if --ca-fingerprint is set, verify the on-disk CA
|
||||||
// check + registry.Insert).
|
// matches the pinned value before we touch the registry. This
|
||||||
func joinLocal(cmd *cobra.Command) error {
|
// prevents typos in the operator-supplied fingerprint from
|
||||||
if joinName == "" {
|
// silently degrading to "no pin" and accepting any cert.
|
||||||
return fmt.Errorf("--name is required")
|
if joinCAFinger != "" {
|
||||||
}
|
fp, err := security.Fingerprint(certpaths.CACertPath())
|
||||||
if joinAddr == "" {
|
if err != nil {
|
||||||
joinAddr = "localhost:8443"
|
return fmt.Errorf("--ca-fingerprint set but local CA is missing: %w (run `orca cert ca-init` first)", err)
|
||||||
}
|
}
|
||||||
|
if fp != joinCAFinger {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"CA fingerprint mismatch: on-disk=%s, pinned=%s — refusing to join (REQ-026)",
|
||||||
|
fp, joinCAFinger,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// REQ-026: if --ca-fingerprint is set, verify the on-disk CA
|
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
|
||||||
// matches the pinned value before we touch the registry. This
|
defer cancel()
|
||||||
// prevents typos in the operator-supplied fingerprint from
|
|
||||||
// silently degrading to "no pin" and accepting any cert.
|
registry, closer, err := nodeRegistry()
|
||||||
if joinCAFinger != "" {
|
|
||||||
fp, err := security.Fingerprint(certpaths.CACertPath())
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("--ca-fingerprint set but local CA is missing: %w (run `orca cert ca-init` first)", err)
|
return err
|
||||||
}
|
}
|
||||||
if fp != joinCAFinger {
|
defer closer()
|
||||||
return fmt.Errorf(
|
|
||||||
"CA fingerprint mismatch: on-disk=%s, pinned=%s — refusing to join (REQ-026)",
|
node := &model.Node{
|
||||||
fp, joinCAFinger,
|
ID: uuid.NewString(),
|
||||||
)
|
Name: joinName,
|
||||||
|
Address: joinAddr,
|
||||||
|
State: model.NodeStateReady,
|
||||||
|
JoinedAt: time.Now().UTC(),
|
||||||
|
LastSeen: time.Now().UTC(),
|
||||||
}
|
}
|
||||||
}
|
if err := registry.Join(ctx, node); err != nil {
|
||||||
|
return err
|
||||||
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
|
}
|
||||||
defer cancel()
|
if jsonOutput {
|
||||||
|
return printJSON(node)
|
||||||
registry, closer, err := nodeRegistry()
|
}
|
||||||
if err != nil {
|
fmt.Fprintf(cmd.OutOrStdout(), "✓ Node joined: %s (%s) at %s\n", node.ID, node.Name, node.Address)
|
||||||
return err
|
return nil
|
||||||
}
|
},
|
||||||
defer closer()
|
|
||||||
|
|
||||||
node := &model.Node{
|
|
||||||
ID: uuid.NewString(),
|
|
||||||
Name: joinName,
|
|
||||||
Address: joinAddr,
|
|
||||||
State: model.NodeStateReady,
|
|
||||||
JoinedAt: time.Now().UTC(),
|
|
||||||
LastSeen: time.Now().UTC(),
|
|
||||||
}
|
|
||||||
if err := registry.Join(ctx, node); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if jsonOutput {
|
|
||||||
return printJSON(node)
|
|
||||||
}
|
|
||||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ Node joined: %s (%s) at %s\n", node.ID, node.Name, node.Address)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// joinProxmox bootstraps a remote Proxmox VE 8/9 host via SSH and
|
|
||||||
// registers it as an orca node (REQ-050, REQ-051). The password is
|
|
||||||
// never persisted (D-031).
|
|
||||||
func joinProxmox(cmd *cobra.Command) error {
|
|
||||||
if joinHost == "" {
|
|
||||||
return fmt.Errorf("--host is required for --type proxmox")
|
|
||||||
}
|
|
||||||
password := joinPassword
|
|
||||||
if password == "" {
|
|
||||||
password = os.Getenv("ORCA_PROXMOX_PASSWORD")
|
|
||||||
}
|
|
||||||
if password == "" {
|
|
||||||
return fmt.Errorf("password is required for --type proxmox (use --password or $ORCA_PROXMOX_PASSWORD)")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(cmd.Context(), 60*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
result, err := proxmox.BootstrapProxmox(ctx, proxmox.Options{
|
|
||||||
Host: joinHost,
|
|
||||||
SSHUser: joinSSHUser,
|
|
||||||
Password: password,
|
|
||||||
ProxmoxUser: proxmoxUser,
|
|
||||||
ProxmoxRole: proxmoxRole,
|
|
||||||
SSHPort: joinSSHPort,
|
|
||||||
Logger: newLogger(),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("proxmox bootstrap: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zero the password byte slice (D-031 — never persist, minimize memory exposure).
|
|
||||||
pwBytes := []byte(password)
|
|
||||||
for i := range pwBytes {
|
|
||||||
pwBytes[i] = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register the proxmox node in the orca registry.
|
|
||||||
registry, closer, err := nodeRegistry()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer closer()
|
|
||||||
|
|
||||||
regCtx, regCancel := context.WithTimeout(ctx, 5*time.Second)
|
|
||||||
defer regCancel()
|
|
||||||
|
|
||||||
node := &model.Node{
|
|
||||||
ID: uuid.NewString(),
|
|
||||||
Name: result.NodeName,
|
|
||||||
Address: result.NodeAddress,
|
|
||||||
State: model.NodeStateReady,
|
|
||||||
JoinedAt: time.Now().UTC(),
|
|
||||||
LastSeen: time.Now().UTC(),
|
|
||||||
Kind: string(model.NodeKindProxmox),
|
|
||||||
OS: "pve",
|
|
||||||
}
|
|
||||||
if err := registry.Join(regCtx, node); err != nil {
|
|
||||||
return fmt.Errorf("register proxmox node: %w", err)
|
|
||||||
}
|
|
||||||
if jsonOutput {
|
|
||||||
return printJSON(node)
|
|
||||||
}
|
|
||||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ Proxmox node joined: %s (%s) at %s\n", node.ID, node.Name, node.Address)
|
|
||||||
fmt.Fprintf(cmd.OutOrStdout(), " role: %s, user: %s@pam\n", proxmoxRole, proxmoxUser)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var nodeLeaveCmd = &cobra.Command{
|
var nodeLeaveCmd = &cobra.Command{
|
||||||
@@ -241,11 +155,8 @@ var nodeLeaveCmd = &cobra.Command{
|
|||||||
var nodeListCmd = &cobra.Command{
|
var nodeListCmd = &cobra.Command{
|
||||||
Use: "list",
|
Use: "list",
|
||||||
Short: "List all nodes in the orca registry",
|
Short: "List all nodes in the orca registry",
|
||||||
Long: "Display all registered nodes and their state. Use --watch to stream updates until Ctrl-C.",
|
Long: "Display all registered nodes and their state.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if nodeWatch {
|
|
||||||
return watchNodes(cmd)
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -274,86 +185,11 @@ var nodeListCmd = &cobra.Command{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func watchNodes(cmd *cobra.Command) error {
|
|
||||||
ctx, cancel := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM)
|
|
||||||
defer cancel()
|
|
||||||
return watchNodesCtx(cmd, ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func watchNodesCtx(cmd *cobra.Command, ctx context.Context) error {
|
|
||||||
db, closer, err := openDB()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer closer()
|
|
||||||
|
|
||||||
out := cmd.OutOrStdout()
|
|
||||||
|
|
||||||
if jsonOutput {
|
|
||||||
seen := make(map[string]string)
|
|
||||||
for snapshot := range store.NewNodeRepo(db).Watch(ctx) {
|
|
||||||
current := make(map[string]bool, len(snapshot))
|
|
||||||
for _, n := range snapshot {
|
|
||||||
current[n.ID] = true
|
|
||||||
compact, _ := json.Marshal(n)
|
|
||||||
key := string(compact)
|
|
||||||
if prev, ok := seen[n.ID]; !ok || prev != key {
|
|
||||||
event := "init"
|
|
||||||
if ok {
|
|
||||||
event = "update"
|
|
||||||
}
|
|
||||||
line, _ := json.Marshal(map[string]any{"event": event, "node": n})
|
|
||||||
fmt.Fprintln(out, string(line))
|
|
||||||
seen[n.ID] = key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for id := range seen {
|
|
||||||
if !current[id] {
|
|
||||||
line, _ := json.Marshal(map[string]any{"event": "delete", "id": id})
|
|
||||||
fmt.Fprintln(out, string(line))
|
|
||||||
delete(seen, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
prevTable := ""
|
|
||||||
for snapshot := range store.NewNodeRepo(db).Watch(ctx) {
|
|
||||||
table := renderNodeTable(snapshot)
|
|
||||||
if table != prevTable {
|
|
||||||
fmt.Fprint(out, "\033[2J\033[H")
|
|
||||||
fmt.Fprint(out, table)
|
|
||||||
prevTable = table
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderNodeTable(nodes []*model.Node) string {
|
|
||||||
if len(nodes) == 0 {
|
|
||||||
return "No nodes registered.\n"
|
|
||||||
}
|
|
||||||
out := fmt.Sprintf("%-36s %-20s %-22s %-10s\n", "ID", "NAME", "ADDRESS", "STATE")
|
|
||||||
for _, n := range nodes {
|
|
||||||
out += fmt.Sprintf("%-36s %-20s %-22s %-10s\n", n.ID, n.Name, n.Address, n.State)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
nodeJoinCmd.Flags().StringVar(&joinName, "name", "", "node name (required for --type localhost)")
|
nodeJoinCmd.Flags().StringVar(&joinName, "name", "", "node name (required)")
|
||||||
nodeJoinCmd.Flags().StringVar(&joinAddr, "addr", "", "node address (default localhost:8443)")
|
nodeJoinCmd.Flags().StringVar(&joinAddr, "addr", "", "node address (default localhost:8443)")
|
||||||
nodeJoinCmd.Flags().StringVar(&joinCAFinger, "ca-fingerprint", "", "pin CA cert SHA-256 (REQ-026); fails if on-disk CA doesn't match")
|
nodeJoinCmd.Flags().StringVar(&joinCAFinger, "ca-fingerprint", "", "pin CA cert SHA-256 (REQ-026); fails if on-disk CA doesn't match")
|
||||||
nodeJoinCmd.Flags().StringVar(&joinType, "type", "localhost", "node type: localhost (default) or proxmox (SSH bootstrap)")
|
|
||||||
nodeJoinCmd.Flags().StringVar(&joinHost, "host", "", "proxmox host address (IP/hostname, no port; required for --type proxmox)")
|
|
||||||
nodeJoinCmd.Flags().StringVar(&joinSSHUser, "ssh-user", "root", "SSH username for proxmox bootstrap (default root)")
|
|
||||||
nodeJoinCmd.Flags().StringVar(&joinPassword, "password", "", "SSH password for proxmox bootstrap (never persisted; prefer $ORCA_PROXMOX_PASSWORD)")
|
|
||||||
nodeJoinCmd.Flags().IntVar(&joinSSHPort, "ssh-port", 22, "SSH port for proxmox bootstrap (default 22)")
|
|
||||||
nodeJoinCmd.Flags().StringVar(&proxmoxUser, "proxmox-user", "orca", "Linux system user to create on the proxmox host (config-overridable)")
|
|
||||||
nodeJoinCmd.Flags().StringVar(&proxmoxRole, "proxmox-role", "OrcaOperator", "PVE custom role to create (config-overridable)")
|
|
||||||
nodeLeaveCmd.Flags().StringVar(&leaveID, "id", "", "node id")
|
nodeLeaveCmd.Flags().StringVar(&leaveID, "id", "", "node id")
|
||||||
nodeListCmd.Flags().BoolVar(&nodeWatch, "watch", false, "stream nodes until Ctrl-C (table refresh or --json per-event)")
|
|
||||||
|
|
||||||
nodeCmd.AddCommand(nodeJoinCmd)
|
nodeCmd.AddCommand(nodeJoinCmd)
|
||||||
nodeCmd.AddCommand(nodeLeaveCmd)
|
nodeCmd.AddCommand(nodeLeaveCmd)
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
package cli
|
|
||||||
|
|
||||||
import "git.cloudinit.dev/coreci/orca/internal/osdetect"
|
|
||||||
|
|
||||||
// detectOS reads /etc/os-release and returns the ID= value.
|
|
||||||
// Delegates to internal/osdetect to avoid import cycles with
|
|
||||||
// internal/doctor (both need OS detection).
|
|
||||||
func detectOS() string {
|
|
||||||
return osdetect.Detect()
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The osdetect parsing/detection logic is tested in
|
|
||||||
// internal/osdetect/osdetect_test.go. These tests verify the cli
|
|
||||||
// wrapper delegates correctly.
|
|
||||||
|
|
||||||
func TestDetectOS_DelegatesToPackage(t *testing.T) {
|
|
||||||
// On this host (Ubuntu), detectOS should return "ubuntu" via the
|
|
||||||
// osdetect package. If /etc/os-release is absent (e.g., in a
|
|
||||||
// minimal container), it returns "linux".
|
|
||||||
result := detectOS()
|
|
||||||
if result == "" {
|
|
||||||
t.Error("detectOS returned empty string, expected a non-empty OS ID")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-19
@@ -3,7 +3,6 @@ package cli
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -14,8 +13,6 @@ var (
|
|||||||
buildTime = "unknown"
|
buildTime = "unknown"
|
||||||
)
|
)
|
||||||
|
|
||||||
const systemNamespaceRoot = "/root/.orca"
|
|
||||||
|
|
||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
Use: "orca",
|
Use: "orca",
|
||||||
Short: "Orca — offline/CLI-first orchestration engine",
|
Short: "Orca — offline/CLI-first orchestration engine",
|
||||||
@@ -24,27 +21,12 @@ inspired by HashiCorp Nomad, prioritizing stability, security, and simplicity
|
|||||||
over feature richness.`,
|
over feature richness.`,
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
SilenceErrors: true,
|
SilenceErrors: true,
|
||||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
if systemNamespace {
|
|
||||||
if existing := os.Getenv("ORCA_HOME"); existing != "" && existing != systemNamespaceRoot {
|
|
||||||
return fmt.Errorf("--system conflicts with ORCA_HOME=%q (already set); unset ORCA_HOME or drop --system", existing)
|
|
||||||
}
|
|
||||||
if err := os.Setenv("ORCA_HOME", systemNamespaceRoot); err != nil {
|
|
||||||
return fmt.Errorf("set ORCA_HOME for --system: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var jsonOutput bool
|
||||||
jsonOutput bool
|
|
||||||
systemNamespace bool
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
rootCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "output in JSON format")
|
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)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Execute() error {
|
func Execute() error {
|
||||||
|
|||||||
@@ -1,287 +0,0 @@
|
|||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestWatchJobs_JSONStreaming(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
dbPath := filepath.Join(dir, "orca.db")
|
|
||||||
db, err := store.Open(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open db: %v", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
repo := store.NewJobRepo(db)
|
|
||||||
bgCtx := context.Background()
|
|
||||||
_ = repo.Insert(bgCtx, &model.Job{ID: "seed-job", Name: "seed", Spec: "t", Status: model.JobStatusPending})
|
|
||||||
|
|
||||||
t.Setenv("ORCA_DB", dbPath)
|
|
||||||
|
|
||||||
jsonOutput = true
|
|
||||||
t.Cleanup(func() { jsonOutput = false })
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
rootCmd.SetOut(&buf)
|
|
||||||
rootCmd.SetErr(&buf)
|
|
||||||
t.Cleanup(func() { rootCmd.SetOut(os.Stdout); rootCmd.SetErr(os.Stderr) })
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(bgCtx)
|
|
||||||
|
|
||||||
done := make(chan error, 1)
|
|
||||||
go func() { done <- watchJobsCtx(rootCmd, ctx) }()
|
|
||||||
|
|
||||||
// First yield is immediate (G-002); wait for it.
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
|
|
||||||
_ = repo.Insert(bgCtx, &model.Job{ID: "watch-job", Name: "watch", Spec: "t", Status: model.JobStatusPending})
|
|
||||||
|
|
||||||
// Wait for at least one ticker interval (default 1s) to capture the change.
|
|
||||||
time.Sleep(1100 * time.Millisecond)
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Fatal("watchJobsCtx did not return within 2s after cancel")
|
|
||||||
}
|
|
||||||
|
|
||||||
output := buf.String()
|
|
||||||
if !strings.Contains(output, `"event":"init"`) {
|
|
||||||
t.Errorf("expected init event, got: %s", output)
|
|
||||||
}
|
|
||||||
if !strings.Contains(output, "watch-job") {
|
|
||||||
t.Errorf("expected watch-job in output, got: %s", output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWatchJobs_TableRefresh(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
dbPath := filepath.Join(dir, "orca.db")
|
|
||||||
db, err := store.Open(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open db: %v", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
repo := store.NewJobRepo(db)
|
|
||||||
bgCtx := context.Background()
|
|
||||||
_ = repo.Insert(bgCtx, &model.Job{ID: "seed-job", Name: "seed", Spec: "t", Status: model.JobStatusPending})
|
|
||||||
|
|
||||||
t.Setenv("ORCA_DB", dbPath)
|
|
||||||
|
|
||||||
jsonOutput = false
|
|
||||||
t.Cleanup(func() { jsonOutput = false })
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
rootCmd.SetOut(&buf)
|
|
||||||
rootCmd.SetErr(&buf)
|
|
||||||
t.Cleanup(func() { rootCmd.SetOut(os.Stdout); rootCmd.SetErr(os.Stderr) })
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(bgCtx)
|
|
||||||
|
|
||||||
done := make(chan error, 1)
|
|
||||||
go func() { done <- watchJobsCtx(rootCmd, ctx) }()
|
|
||||||
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
|
|
||||||
_ = repo.Insert(bgCtx, &model.Job{ID: "table-job", Name: "table", Spec: "t", Status: model.JobStatusPending})
|
|
||||||
|
|
||||||
time.Sleep(1100 * time.Millisecond)
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Fatal("watchJobsCtx did not return within 2s after cancel")
|
|
||||||
}
|
|
||||||
|
|
||||||
output := buf.String()
|
|
||||||
if !strings.Contains(output, "\033[2J\033[H") {
|
|
||||||
t.Errorf("expected clear-screen escape in table watch output, got: %s", output)
|
|
||||||
}
|
|
||||||
if !strings.Contains(output, "table-job") {
|
|
||||||
t.Errorf("expected table-job in output, got: %s", output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWatchNodes_JSONStreaming(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
dbPath := filepath.Join(dir, "orca.db")
|
|
||||||
db, err := store.Open(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open db: %v", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
repo := store.NewNodeRepo(db)
|
|
||||||
bgCtx := context.Background()
|
|
||||||
_ = repo.Insert(bgCtx, &model.Node{
|
|
||||||
ID: "seed-node", Name: "seed", Address: "addr",
|
|
||||||
State: model.NodeStateReady, JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(),
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Setenv("ORCA_DB", dbPath)
|
|
||||||
|
|
||||||
jsonOutput = true
|
|
||||||
t.Cleanup(func() { jsonOutput = false })
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
rootCmd.SetOut(&buf)
|
|
||||||
rootCmd.SetErr(&buf)
|
|
||||||
t.Cleanup(func() { rootCmd.SetOut(os.Stdout); rootCmd.SetErr(os.Stderr) })
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(bgCtx)
|
|
||||||
|
|
||||||
done := make(chan error, 1)
|
|
||||||
go func() { done <- watchNodesCtx(rootCmd, ctx) }()
|
|
||||||
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
|
|
||||||
_ = repo.Insert(bgCtx, &model.Node{
|
|
||||||
ID: "watch-node", Name: "watch", Address: "addr2",
|
|
||||||
State: model.NodeStateReady, JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(),
|
|
||||||
})
|
|
||||||
|
|
||||||
time.Sleep(1100 * time.Millisecond)
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Fatal("watchNodesCtx did not return within 2s after cancel")
|
|
||||||
}
|
|
||||||
|
|
||||||
output := buf.String()
|
|
||||||
initFound := false
|
|
||||||
watchNodeFound := false
|
|
||||||
for _, line := range strings.Split(output, "\n") {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var event map[string]any
|
|
||||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if event["event"] == "init" {
|
|
||||||
initFound = true
|
|
||||||
if node, ok := event["node"].(map[string]any); ok {
|
|
||||||
if node["id"] == "watch-node" {
|
|
||||||
watchNodeFound = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !initFound {
|
|
||||||
t.Errorf("expected init event in JSON stream, got: %s", output)
|
|
||||||
}
|
|
||||||
if !watchNodeFound {
|
|
||||||
t.Errorf("expected watch-node in JSON stream, got: %s", output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWatchNodes_TableRefresh(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
dbPath := filepath.Join(dir, "orca.db")
|
|
||||||
db, err := store.Open(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open db: %v", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
repo := store.NewNodeRepo(db)
|
|
||||||
bgCtx := context.Background()
|
|
||||||
_ = repo.Insert(bgCtx, &model.Node{
|
|
||||||
ID: "seed-node", Name: "seed", Address: "addr",
|
|
||||||
State: model.NodeStateReady, JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(),
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Setenv("ORCA_DB", dbPath)
|
|
||||||
|
|
||||||
jsonOutput = false
|
|
||||||
t.Cleanup(func() { jsonOutput = false })
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
rootCmd.SetOut(&buf)
|
|
||||||
rootCmd.SetErr(&buf)
|
|
||||||
t.Cleanup(func() { rootCmd.SetOut(os.Stdout); rootCmd.SetErr(os.Stderr) })
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(bgCtx)
|
|
||||||
|
|
||||||
done := make(chan error, 1)
|
|
||||||
go func() { done <- watchNodesCtx(rootCmd, ctx) }()
|
|
||||||
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
|
|
||||||
_ = repo.Insert(bgCtx, &model.Node{
|
|
||||||
ID: "table-node", Name: "table", Address: "addr2",
|
|
||||||
State: model.NodeStateReady, JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(),
|
|
||||||
})
|
|
||||||
|
|
||||||
time.Sleep(1100 * time.Millisecond)
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Fatal("watchNodesCtx did not return within 2s after cancel")
|
|
||||||
}
|
|
||||||
|
|
||||||
output := buf.String()
|
|
||||||
if !strings.Contains(output, "\033[2J\033[H") {
|
|
||||||
t.Errorf("expected clear-screen escape in table watch output, got: %s", output)
|
|
||||||
}
|
|
||||||
if !strings.Contains(output, "table-node") {
|
|
||||||
t.Errorf("expected table-node in output, got: %s", output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRenderJobTable(t *testing.T) {
|
|
||||||
jobs := []*model.Job{
|
|
||||||
{ID: "j1", Name: "alpha", Status: "running", ExitCode: 0},
|
|
||||||
{ID: "j2", Name: "beta", Status: "done", ExitCode: 0},
|
|
||||||
}
|
|
||||||
out := renderJobTable(jobs)
|
|
||||||
if !strings.Contains(out, "j1") || !strings.Contains(out, "alpha") {
|
|
||||||
t.Errorf("renderJobTable missing job 1: %s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "j2") || !strings.Contains(out, "beta") {
|
|
||||||
t.Errorf("renderJobTable missing job 2: %s", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRenderJobTableEmpty(t *testing.T) {
|
|
||||||
out := renderJobTable(nil)
|
|
||||||
if !strings.Contains(out, "No jobs") {
|
|
||||||
t.Errorf("expected empty message, got: %s", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRenderNodeTable(t *testing.T) {
|
|
||||||
nodes := []*model.Node{
|
|
||||||
{ID: "n1", Name: "alpha", Address: "localhost:8443", State: "ready"},
|
|
||||||
}
|
|
||||||
out := renderNodeTable(nodes)
|
|
||||||
if !strings.Contains(out, "n1") || !strings.Contains(out, "alpha") {
|
|
||||||
t.Errorf("renderNodeTable missing node: %s", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRenderNodeTableEmpty(t *testing.T) {
|
|
||||||
out := renderNodeTable(nil)
|
|
||||||
if !strings.Contains(out, "No nodes") {
|
|
||||||
t.Errorf("expected empty message, got: %s", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+13
-293
@@ -19,21 +19,12 @@ import (
|
|||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/crypto/ssh"
|
|
||||||
"golang.org/x/crypto/ssh/knownhosts"
|
|
||||||
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
||||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/osdetect"
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/security"
|
"git.cloudinit.dev/coreci/orca/internal/security"
|
||||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/transport"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Result is the outcome of a single check.
|
// Result is the outcome of a single check.
|
||||||
@@ -72,10 +63,8 @@ func All() []Check {
|
|||||||
CertServer(),
|
CertServer(),
|
||||||
CertExpiry(),
|
CertExpiry(),
|
||||||
CertFingerprint(),
|
CertFingerprint(),
|
||||||
OS(),
|
NetworkStub(),
|
||||||
Network(),
|
DBStub(),
|
||||||
Proxmox(),
|
|
||||||
DB(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,297 +177,28 @@ func CertFingerprint() Check {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DB checks SQLite integrity and migration version (REQ-032 completion).
|
// NetworkStub is a stub for the network check; full impl in P02.
|
||||||
func DB() Check {
|
func NetworkStub() Check {
|
||||||
return Check{
|
|
||||||
Name: "db",
|
|
||||||
Description: "SQLite integrity_check + migration version",
|
|
||||||
Run: func(ctx context.Context) (Result, string) {
|
|
||||||
path := certpaths.DBPath()
|
|
||||||
db, err := store.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return ResultFail, fmt.Sprintf("open db: %v", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
var integrity string
|
|
||||||
if err := db.QueryRowContext(ctx, "PRAGMA integrity_check").Scan(&integrity); err != nil {
|
|
||||||
return ResultFail, fmt.Sprintf("integrity_check: %v", err)
|
|
||||||
}
|
|
||||||
if !strings.EqualFold(integrity, "ok") {
|
|
||||||
return ResultFail, fmt.Sprintf("integrity_check: %s", integrity)
|
|
||||||
}
|
|
||||||
|
|
||||||
version, err := store.MigrationVersion(ctx, db)
|
|
||||||
if err != nil {
|
|
||||||
return ResultFail, fmt.Sprintf("migration version: %v", err)
|
|
||||||
}
|
|
||||||
if version == "" {
|
|
||||||
return ResultWarn, "integrity OK but no migrations applied (fresh db)"
|
|
||||||
}
|
|
||||||
return ResultPass, fmt.Sprintf("integrity OK, migrations up to %s", version)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Network probes peer reachability via mTLS /healthz (REQ-032 completion).
|
|
||||||
// Peers are sourced from the persisted nodes table (not the in-memory
|
|
||||||
// PeerRegistry, which is empty at CLI time). Zero peers → WARN (single-node
|
|
||||||
// is legitimate). Any peer unreachable → FAIL (D-038).
|
|
||||||
func Network() Check {
|
|
||||||
return Check{
|
return Check{
|
||||||
Name: "network",
|
Name: "network",
|
||||||
Description: "peer reachability via mTLS /healthz probe",
|
Description: "TCP reachability + mTLS handshake (full impl in P02)",
|
||||||
Run: func(ctx context.Context) (Result, string) {
|
Run: func(_ context.Context) (Result, string) {
|
||||||
caPath := certpaths.CACertPath()
|
return ResultWarn, "network check is a stub in P01; full impl in P02"
|
||||||
certPath := certpaths.ServerCertPath()
|
|
||||||
keyPath := certpaths.ServerKeyPath()
|
|
||||||
|
|
||||||
// Check that cert files exist before attempting probes.
|
|
||||||
if _, err := os.Stat(caPath); err != nil {
|
|
||||||
return ResultFail, fmt.Sprintf("CA cert missing: %v (run `orca cert init`)", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
path := certpaths.DBPath()
|
|
||||||
db, err := store.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return ResultFail, fmt.Sprintf("open db: %v", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
nodes, err := store.NewNodeRepo(db).List(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return ResultFail, fmt.Sprintf("list nodes: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
live := make([]*model.Node, 0, len(nodes))
|
|
||||||
for _, n := range nodes {
|
|
||||||
if n.State != model.NodeStateLeft {
|
|
||||||
live = append(live, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(live) == 0 {
|
|
||||||
return ResultWarn, "no peers registered (single-node?)"
|
|
||||||
}
|
|
||||||
|
|
||||||
var lines []string
|
|
||||||
anyFail := false
|
|
||||||
for _, n := range live {
|
|
||||||
probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
||||||
err := probeHealthz(probeCtx, caPath, certPath, keyPath, n.Name, n.Address)
|
|
||||||
cancel()
|
|
||||||
if err != nil {
|
|
||||||
anyFail = true
|
|
||||||
lines = append(lines, fmt.Sprintf(" ✗ %s (%s): %v", n.Name, n.Address, err))
|
|
||||||
} else {
|
|
||||||
lines = append(lines, fmt.Sprintf(" ✓ %s (%s)", n.Name, n.Address))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result := ResultPass
|
|
||||||
if anyFail {
|
|
||||||
result = ResultFail
|
|
||||||
}
|
|
||||||
return result, strings.Join(lines, "\n")
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// probeHealthz opens an mTLS connection to the peer and GETs /healthz.
|
// DBStub is a stub for the database check; full impl in P02.
|
||||||
func probeHealthz(ctx context.Context, caPath, certPath, keyPath, serverName, addr string) error {
|
func DBStub() Check {
|
||||||
client, err := transport.NewMTLSClient(caPath, serverName, certPath, keyPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("mTLS client: %w", err)
|
|
||||||
}
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://"+addr+"/healthz", nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("request: %w", err)
|
|
||||||
}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("probe: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return fmt.Errorf("healthz returned %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// OS checks that the auto-detected OS matches the stored localhost
|
|
||||||
// node's os field (REQ-052). Drift (e.g., OS upgraded since init)
|
|
||||||
// returns WARN; match returns PASS; missing localhost node returns FAIL.
|
|
||||||
func OS() Check {
|
|
||||||
return Check{
|
return Check{
|
||||||
Name: "os",
|
Name: "db",
|
||||||
Description: "localhost OS detection vs stored node row",
|
Description: "SQLite open + migration apply (full impl in P02)",
|
||||||
Run: func(ctx context.Context) (Result, string) {
|
Run: func(_ context.Context) (Result, string) {
|
||||||
detected := osdetect.Detect()
|
return ResultWarn, "db check is a stub in P01; full impl in P02"
|
||||||
|
|
||||||
db, err := store.Open(certpaths.DBPath())
|
|
||||||
if err != nil {
|
|
||||||
return ResultFail, fmt.Sprintf("open db: %v", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
node, err := store.NewNodeRepo(db).GetByName(ctx, "localhost")
|
|
||||||
if err == store.ErrNotFound {
|
|
||||||
return ResultFail, "no localhost node registered — run `orca init`"
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return ResultFail, fmt.Sprintf("lookup localhost node: %v", err)
|
|
||||||
}
|
|
||||||
if node.OS == "" {
|
|
||||||
return ResultWarn, fmt.Sprintf("localhost node has no os field (pre-0006 row?); detected=%s — re-run `orca init` to refresh", detected)
|
|
||||||
}
|
|
||||||
if node.OS != detected {
|
|
||||||
return ResultWarn, fmt.Sprintf("OS drift: init=%s, now=%s — re-run `orca init` to refresh", node.OS, detected)
|
|
||||||
}
|
|
||||||
return ResultPass, fmt.Sprintf("localhost os=%s (matches /etc/os-release)", detected)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Proxmox probes each kind=proxmox node via SSH with `pveversion`
|
|
||||||
// (REQ-052). Clones the Network() pattern: list nodes, filter by kind,
|
|
||||||
// 3s timeout per peer, PASS/WARN/FAIL per node. Zero proxmox nodes
|
|
||||||
// returns WARN (single-node cluster is legitimate).
|
|
||||||
func Proxmox() Check {
|
|
||||||
return Check{
|
|
||||||
Name: "proxmox",
|
|
||||||
Description: "proxmox node reachability via SSH pveversion probe",
|
|
||||||
Run: func(ctx context.Context) (Result, string) {
|
|
||||||
db, err := store.Open(certpaths.DBPath())
|
|
||||||
if err != nil {
|
|
||||||
return ResultFail, fmt.Sprintf("open db: %v", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
nodes, err := store.NewNodeRepo(db).List(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return ResultFail, fmt.Sprintf("list nodes: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
proxmoxNodes := make([]*model.Node, 0, len(nodes))
|
|
||||||
for _, n := range nodes {
|
|
||||||
if n.Kind == string(model.NodeKindProxmox) && n.State != model.NodeStateLeft {
|
|
||||||
proxmoxNodes = append(proxmoxNodes, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(proxmoxNodes) == 0 {
|
|
||||||
return ResultWarn, "no proxmox nodes registered (single-node?)"
|
|
||||||
}
|
|
||||||
|
|
||||||
var lines []string
|
|
||||||
anyFail := false
|
|
||||||
for _, n := range proxmoxNodes {
|
|
||||||
probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
||||||
err := probeProxmoxPVEVersion(probeCtx, n.Name)
|
|
||||||
cancel()
|
|
||||||
if err != nil {
|
|
||||||
anyFail = true
|
|
||||||
lines = append(lines, fmt.Sprintf(" ✗ %s: %v", n.Name, err))
|
|
||||||
} else {
|
|
||||||
lines = append(lines, fmt.Sprintf(" ✓ %s", n.Name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result := ResultPass
|
|
||||||
if anyFail {
|
|
||||||
result = ResultFail
|
|
||||||
}
|
|
||||||
return result, strings.Join(lines, "\n")
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// probeProxmoxPVEVersion SSHes into the proxmox host and runs
|
|
||||||
// `pveversion` to verify reachability + PVE installation. Uses the
|
|
||||||
// orca SSH key for auth (deployed during `orca node join --type proxmox`)
|
|
||||||
// and the known_hosts TOFU store for host-key verification (D-035).
|
|
||||||
func probeProxmoxPVEVersion(ctx context.Context, host string) error {
|
|
||||||
// Load the orca SSH key for public-key auth.
|
|
||||||
keyPEM, err := os.ReadFile(certpaths.SSHKeyPath())
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("read SSH key: %w (run `orca node join --type proxmox` first)", err)
|
|
||||||
}
|
|
||||||
signer, err := ssh.ParsePrivateKey(keyPEM)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("parse SSH key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
hostKeyCallback, err := knownhosts.New(certpaths.KnownHostsPath())
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("known_hosts: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
config := &ssh.ClientConfig{
|
|
||||||
User: "orca",
|
|
||||||
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
|
|
||||||
HostKeyCallback: hostKeyCallback,
|
|
||||||
Timeout: 3 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract host from the node address (orca stores host:8443;
|
|
||||||
// SSH needs host:22). We dial the SSH port, not the orca daemon port.
|
|
||||||
sshHost := host
|
|
||||||
if strings.Contains(host, ":") {
|
|
||||||
sshHost = strings.SplitN(host, ":", 2)[0]
|
|
||||||
}
|
|
||||||
sshAddr := sshHost + ":22"
|
|
||||||
|
|
||||||
dialer := &netDialer{}
|
|
||||||
conn, err := dialer.DialContext(ctx, "tcp", sshAddr, config)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("ssh dial: %w", err)
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
session, err := conn.NewSession()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("new session: %w", err)
|
|
||||||
}
|
|
||||||
defer session.Close()
|
|
||||||
|
|
||||||
out, err := session.CombinedOutput("pveversion")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("pveversion: %w (output: %s)", err, strings.TrimSpace(string(out)))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// netDialer wraps ssh.Dial with context support. The ssh package's
|
|
||||||
// Dial doesn't accept a context directly, so we use a dialer that
|
|
||||||
// respects ctx cancellation via a goroutine + channel.
|
|
||||||
type netDialer struct{}
|
|
||||||
|
|
||||||
func (d *netDialer) DialContext(ctx context.Context, network, addr string, config *ssh.ClientConfig) (*ssh.Client, error) {
|
|
||||||
type result struct {
|
|
||||||
client *ssh.Client
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
ch := make(chan result, 1)
|
|
||||||
go func() {
|
|
||||||
client, err := ssh.Dial(network, addr, config)
|
|
||||||
ch <- result{client, err}
|
|
||||||
}()
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
// Best-effort: if the dial succeeds after ctx cancellation,
|
|
||||||
// the goroutine will close the client. We return the ctx error.
|
|
||||||
go func() {
|
|
||||||
if r := <-ch; r.client != nil {
|
|
||||||
_ = r.client.Close()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return nil, ctx.Err()
|
|
||||||
case r := <-ch:
|
|
||||||
return r.client, r.err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadCert reads a PEM cert from path and parses the first CERTIFICATE
|
// loadCert reads a PEM cert from path and parses the first CERTIFICATE
|
||||||
// block.
|
// block.
|
||||||
func loadCert(path string) (*x509.Certificate, error) {
|
func loadCert(path string) (*x509.Certificate, error) {
|
||||||
|
|||||||
+35
-341
@@ -2,75 +2,60 @@ package doctor
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/osdetect"
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/security"
|
"git.cloudinit.dev/coreci/orca/internal/security"
|
||||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestRunAllChecksWithNoCA runs the full battery in a clean temp dir.
|
// TestRunAllChecksWithNoCA runs the full battery in a clean temp dir
|
||||||
// With the P02 real checks (no stubs): cert checks FAIL (no CA),
|
// and expects all checks to FAIL (no CA, no server cert) except the
|
||||||
// db check PASS (store.Open runs migrations), network check WARN
|
// two stubs which return WARN.
|
||||||
// (no peers).
|
|
||||||
func TestRunAllChecksWithNoCA(t *testing.T) {
|
func TestRunAllChecksWithNoCA(t *testing.T) {
|
||||||
dir := t.TempDir()
|
// Isolated home so we don't touch the real ~/.orca.
|
||||||
t.Setenv("ORCA_HOME", dir)
|
t.Setenv("ORCA_HOME", t.TempDir())
|
||||||
t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))
|
|
||||||
|
|
||||||
rep := Run(context.Background())
|
rep := Run(context.Background())
|
||||||
if len(rep.Checks) == 0 {
|
if len(rep.Checks) == 0 {
|
||||||
t.Fatal("expected checks, got 0")
|
t.Fatal("expected checks, got 0")
|
||||||
}
|
}
|
||||||
|
hasFail := false
|
||||||
byName := make(map[string]CheckResult, len(rep.Checks))
|
hasWarn := false
|
||||||
for _, c := range rep.Checks {
|
for _, c := range rep.Checks {
|
||||||
byName[c.Name] = c
|
if c.Result == ResultFail {
|
||||||
}
|
hasFail = true
|
||||||
|
|
||||||
// Cert checks: no CA → FAIL.
|
|
||||||
for _, name := range []string{"cert.ca", "cert.server", "cert.expiry", "cert.fingerprint"} {
|
|
||||||
c, ok := byName[name]
|
|
||||||
if !ok {
|
|
||||||
t.Errorf("missing check %s", name)
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
if c.Result != ResultFail {
|
if c.Result == ResultWarn {
|
||||||
t.Errorf("%s: got %s, want FAIL — %s", name, c.Result, c.Message)
|
hasWarn = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !hasFail {
|
||||||
// DB check: store.Open runs migrations → PASS.
|
t.Error("expected at least one FAIL (no CA installed)")
|
||||||
if c, ok := byName["db"]; ok {
|
}
|
||||||
if c.Result != ResultPass {
|
if !hasWarn {
|
||||||
t.Errorf("db: got %s, want PASS — %s", c.Result, c.Message)
|
t.Error("expected at least one WARN (stubs in P01)")
|
||||||
}
|
|
||||||
} else {
|
|
||||||
t.Error("missing check db")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Network check: no CA → FAIL (can't build mTLS client without CA).
|
// Render the report — basic shape check.
|
||||||
if c, ok := byName["network"]; ok {
|
out := rep.Print()
|
||||||
if c.Result != ResultFail {
|
if !strings.Contains(out, "PASS") {
|
||||||
t.Errorf("network: got %s, want FAIL (no CA cert) — %s", c.Result, c.Message)
|
t.Errorf("expected PASS in output, got: %s", out)
|
||||||
}
|
}
|
||||||
} else {
|
if !strings.Contains(out, "WARN") {
|
||||||
t.Error("missing check network")
|
t.Errorf("expected WARN in output, got: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "FAIL") {
|
||||||
|
t.Errorf("expected FAIL in output, got: %s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRunWithCAAndServerCert covers the happy path: CA + server cert
|
// TestRunWithCAAndServerCert covers the happy path: CA + server cert
|
||||||
// installed → all cert checks PASS, db PASS, network WARN (no peers).
|
// installed → all cert checks PASS.
|
||||||
func TestRunWithCAAndServerCert(t *testing.T) {
|
func TestRunWithCAAndServerCert(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("ORCA_HOME", dir)
|
t.Setenv("ORCA_HOME", dir)
|
||||||
t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))
|
|
||||||
|
|
||||||
|
// Bootstrap CA.
|
||||||
if _, err := security.CAInit(dir, "test-ca"); err != nil {
|
if _, err := security.CAInit(dir, "test-ca"); err != nil {
|
||||||
t.Fatalf("CAInit: %v", err)
|
t.Fatalf("CAInit: %v", err)
|
||||||
}
|
}
|
||||||
@@ -78,6 +63,7 @@ func TestRunWithCAAndServerCert(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadCA: %v", err)
|
t.Fatalf("LoadCA: %v", err)
|
||||||
}
|
}
|
||||||
|
// Generate + sign server cert.
|
||||||
keyPEM, csrPEM, err := security.GenerateCSR("test-server", []string{"localhost", "127.0.0.1"})
|
keyPEM, csrPEM, err := security.GenerateCSR("test-server", []string{"localhost", "127.0.0.1"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GenerateCSR: %v", err)
|
t.Fatalf("GenerateCSR: %v", err)
|
||||||
@@ -94,305 +80,13 @@ func TestRunWithCAAndServerCert(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rep := Run(context.Background())
|
rep := Run(context.Background())
|
||||||
byName := make(map[string]CheckResult, len(rep.Checks))
|
// The cert-related checks should be PASS; the network/db stubs WARN.
|
||||||
for _, c := range rep.Checks {
|
for _, c := range rep.Checks {
|
||||||
byName[c.Name] = c
|
switch c.Name {
|
||||||
}
|
case "cert.ca", "cert.server", "cert.expiry", "cert.fingerprint":
|
||||||
|
if c.Result != ResultPass {
|
||||||
for _, name := range []string{"cert.ca", "cert.server", "cert.expiry", "cert.fingerprint", "db"} {
|
t.Errorf("%s: got %s, want PASS — %s", c.Name, c.Result, c.Message)
|
||||||
c, ok := byName[name]
|
}
|
||||||
if !ok {
|
|
||||||
t.Errorf("missing check %s", name)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if c.Result != ResultPass {
|
|
||||||
t.Errorf("%s: got %s, want PASS — %s", name, c.Result, c.Message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if c, ok := byName["network"]; ok {
|
|
||||||
if c.Result != ResultWarn {
|
|
||||||
t.Errorf("network: got %s, want WARN (no peers) — %s", c.Result, c.Message)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDBCheck_IntegrityOK verifies the DB check passes on a fresh
|
|
||||||
// database with migrations applied.
|
|
||||||
func TestDBCheck_IntegrityOK(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
t.Setenv("ORCA_HOME", dir)
|
|
||||||
t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))
|
|
||||||
|
|
||||||
db, err := store.Open(filepath.Join(dir, "orca.db"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open db: %v", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
c := DB()
|
|
||||||
r, msg := c.Run(context.Background())
|
|
||||||
if r != ResultPass {
|
|
||||||
t.Errorf("DB check: got %s, want PASS — %s", r, msg)
|
|
||||||
}
|
|
||||||
if !strings.Contains(msg, "0006") {
|
|
||||||
t.Errorf("DB check message should contain migration version, got: %s", msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNetworkCheck_NoPeers verifies the network check returns WARN
|
|
||||||
// when no peers are registered.
|
|
||||||
func TestNetworkCheck_NoPeers(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
t.Setenv("ORCA_HOME", dir)
|
|
||||||
t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))
|
|
||||||
|
|
||||||
// Create a CA + server cert so the network check can build a client.
|
|
||||||
if _, err := security.CAInit(dir, "test-ca"); err != nil {
|
|
||||||
t.Fatalf("CAInit: %v", err)
|
|
||||||
}
|
|
||||||
ca, _ := security.LoadCA(dir)
|
|
||||||
keyPEM, csrPEM, _ := security.GenerateCSR("test-server", []string{"localhost"})
|
|
||||||
certPEM, _ := ca.SignCSR(csrPEM)
|
|
||||||
_ = security.WriteCert(dir+"/server.crt", certPEM)
|
|
||||||
_ = security.WriteKey(dir+"/server.key", keyPEM)
|
|
||||||
|
|
||||||
c := Network()
|
|
||||||
r, msg := c.Run(context.Background())
|
|
||||||
if r != ResultWarn {
|
|
||||||
t.Errorf("Network check: got %s, want WARN — %s", r, msg)
|
|
||||||
}
|
|
||||||
if !strings.Contains(msg, "no peers") {
|
|
||||||
t.Errorf("Network check message should mention no peers, got: %s", msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNetworkCheck_PeerUnreachable verifies the network check returns
|
|
||||||
// FAIL when a registered peer is not reachable.
|
|
||||||
func TestNetworkCheck_PeerUnreachable(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
t.Setenv("ORCA_HOME", dir)
|
|
||||||
t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))
|
|
||||||
|
|
||||||
// Create a CA + server cert.
|
|
||||||
if _, err := security.CAInit(dir, "test-ca"); err != nil {
|
|
||||||
t.Fatalf("CAInit: %v", err)
|
|
||||||
}
|
|
||||||
ca, _ := security.LoadCA(dir)
|
|
||||||
keyPEM, csrPEM, _ := security.GenerateCSR("test-server", []string{"localhost"})
|
|
||||||
certPEM, _ := ca.SignCSR(csrPEM)
|
|
||||||
_ = security.WriteCert(dir+"/server.crt", certPEM)
|
|
||||||
_ = security.WriteKey(dir+"/server.key", keyPEM)
|
|
||||||
|
|
||||||
// Insert a peer node with an unreachable address.
|
|
||||||
db, err := store.Open(filepath.Join(dir, "orca.db"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open db: %v", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
repo := store.NewNodeRepo(db)
|
|
||||||
_ = repo.Insert(context.Background(), &model.Node{
|
|
||||||
ID: "dead-peer", Name: "dead", Address: "127.0.0.1:1",
|
|
||||||
State: model.NodeStateReady, JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(),
|
|
||||||
})
|
|
||||||
|
|
||||||
c := Network()
|
|
||||||
r, msg := c.Run(context.Background())
|
|
||||||
if r != ResultFail {
|
|
||||||
t.Errorf("Network check: got %s, want FAIL — %s", r, msg)
|
|
||||||
}
|
|
||||||
if !strings.Contains(msg, "dead") {
|
|
||||||
t.Errorf("Network check message should mention the dead peer, got: %s", msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNetworkCheck_NoCert verifies the network check returns FAIL
|
|
||||||
// when no CA cert is installed.
|
|
||||||
func TestNetworkCheck_NoCert(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
t.Setenv("ORCA_HOME", dir)
|
|
||||||
t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))
|
|
||||||
|
|
||||||
c := Network()
|
|
||||||
r, msg := c.Run(context.Background())
|
|
||||||
if r != ResultFail {
|
|
||||||
t.Errorf("Network check: got %s, want FAIL — %s", r, msg)
|
|
||||||
}
|
|
||||||
if !strings.Contains(msg, "CA cert missing") {
|
|
||||||
t.Errorf("Network check message should mention missing CA, got: %s", msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRenderReport verifies the report output format.
|
|
||||||
func TestRenderReport(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
t.Setenv("ORCA_HOME", dir)
|
|
||||||
t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))
|
|
||||||
|
|
||||||
rep := Run(context.Background())
|
|
||||||
out := rep.Print()
|
|
||||||
if !strings.Contains(out, "PASS") {
|
|
||||||
t.Errorf("expected PASS in output, got: %s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "WARN") {
|
|
||||||
t.Errorf("expected WARN in output, got: %s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "FAIL") {
|
|
||||||
t.Errorf("expected FAIL in output, got: %s", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestOSCheck_MissingLocalhostNode verifies the OS check returns FAIL
|
|
||||||
// when no localhost node is registered.
|
|
||||||
func TestOSCheck_MissingLocalhostNode(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
t.Setenv("ORCA_HOME", dir)
|
|
||||||
t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))
|
|
||||||
|
|
||||||
// Open the DB to apply migrations but insert no nodes.
|
|
||||||
db, err := store.Open(filepath.Join(dir, "orca.db"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open db: %v", err)
|
|
||||||
}
|
|
||||||
db.Close()
|
|
||||||
|
|
||||||
c := OS()
|
|
||||||
r, msg := c.Run(context.Background())
|
|
||||||
if r != ResultFail {
|
|
||||||
t.Errorf("OS check: got %s, want FAIL — %s", r, msg)
|
|
||||||
}
|
|
||||||
if !strings.Contains(msg, "no localhost node") {
|
|
||||||
t.Errorf("OS check message should mention missing localhost node, got: %s", msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestOSCheck_Match verifies the OS check returns PASS when the stored
|
|
||||||
// localhost node's os matches the detected OS.
|
|
||||||
func TestOSCheck_Match(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
t.Setenv("ORCA_HOME", dir)
|
|
||||||
t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))
|
|
||||||
|
|
||||||
db, err := store.Open(filepath.Join(dir, "orca.db"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open db: %v", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
repo := store.NewNodeRepo(db)
|
|
||||||
|
|
||||||
// Insert a localhost node with the currently-detected OS.
|
|
||||||
detected := osdetect.Detect()
|
|
||||||
if err := repo.Insert(context.Background(), &model.Node{
|
|
||||||
ID: "os-match-1", Name: "localhost", Address: "localhost:8443",
|
|
||||||
State: model.NodeStateReady, JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(),
|
|
||||||
Kind: "localhost", OS: detected,
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("insert: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c := OS()
|
|
||||||
r, msg := c.Run(context.Background())
|
|
||||||
if r != ResultPass {
|
|
||||||
t.Errorf("OS check: got %s, want PASS — %s", r, msg)
|
|
||||||
}
|
|
||||||
if !strings.Contains(msg, detected) {
|
|
||||||
t.Errorf("OS check message should contain %s, got: %s", detected, msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestOSCheck_Drift verifies the OS check returns WARN when the stored
|
|
||||||
// os differs from the detected os.
|
|
||||||
func TestOSCheck_Drift(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
t.Setenv("ORCA_HOME", dir)
|
|
||||||
t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))
|
|
||||||
|
|
||||||
db, err := store.Open(filepath.Join(dir, "orca.db"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open db: %v", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
repo := store.NewNodeRepo(db)
|
|
||||||
|
|
||||||
// Insert a localhost node with a deliberately wrong OS.
|
|
||||||
if err := repo.Insert(context.Background(), &model.Node{
|
|
||||||
ID: "os-drift-1", Name: "localhost", Address: "localhost:8443",
|
|
||||||
State: model.NodeStateReady, JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(),
|
|
||||||
Kind: "localhost", OS: "debian",
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("insert: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c := OS()
|
|
||||||
r, msg := c.Run(context.Background())
|
|
||||||
if r != ResultWarn {
|
|
||||||
t.Errorf("OS check: got %s, want WARN — %s", r, msg)
|
|
||||||
}
|
|
||||||
if !strings.Contains(msg, "drift") {
|
|
||||||
t.Errorf("OS check message should mention drift, got: %s", msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestProxmoxCheck_NoProxmoxNodes verifies the proxmox check returns
|
|
||||||
// WARN when no proxmox nodes are registered.
|
|
||||||
func TestProxmoxCheck_NoProxmoxNodes(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
t.Setenv("ORCA_HOME", dir)
|
|
||||||
t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))
|
|
||||||
|
|
||||||
db, err := store.Open(filepath.Join(dir, "orca.db"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open db: %v", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
c := Proxmox()
|
|
||||||
r, msg := c.Run(context.Background())
|
|
||||||
if r != ResultWarn {
|
|
||||||
t.Errorf("Proxmox check: got %s, want WARN — %s", r, msg)
|
|
||||||
}
|
|
||||||
if !strings.Contains(msg, "no proxmox nodes") {
|
|
||||||
t.Errorf("Proxmox check message should mention no proxmox nodes, got: %s", msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestProxmoxCheck_UnreachableNode verifies the proxmox check returns
|
|
||||||
// FAIL when a proxmox node is registered but unreachable (no SSH key
|
|
||||||
// or host down). We insert a proxmox node with an unreachable address;
|
|
||||||
// the SSH dial will fail (no SSH key file → error).
|
|
||||||
func TestProxmoxCheck_UnreachableNode(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
t.Setenv("ORCA_HOME", dir)
|
|
||||||
t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))
|
|
||||||
|
|
||||||
db, err := store.Open(filepath.Join(dir, "orca.db"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open db: %v", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
repo := store.NewNodeRepo(db)
|
|
||||||
|
|
||||||
// Insert a proxmox node. The SSH probe will fail because no SSH
|
|
||||||
// key exists in the test namespace dir.
|
|
||||||
if err := repo.Insert(context.Background(), &model.Node{
|
|
||||||
ID: "px-1", Name: "10.0.0.99", Address: "10.0.0.99:8443",
|
|
||||||
State: model.NodeStateReady, JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(),
|
|
||||||
Kind: "proxmox", OS: "pve",
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("insert: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c := Proxmox()
|
|
||||||
r, msg := c.Run(context.Background())
|
|
||||||
if r != ResultFail {
|
|
||||||
t.Errorf("Proxmox check: got %s, want FAIL — %s", r, msg)
|
|
||||||
}
|
|
||||||
if !strings.Contains(msg, "10.0.0.99") {
|
|
||||||
t.Errorf("Proxmox check message should mention the node, got: %s", msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
// Suppress slog noise during tests.
|
|
||||||
_ = os.Setenv("ORCA_LOG_LEVEL", "error")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,19 +10,6 @@ const (
|
|||||||
NodeStateLeft NodeState = "left"
|
NodeStateLeft NodeState = "left"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NodeKind classifies a node by how it joined the cluster.
|
|
||||||
type NodeKind string
|
|
||||||
|
|
||||||
const (
|
|
||||||
// NodeKindLocalhost is the auto-registered local node from `orca init`.
|
|
||||||
NodeKindLocalhost NodeKind = "localhost"
|
|
||||||
// NodeKindLinux is a generic Linux node (ubuntu/debian/alpine) joined
|
|
||||||
// without a specific type. Reserved for future SSH-join flows.
|
|
||||||
NodeKindLinux NodeKind = "linux"
|
|
||||||
// NodeKindProxmox is a Proxmox VE 8/9 host joined via SSH bootstrap.
|
|
||||||
NodeKindProxmox NodeKind = "proxmox"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Node struct {
|
type Node struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -31,10 +18,4 @@ type Node struct {
|
|||||||
JoinedAt time.Time `json:"joined_at"`
|
JoinedAt time.Time `json:"joined_at"`
|
||||||
LastSeen time.Time `json:"last_seen"`
|
LastSeen time.Time `json:"last_seen"`
|
||||||
Metadata map[string]string `json:"metadata,omitempty"`
|
Metadata map[string]string `json:"metadata,omitempty"`
|
||||||
// Kind classifies the node: localhost | linux | proxmox (REQ-049).
|
|
||||||
// Empty string for rows created before migration 0006.
|
|
||||||
Kind string `json:"kind,omitempty"`
|
|
||||||
// OS is the auto-detected OS identifier from /etc/os-release ID=
|
|
||||||
// (ubuntu|debian|alpine|pve|linux). Empty for pre-0006 rows.
|
|
||||||
OS string `json:"os,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
// Package osdetect provides OS detection from /etc/os-release (D-032).
|
|
||||||
// It's a separate package to avoid import cycles between internal/cli
|
|
||||||
// and internal/doctor (both need to detect the local OS).
|
|
||||||
package osdetect
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// osReleasePaths are checked in order for the os-release file. The
|
|
||||||
// freedesktop.org spec says /etc/os-release is the canonical path,
|
|
||||||
// with /usr/lib/os-release as a fallback for minimal containers that
|
|
||||||
// may not symlink the former.
|
|
||||||
var osReleasePaths = []string{"/etc/os-release", "/usr/lib/os-release"}
|
|
||||||
|
|
||||||
// Detect reads /etc/os-release (then /usr/lib/os-release as a
|
|
||||||
// fallback) and returns the value of the ID= field. Returns "linux"
|
|
||||||
// (the generic fallback per D-032) if the file is missing, the ID
|
|
||||||
// field is absent, or the value is empty. Unknown ID values (e.g.
|
|
||||||
// "fedora", "arch") are returned verbatim — doctor os can warn on
|
|
||||||
// unknown values, but orca init must not fail.
|
|
||||||
func Detect() string {
|
|
||||||
for _, p := range osReleasePaths {
|
|
||||||
data, err := os.ReadFile(p)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if id := ParseID(data); id != "" {
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "linux"
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseID extracts the ID= value from os-release content.
|
|
||||||
// The format is shell-compatible KEY=VALUE lines; values may be
|
|
||||||
// double-quoted. Returns "" if ID is absent or empty.
|
|
||||||
func ParseID(data []byte) string {
|
|
||||||
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := strings.TrimSpace(scanner.Text())
|
|
||||||
if line == "" || strings.HasPrefix(line, "#") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key, value, ok := strings.Cut(line, "=")
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key = strings.TrimSpace(key)
|
|
||||||
if key != "ID" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
value = strings.TrimSpace(value)
|
|
||||||
// Strip surrounding double quotes (freedesktop spec allows quoted values).
|
|
||||||
if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
|
|
||||||
value = value[1 : len(value)-1]
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
package osdetect
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParseID_Ubuntu(t *testing.T) {
|
|
||||||
content := `NAME="Ubuntu"
|
|
||||||
VERSION="24.04.4 LTS (Noble Numbat)"
|
|
||||||
ID=ubuntu
|
|
||||||
ID_LIKE=debian`
|
|
||||||
if got := ParseID([]byte(content)); got != "ubuntu" {
|
|
||||||
t.Errorf("got %q, want ubuntu", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseID_Debian(t *testing.T) {
|
|
||||||
if got := ParseID([]byte("ID=debian\n")); got != "debian" {
|
|
||||||
t.Errorf("got %q, want debian", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseID_Alpine(t *testing.T) {
|
|
||||||
if got := ParseID([]byte("ID=alpine\n")); got != "alpine" {
|
|
||||||
t.Errorf("got %q, want alpine", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseID_PVE(t *testing.T) {
|
|
||||||
if got := ParseID([]byte("ID=pve\nID_LIKE=debian\n")); got != "pve" {
|
|
||||||
t.Errorf("got %q, want pve", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseID_QuotedValue(t *testing.T) {
|
|
||||||
if got := ParseID([]byte(`ID="ubuntu"` + "\n")); got != "ubuntu" {
|
|
||||||
t.Errorf("got %q, want ubuntu", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseID_MissingID(t *testing.T) {
|
|
||||||
if got := ParseID([]byte("NAME=Test\n")); got != "" {
|
|
||||||
t.Errorf("got %q, want empty", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseID_UnknownIDVerbatim(t *testing.T) {
|
|
||||||
if got := ParseID([]byte("ID=fedora\n")); got != "fedora" {
|
|
||||||
t.Errorf("got %q, want fedora", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseID_CommentsAndBlanks(t *testing.T) {
|
|
||||||
content := `# comment
|
|
||||||
|
|
||||||
NAME="Test"
|
|
||||||
# ID below
|
|
||||||
ID=arch`
|
|
||||||
if got := ParseID([]byte(content)); got != "arch" {
|
|
||||||
t.Errorf("got %q, want arch", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetect_FallbackToLinux(t *testing.T) {
|
|
||||||
orig := osReleasePaths
|
|
||||||
defer func() { osReleasePaths = orig }()
|
|
||||||
osReleasePaths = []string{filepath.Join(t.TempDir(), "nonexistent")}
|
|
||||||
if got := Detect(); got != "linux" {
|
|
||||||
t.Errorf("got %q, want linux (fallback)", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetect_ReadsFile(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
orig := osReleasePaths
|
|
||||||
defer func() { osReleasePaths = orig }()
|
|
||||||
path := filepath.Join(dir, "os-release")
|
|
||||||
osReleasePaths = []string{path}
|
|
||||||
if err := os.WriteFile(path, []byte("ID=ubuntu\n"), 0o644); err != nil {
|
|
||||||
t.Fatalf("write: %v", err)
|
|
||||||
}
|
|
||||||
if got := Detect(); got != "ubuntu" {
|
|
||||||
t.Errorf("got %q, want ubuntu", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetect_FallbackToUsrLib(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
orig := osReleasePaths
|
|
||||||
defer func() { osReleasePaths = orig }()
|
|
||||||
osReleasePaths = []string{
|
|
||||||
filepath.Join(dir, "etc"), // missing
|
|
||||||
filepath.Join(dir, "usr-lib"), // fallback
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(osReleasePaths[1], []byte("ID=alpine\n"), 0o644); err != nil {
|
|
||||||
t.Fatalf("write: %v", err)
|
|
||||||
}
|
|
||||||
if got := Detect(); got != "alpine" {
|
|
||||||
t.Errorf("got %q, want alpine (from fallback)", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,346 +0,0 @@
|
|||||||
// Package proxmox implements the SSH-based bootstrap of a remote
|
|
||||||
// Proxmox VE 8/9 host as an orca node (REQ-050, REQ-051).
|
|
||||||
//
|
|
||||||
// The bootstrap sequence (run via `orca node join --type proxmox`):
|
|
||||||
// 1. Generate or load the orca SSH keypair (Ed25519, D-037)
|
|
||||||
// 2. SSH dial with password auth + TOFU host-key capture (D-035)
|
|
||||||
// 3. Deploy the orca pubkey to ~orca/.ssh/authorized_keys
|
|
||||||
// 4. Create the `orca` Linux system user (config-overridable name)
|
|
||||||
// 5. Create the OrcaOperator PVE role with least-privilege privileges
|
|
||||||
// 6. Create the orca@pam PVE user (maps to the Linux system user)
|
|
||||||
// 7. Assign the OrcaOperator role to orca@pam on path /
|
|
||||||
// 8. Write /etc/sudoers.d/orca with NOEXEC on pct/qm, no NOEXEC on
|
|
||||||
// apt-get/dpkg, and pvesh EXCLUDED (AD-020: pvesh can bypass NOEXEC
|
|
||||||
// via the API execute endpoint)
|
|
||||||
// 9. Validate the sudoers file with visudo -cf
|
|
||||||
// 10. Return the node metadata for the caller to persist
|
|
||||||
//
|
|
||||||
// All steps are idempotent (D-036): re-running the bootstrap on an
|
|
||||||
// already-configured host is a no-op. The password is never persisted
|
|
||||||
// (D-031) — it is used only for the initial SSH auth and pubkey
|
|
||||||
// deployment; subsequent orca→Proxmox access uses the deployed SSH key.
|
|
||||||
package proxmox
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/ssh"
|
|
||||||
"golang.org/x/crypto/ssh/knownhosts"
|
|
||||||
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/security"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DefaultProxmoxUser is the default Linux system user created on the
|
|
||||||
// Proxmox host. Overridable via Options.ProxmoxUser.
|
|
||||||
const DefaultProxmoxUser = "orca"
|
|
||||||
|
|
||||||
// DefaultProxmoxRole is the default PVE custom role created for the
|
|
||||||
// orca user. Overridable via Options.ProxmoxRole.
|
|
||||||
const DefaultProxmoxRole = "OrcaOperator"
|
|
||||||
|
|
||||||
// DefaultSSHPort is the default SSH port for Proxmox hosts.
|
|
||||||
const DefaultSSHPort = 22
|
|
||||||
|
|
||||||
// OrcaOperatorPrivileges is the least-privilege privilege set for the
|
|
||||||
// OrcaOperator PVE role (D-033). Space-separated per pveum --privs
|
|
||||||
// syntax. VM.Audit covers CTs as well (both live under /vms/{vmid}).
|
|
||||||
const OrcaOperatorPrivileges = "VM.Audit Datastore.AllocateSpace SDN.Use"
|
|
||||||
|
|
||||||
// Options configures a Proxmox bootstrap run.
|
|
||||||
type Options struct {
|
|
||||||
// Host is the Proxmox host address (IP or hostname, no port).
|
|
||||||
Host string
|
|
||||||
// SSHUser is the initial SSH username (default "root").
|
|
||||||
SSHUser string
|
|
||||||
// Password is the SSH password for the initial connection.
|
|
||||||
// NEVER persisted (D-031). The caller must zero this after use.
|
|
||||||
Password string
|
|
||||||
// ProxmoxUser is the Linux system user to create on the host
|
|
||||||
// (default "orca"). Config-overridable.
|
|
||||||
ProxmoxUser string
|
|
||||||
// ProxmoxRole is the PVE custom role to create (default
|
|
||||||
// "OrcaOperator"). Config-overridable.
|
|
||||||
ProxmoxRole string
|
|
||||||
// SSHPort is the SSH port (default 22).
|
|
||||||
SSHPort int
|
|
||||||
// Logger receives audit-log entries. If nil, slog.Default() is used.
|
|
||||||
Logger *slog.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// Result is the outcome of a successful bootstrap.
|
|
||||||
type Result struct {
|
|
||||||
// NodeName is the name to use for the node in the orca registry
|
|
||||||
// (typically the host address).
|
|
||||||
NodeName string
|
|
||||||
// NodeAddress is the orca daemon address on the Proxmox host
|
|
||||||
// (host:8443 — the orca daemon port).
|
|
||||||
NodeAddress string
|
|
||||||
// HostKeyFingerprint is the SHA-256 fingerprint of the captured
|
|
||||||
// SSH host key (for operator verification).
|
|
||||||
HostKeyFingerprint string
|
|
||||||
}
|
|
||||||
|
|
||||||
// BootstrapProxmox runs the full SSH bootstrap sequence on a remote
|
|
||||||
// Proxmox VE 8/9 host. All steps are idempotent. Returns a Result
|
|
||||||
// describing the node to register, or an error if any step fails.
|
|
||||||
func BootstrapProxmox(ctx context.Context, opts Options) (*Result, error) {
|
|
||||||
if opts.Host == "" {
|
|
||||||
return nil, fmt.Errorf("proxmox bootstrap: host is required")
|
|
||||||
}
|
|
||||||
if opts.Password == "" {
|
|
||||||
return nil, fmt.Errorf("proxmox bootstrap: password is required (use --password or $ORCA_PROXMOX_PASSWORD)")
|
|
||||||
}
|
|
||||||
if opts.SSHUser == "" {
|
|
||||||
opts.SSHUser = "root"
|
|
||||||
}
|
|
||||||
if opts.ProxmoxUser == "" {
|
|
||||||
opts.ProxmoxUser = DefaultProxmoxUser
|
|
||||||
}
|
|
||||||
if opts.ProxmoxRole == "" {
|
|
||||||
opts.ProxmoxRole = DefaultProxmoxRole
|
|
||||||
}
|
|
||||||
if opts.SSHPort == 0 {
|
|
||||||
opts.SSHPort = DefaultSSHPort
|
|
||||||
}
|
|
||||||
log := opts.Logger
|
|
||||||
if log == nil {
|
|
||||||
log = slog.Default()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Generate or load the orca SSH keypair (D-037).
|
|
||||||
// The key is deployed to the remote host's authorized_keys in step 3.
|
|
||||||
_, pubLine, err := security.GenerateOrLoadSSHKey(certpaths.Dir())
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("ssh key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: SSH dial with password auth + TOFU host-key capture (D-035).
|
|
||||||
// knownhosts.New reads ~/.orca/known_hosts; on first connect it
|
|
||||||
// captures the host key, on subsequent connects it verifies.
|
|
||||||
hostKeyCallback, err := knownhosts.New(certpaths.KnownHostsPath())
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("known_hosts callback: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sshAddr := fmt.Sprintf("%s:%d", opts.Host, opts.SSHPort)
|
|
||||||
sshConfig := &ssh.ClientConfig{
|
|
||||||
User: opts.SSHUser,
|
|
||||||
Auth: []ssh.AuthMethod{ssh.Password(opts.Password)},
|
|
||||||
HostKeyCallback: hostKeyCallback,
|
|
||||||
Timeout: 10 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
dialCtx, dialCancel := context.WithTimeout(ctx, 15*time.Second)
|
|
||||||
defer dialCancel()
|
|
||||||
conn, err := sshDialer.DialContext(dialCtx, "tcp", sshAddr, sshConfig)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("ssh dial %s: %w", sshAddr, err)
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
log.Info("proxmox.ssh_connected",
|
|
||||||
slog.String("event", "proxmox.ssh_connected"),
|
|
||||||
slog.String("host", opts.Host),
|
|
||||||
slog.String("ssh_user", opts.SSHUser),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Step 3: Deploy orca pubkey to ~orca/.ssh/authorized_keys (idempotent).
|
|
||||||
if err := deployPubKey(conn, opts.ProxmoxUser, string(pubLine)); err != nil {
|
|
||||||
return nil, fmt.Errorf("deploy pubkey: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: Create orca Linux system user (idempotent).
|
|
||||||
if err := createLinuxUser(conn, opts.ProxmoxUser); err != nil {
|
|
||||||
return nil, fmt.Errorf("create user %s: %w", opts.ProxmoxUser, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 5: Create OrcaOperator PVE role (idempotent).
|
|
||||||
if err := createPVERole(conn, opts.ProxmoxRole); err != nil {
|
|
||||||
return nil, fmt.Errorf("create PVE role %s: %w", opts.ProxmoxRole, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 6: Create orca@pam PVE user (idempotent).
|
|
||||||
if err := createPVEUser(conn, opts.ProxmoxUser); err != nil {
|
|
||||||
return nil, fmt.Errorf("create PVE user %s@pam: %w", opts.ProxmoxUser, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 7: Assign OrcaOperator role to orca@pam on path / (idempotent).
|
|
||||||
if err := assignPVEACL(conn, opts.ProxmoxUser, opts.ProxmoxRole); err != nil {
|
|
||||||
return nil, fmt.Errorf("assign ACL: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 8: Write /etc/sudoers.d/orca (AD-020: NOEXEC on pct/qm,
|
|
||||||
// no NOEXEC on apt-get/dpkg, pvesh EXCLUDED).
|
|
||||||
if err := writeSudoers(conn, opts.ProxmoxUser); err != nil {
|
|
||||||
return nil, fmt.Errorf("write sudoers: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 9: Validate sudoers with visudo -cf.
|
|
||||||
if err := validateSudoers(conn); err != nil {
|
|
||||||
return nil, fmt.Errorf("validate sudoers: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info("proxmox.bootstrap_ok",
|
|
||||||
slog.String("event", "proxmox.bootstrap_ok"),
|
|
||||||
slog.String("host", opts.Host),
|
|
||||||
slog.String("proxmox_user", opts.ProxmoxUser),
|
|
||||||
slog.String("proxmox_role", opts.ProxmoxRole),
|
|
||||||
)
|
|
||||||
|
|
||||||
return &Result{
|
|
||||||
NodeName: opts.Host,
|
|
||||||
NodeAddress: opts.Host + ":8443",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// sshDialer is the dialer used by BootstrapProxmox. It's a package-level
|
|
||||||
// variable so tests can override it with a fake SSH server.
|
|
||||||
var sshDialer sshDialerType = defaultSSHDialer{}
|
|
||||||
|
|
||||||
type sshDialerType interface {
|
|
||||||
DialContext(ctx context.Context, network, addr string, config *ssh.ClientConfig) (*ssh.Client, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type defaultSSHDialer struct{}
|
|
||||||
|
|
||||||
func (defaultSSHDialer) DialContext(ctx context.Context, network, addr string, config *ssh.ClientConfig) (*ssh.Client, error) {
|
|
||||||
return ssh.Dial(network, addr, config)
|
|
||||||
}
|
|
||||||
|
|
||||||
// runRemote runs a command over the SSH connection and returns its
|
|
||||||
// combined output. Returns an error if the command exits non-zero.
|
|
||||||
func runRemote(conn *ssh.Client, cmd string) ([]byte, error) {
|
|
||||||
session, err := conn.NewSession()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("new session: %w", err)
|
|
||||||
}
|
|
||||||
defer session.Close()
|
|
||||||
out, err := session.CombinedOutput(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return out, fmt.Errorf("run %q: %w (output: %s)", cmd, err, strings.TrimSpace(string(out)))
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// deployPubKey appends the orca public key to the remote user's
|
|
||||||
// authorized_keys file, creating the .ssh dir if needed. Idempotent:
|
|
||||||
// if the key is already present, it is not re-appended.
|
|
||||||
func deployPubKey(conn *ssh.Client, user, pubLine string) error {
|
|
||||||
pubLine = strings.TrimSpace(pubLine)
|
|
||||||
if pubLine == "" {
|
|
||||||
return fmt.Errorf("deployPubKey: empty pub line")
|
|
||||||
}
|
|
||||||
home := "/home/" + user
|
|
||||||
if user == "root" {
|
|
||||||
home = "/root"
|
|
||||||
}
|
|
||||||
sshDir := home + "/.ssh"
|
|
||||||
authFile := sshDir + "/authorized_keys"
|
|
||||||
// Create .ssh dir, touch authorized_keys, set modes, append key if absent.
|
|
||||||
cmd := fmt.Sprintf(
|
|
||||||
"mkdir -p %s && touch %s && chmod 0700 %s && chmod 0600 %s && grep -qF '%s' %s || echo '%s' >> %s",
|
|
||||||
sshDir, authFile, sshDir, authFile, pubLine, authFile, pubLine, authFile,
|
|
||||||
)
|
|
||||||
if _, err := runRemote(conn, cmd); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createLinuxUser creates the orca system user if it doesn't already
|
|
||||||
// exist. Idempotent: `id -u` check before `useradd`.
|
|
||||||
func createLinuxUser(conn *ssh.Client, user string) error {
|
|
||||||
cmd := fmt.Sprintf("id -u %s 2>/dev/null || useradd -m -s /bin/bash %s", user, user)
|
|
||||||
if _, err := runRemote(conn, cmd); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createPVERole creates the OrcaOperator PVE role if it doesn't exist.
|
|
||||||
// Idempotent: probes `pveum role list` before `pveum role add`.
|
|
||||||
func createPVERole(conn *ssh.Client, role string) error {
|
|
||||||
cmd := fmt.Sprintf(
|
|
||||||
"pveum role list 2>/dev/null | grep -q '^%s' || pveum role add %s --privs '%s'",
|
|
||||||
role, role, OrcaOperatorPrivileges,
|
|
||||||
)
|
|
||||||
if _, err := runRemote(conn, cmd); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createPVEUser creates the orca@pam PVE user if it doesn't exist.
|
|
||||||
// Idempotent: probes `pveum user list` before `pveum user add`.
|
|
||||||
// Uses @pam realm (AD-019) since orca creates a Linux system user.
|
|
||||||
func createPVEUser(conn *ssh.Client, user string) error {
|
|
||||||
pveUserID := user + "@pam"
|
|
||||||
cmd := fmt.Sprintf(
|
|
||||||
"pveum user list 2>/dev/null | grep -q '%s' || pveum user add %s -comment 'Orca automation user'",
|
|
||||||
pveUserID, pveUserID,
|
|
||||||
)
|
|
||||||
if _, err := runRemote(conn, cmd); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// assignPVEACL assigns the OrcaOperator role to orca@pam on path /
|
|
||||||
// (cluster-wide). `pveum acl modify` is idempotent (creates or updates).
|
|
||||||
func assignPVEACL(conn *ssh.Client, user, role string) error {
|
|
||||||
pveUserID := user + "@pam"
|
|
||||||
cmd := fmt.Sprintf("pveum acl modify / -user %s -role %s", pveUserID, role)
|
|
||||||
if _, err := runRemote(conn, cmd); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// sudoersContent returns the /etc/sudoers.d/orca file content (AD-020).
|
|
||||||
// NOEXEC on pct/qm (blocks shell escapes); no NOEXEC on apt-get/dpkg
|
|
||||||
// (they need exec for maintainer scripts); pvesh EXCLUDED (API execute
|
|
||||||
// bypasses NOEXEC). File must be mode 0440 per sudo requirements.
|
|
||||||
func sudoersContent(user string) string {
|
|
||||||
return fmt.Sprintf(`# /etc/sudoers.d/orca — Managed by orca; do not edit manually.
|
|
||||||
# Least-privilege allowlist for the orca PVE operator user.
|
|
||||||
# NOPASSWD: non-interactive SSH automation. NOEXEC: blocks shell escapes.
|
|
||||||
# pvesh is EXCLUDED (AD-020: pvesh can bypass NOEXEC via API execute).
|
|
||||||
%s ALL=(root) NOPASSWD: NOEXEC: /usr/bin/pct
|
|
||||||
%s ALL=(root) NOPASSWD: NOEXEC: /usr/bin/qm
|
|
||||||
%s ALL=(root) NOPASSWD: /usr/bin/apt-get
|
|
||||||
%s ALL=(root) NOPASSWD: /usr/bin/dpkg
|
|
||||||
`, user, user, user, user)
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeSudoers writes the /etc/sudoers.d/orca file on the remote host
|
|
||||||
// with mode 0440. Uses a heredoc via cat to avoid quoting issues.
|
|
||||||
func writeSudoers(conn *ssh.Client, user string) error {
|
|
||||||
content := sudoersContent(user)
|
|
||||||
// Write via cat heredoc, then chmod 0440.
|
|
||||||
cmd := fmt.Sprintf("cat > /etc/sudoers.d/%s <<'ORCA_SUDOERS_EOF'\n%s\nORCA_SUDOERS_EOF\nchmod 0440 /etc/sudoers.d/%s",
|
|
||||||
user, content, user)
|
|
||||||
if _, err := runRemote(conn, cmd); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateSudoers runs `visudo -cf` on the sudoers file. Aborts the
|
|
||||||
// bootstrap if validation fails (prevents a broken sudoers from
|
|
||||||
// locking the orca user out of sudo).
|
|
||||||
func validateSudoers(conn *ssh.Client) error {
|
|
||||||
cmd := "visudo -cf /etc/sudoers.d/orca"
|
|
||||||
out, err := runRemote(conn, cmd)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("visudo validation failed: %w (output: %s)", err, strings.TrimSpace(string(out)))
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(out), "parsed OK") {
|
|
||||||
return fmt.Errorf("visudo validation did not report OK: %s", strings.TrimSpace(string(out)))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
package proxmox
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
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)")
|
|
||||||
}
|
|
||||||
if !strings.Contains(content, "NOPASSWD: NOEXEC: /usr/bin/qm") {
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
if !strings.Contains(content, "NOPASSWD: /usr/bin/dpkg") {
|
|
||||||
t.Error("missing NOPASSWD on dpkg")
|
|
||||||
}
|
|
||||||
if strings.Contains(content, "NOEXEC: /usr/bin/apt-get") {
|
|
||||||
t.Error("apt-get must NOT have NOEXEC (breaks maintainer scripts)")
|
|
||||||
}
|
|
||||||
if strings.Contains(content, "NOEXEC: /usr/bin/dpkg") {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
if !strings.Contains(content, "orca ALL=(root)") {
|
|
||||||
t.Error("missing orca user in sudoers")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSudoersContent_CustomUser(t *testing.T) {
|
|
||||||
content := sudoersContent("custom-orca")
|
|
||||||
if !strings.Contains(content, "custom-orca ALL=(root)") {
|
|
||||||
t.Error("missing custom-orca user in sudoers")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
"Datastore.AllocateSpace": true,
|
|
||||||
"SDN.Use": true,
|
|
||||||
}
|
|
||||||
if len(privs) != 3 {
|
|
||||||
t.Errorf("expected 3 privileges, got %d: %v", len(privs), privs)
|
|
||||||
}
|
|
||||||
for _, p := range privs {
|
|
||||||
if !expected[p] {
|
|
||||||
t.Errorf("unexpected privilege %q", p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
if DefaultProxmoxRole != "OrcaOperator" {
|
|
||||||
t.Errorf("DefaultProxmoxRole = %q, want OrcaOperator", DefaultProxmoxRole)
|
|
||||||
}
|
|
||||||
if DefaultSSHPort != 22 {
|
|
||||||
t.Errorf("DefaultSSHPort = %d, want 22", DefaultSSHPort)
|
|
||||||
}
|
|
||||||
_ = opts
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
package security
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/ed25519"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/x509"
|
|
||||||
"encoding/pem"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/ssh"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SSHKeyMode is the file mode for the SSH private key. Matches the
|
|
||||||
// CA key mode (REQ-033 spirit: 0600 for private keys).
|
|
||||||
const SSHKeyMode os.FileMode = 0o600
|
|
||||||
|
|
||||||
// SSHPubMode is the file mode for the SSH public key (authorized_keys
|
|
||||||
// line). Matches the CA cert mode (0644 for public material).
|
|
||||||
const SSHPubMode os.FileMode = 0o644
|
|
||||||
|
|
||||||
const (
|
|
||||||
sshKeyFile = "orca_ssh_key"
|
|
||||||
sshPubFile = "orca_ssh_key.pub"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GenerateOrLoadSSHKey returns the orca SSH keypair, generating it
|
|
||||||
// lazily on first call (D-037). The key is Ed25519 (smaller, faster,
|
|
||||||
// more secure than RSA for SSH auth), persisted as PKCS8 PEM to
|
|
||||||
// dir/orca_ssh_key (0600) and dir/orca_ssh_key.pub (0644).
|
|
||||||
//
|
|
||||||
// Idempotent: if both files exist with valid content, they are loaded
|
|
||||||
// and returned without regeneration. This matches the CAInit fast-path
|
|
||||||
// pattern (D-036 idempotency).
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - keyPEM: PKCS8 PEM private key (parses with ssh.ParsePrivateKey)
|
|
||||||
// - pubLine: authorized_keys line (ssh-ed25519 AAAA... comment\n)
|
|
||||||
func GenerateOrLoadSSHKey(dir string) (keyPEM, pubLine []byte, err error) {
|
|
||||||
if dir == "" {
|
|
||||||
return nil, nil, errors.New("GenerateOrLoadSSHKey: dir is required")
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("GenerateOrLoadSSHKey: mkdir: %w", err)
|
|
||||||
}
|
|
||||||
keyPath := filepath.Join(dir, sshKeyFile)
|
|
||||||
pubPath := filepath.Join(dir, sshPubFile)
|
|
||||||
|
|
||||||
// Fast path: existing key — load and return.
|
|
||||||
if ok, err := bothExist(keyPath, pubPath); err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
} else if ok {
|
|
||||||
keyPEM, err := os.ReadFile(keyPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("read SSH key: %w", err)
|
|
||||||
}
|
|
||||||
pubLine, err := os.ReadFile(pubPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("read SSH pub: %w", err)
|
|
||||||
}
|
|
||||||
return keyPEM, pubLine, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate Ed25519 keypair.
|
|
||||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("GenerateOrLoadSSHKey: ed25519 gen: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Serialize private key as PKCS8 PEM (consistent with ca.key/server.key).
|
|
||||||
keyDER, err := x509.MarshalPKCS8PrivateKey(priv)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("GenerateOrLoadSSHKey: marshal key: %w", err)
|
|
||||||
}
|
|
||||||
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
|
||||||
|
|
||||||
// Serialize public key as authorized_keys line.
|
|
||||||
sshPub, err := ssh.NewPublicKey(pub)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("GenerateOrLoadSSHKey: new pubkey: %w", err)
|
|
||||||
}
|
|
||||||
pubLine = ssh.MarshalAuthorizedKey(sshPub)
|
|
||||||
|
|
||||||
// Persist with correct modes (atomic write + chmod).
|
|
||||||
if err := writeAtomic(keyPath, SSHKeyMode, keyPEM); err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("write SSH key: %w", err)
|
|
||||||
}
|
|
||||||
if err := writeAtomic(pubPath, SSHPubMode, pubLine); err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("write SSH pub: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return keyPEM, pubLine, nil
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
package security
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/ssh"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGenerateOrLoadSSHKey_Generates(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
|
|
||||||
keyPEM, pubLine, err := GenerateOrLoadSSHKey(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("generate: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Private key file exists with mode 0600.
|
|
||||||
keyPath := filepath.Join(dir, sshKeyFile)
|
|
||||||
info, err := os.Stat(keyPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("stat key: %v", err)
|
|
||||||
}
|
|
||||||
if info.Mode().Perm() != SSHKeyMode {
|
|
||||||
t.Errorf("key mode = %04o, want %04o", info.Mode().Perm(), SSHKeyMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public key file exists with mode 0644.
|
|
||||||
pubPath := filepath.Join(dir, sshPubFile)
|
|
||||||
info, err = os.Stat(pubPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("stat pub: %v", err)
|
|
||||||
}
|
|
||||||
if info.Mode().Perm() != SSHPubMode {
|
|
||||||
t.Errorf("pub mode = %04o, want %04o", info.Mode().Perm(), SSHPubMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public key line is ssh-ed25519 format.
|
|
||||||
if !strings.HasPrefix(string(pubLine), "ssh-ed25519 ") {
|
|
||||||
t.Errorf("pub line = %q, want ssh-ed25519 prefix", string(pubLine))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Private key PEM parses with ssh.ParsePrivateKey (PKCS8).
|
|
||||||
signer, err := ssh.ParsePrivateKey(keyPEM)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parse private key: %v", err)
|
|
||||||
}
|
|
||||||
if signer.PublicKey().Type() != "ssh-ed25519" {
|
|
||||||
t.Errorf("signer key type = %q, want ssh-ed25519", signer.PublicKey().Type())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGenerateOrLoadSSHKey_IdempotentLoad(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
|
|
||||||
// First call generates.
|
|
||||||
keyPEM1, pubLine1, err := GenerateOrLoadSSHKey(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("first generate: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Second call loads existing.
|
|
||||||
keyPEM2, pubLine2, err := GenerateOrLoadSSHKey(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("second load: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if string(keyPEM1) != string(keyPEM2) {
|
|
||||||
t.Error("key was regenerated on second call (D-036 idempotency violation)")
|
|
||||||
}
|
|
||||||
if string(pubLine1) != string(pubLine2) {
|
|
||||||
t.Error("pub was regenerated on second call (D-036 idempotency violation)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGenerateOrLoadSSHKey_EmptyDir(t *testing.T) {
|
|
||||||
_, _, err := GenerateOrLoadSSHKey("")
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error for empty dir")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGenerateOrLoadSSHKey_CreatesDir(t *testing.T) {
|
|
||||||
dir := filepath.Join(t.TempDir(), "nested", "ssh-dir")
|
|
||||||
if _, _, err := GenerateOrLoadSSHKey(dir); err != nil {
|
|
||||||
t.Fatalf("generate with nested dir: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(dir); err != nil {
|
|
||||||
t.Errorf("nested dir not created: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,19 +6,11 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"iter"
|
|
||||||
"log/slog"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// watchInterval is the poll cadence used by JobRepo.Watch and NodeRepo.Watch.
|
|
||||||
// It is an unexported package var (default 1s) so tests can override it to a
|
|
||||||
// small value for deterministic assertions (D-043). Do not change it from
|
|
||||||
// production code paths.
|
|
||||||
var watchInterval = 1 * time.Second
|
|
||||||
|
|
||||||
type JobRepo struct {
|
type JobRepo struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
}
|
}
|
||||||
@@ -67,52 +59,6 @@ func (r *JobRepo) List(ctx context.Context) ([]*model.Job, error) {
|
|||||||
return jobs, rows.Err()
|
return jobs, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch yields the full snapshot of jobs on a watchInterval ticker until ctx
|
|
||||||
// is cancelled or the consumer stops pulling (yield returns false). It does
|
|
||||||
// not spawn a goroutine; the polling loop runs inline in the caller's
|
|
||||||
// goroutine via the range-over-func pull protocol (D-032).
|
|
||||||
//
|
|
||||||
// Each tick re-runs the List query and yields one []*model.Job snapshot
|
|
||||||
// containing ALL rows for that tick (G-001). The first yield happens
|
|
||||||
// immediately before the first ticker wait, so the consumer sees the initial
|
|
||||||
// state with no watchInterval delay (G-002). Transient query/scan errors are
|
|
||||||
// logged via slog.Default().Warn and the loop continues to the next tick
|
|
||||||
// rather than terminating the stream (D-034 lite). The ticker is stopped and
|
|
||||||
// rows are closed on every exit path (ctx.Done, yield==false, scan error).
|
|
||||||
func (r *JobRepo) Watch(ctx context.Context) iter.Seq[[]*model.Job] {
|
|
||||||
return func(yield func([]*model.Job) bool) {
|
|
||||||
ticker := time.NewTicker(watchInterval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for {
|
|
||||||
rows, err := r.db.QueryContext(ctx,
|
|
||||||
`SELECT id, name, spec, status, exit_code, created_at, started_at, ended_at FROM jobs ORDER BY created_at DESC`)
|
|
||||||
if err != nil {
|
|
||||||
slog.Default().Warn("watch jobs: query failed", "error", err)
|
|
||||||
// fall through to the select to wait for the next tick
|
|
||||||
} else {
|
|
||||||
snapshot := make([]*model.Job, 0)
|
|
||||||
for rows.Next() {
|
|
||||||
j, scanErr := scanJob(rows)
|
|
||||||
if scanErr != nil {
|
|
||||||
slog.Default().Warn("watch jobs: scan failed", "error", scanErr)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
snapshot = append(snapshot, j)
|
|
||||||
}
|
|
||||||
rows.Close()
|
|
||||||
if !yield(snapshot) {
|
|
||||||
return // consumer stopped pulling
|
|
||||||
}
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *JobRepo) UpdateStatus(ctx context.Context, id string, status model.JobStatus, exitCode int) error {
|
func (r *JobRepo) UpdateStatus(ctx context.Context, id string, status model.JobStatus, exitCode int) error {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
var startedAt, endedAt *time.Time
|
var startedAt, endedAt *time.Time
|
||||||
|
|||||||
@@ -1,201 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
func openJobTestDB(t *testing.T) (*JobRepo, func()) {
|
|
||||||
t.Helper()
|
|
||||||
path := filepath.Join(t.TempDir(), "test.db")
|
|
||||||
db, err := Open(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open db: %v", err)
|
|
||||||
}
|
|
||||||
return NewJobRepo(db), func() { _ = db.Close() }
|
|
||||||
}
|
|
||||||
|
|
||||||
func insertJob(t *testing.T, repo *JobRepo, ctx context.Context, id, name string) {
|
|
||||||
t.Helper()
|
|
||||||
if err := repo.Insert(ctx, &model.Job{
|
|
||||||
ID: id,
|
|
||||||
Name: name,
|
|
||||||
Spec: "test",
|
|
||||||
Status: model.JobStatusPending,
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("insert job %s: %v", id, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// withFastWatch sets watchInterval to a small value for deterministic tests and
|
|
||||||
// restores the default (1s) on cleanup.
|
|
||||||
func withFastWatch(t *testing.T, d time.Duration) {
|
|
||||||
t.Helper()
|
|
||||||
prev := watchInterval
|
|
||||||
watchInterval = d
|
|
||||||
t.Cleanup(func() { watchInterval = prev })
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestJobRepoWatch_YieldsSnapshots verifies each yield is a complete tick
|
|
||||||
// snapshot (G-001): the first snapshot contains only the first job, and a
|
|
||||||
// later snapshot contains both jobs after a second insert.
|
|
||||||
func TestJobRepoWatch_YieldsSnapshots(t *testing.T) {
|
|
||||||
withFastWatch(t, 10*time.Millisecond)
|
|
||||||
repo, cleanup := openJobTestDB(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
insertJob(t, repo, ctx, "job-1", "alpha")
|
|
||||||
|
|
||||||
var snapshots [][]*model.Job
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
defer close(done)
|
|
||||||
for snap := range repo.Watch(ctx) {
|
|
||||||
snapshots = append(snapshots, snap)
|
|
||||||
if len(snapshots) >= 40 {
|
|
||||||
cancel()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Insert a second job after a short delay so a later tick observes it.
|
|
||||||
// Use a background context — the watch ctx may be cancelled by the
|
|
||||||
// goroutine above once it collects enough snapshots.
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
insertJob(t, repo, context.Background(), "job-2", "beta")
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Fatal("watch did not complete within 2s")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(snapshots) == 0 {
|
|
||||||
t.Fatal("expected at least one snapshot, got none")
|
|
||||||
}
|
|
||||||
// First snapshot must contain only the first job (G-001).
|
|
||||||
if len(snapshots[0]) != 1 || snapshots[0][0].ID != "job-1" {
|
|
||||||
t.Errorf("first snapshot = %+v, want only job-1", snapshots[0])
|
|
||||||
}
|
|
||||||
// At least one later snapshot must contain both jobs.
|
|
||||||
foundBoth := false
|
|
||||||
for _, snap := range snapshots[1:] {
|
|
||||||
ids := make(map[string]bool, len(snap))
|
|
||||||
for _, j := range snap {
|
|
||||||
ids[j.ID] = true
|
|
||||||
}
|
|
||||||
if ids["job-1"] && ids["job-2"] {
|
|
||||||
foundBoth = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !foundBoth {
|
|
||||||
t.Errorf("no snapshot contained both jobs; snapshots=%v", snapshots)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestJobRepoWatch_ImmediateFirstYield verifies G-002: the first snapshot
|
|
||||||
// arrives before the first ticker wait, i.e. well under the watchInterval.
|
|
||||||
func TestJobRepoWatch_ImmediateFirstYield(t *testing.T) {
|
|
||||||
withFastWatch(t, 200*time.Millisecond)
|
|
||||||
repo, cleanup := openJobTestDB(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
insertJob(t, repo, ctx, "job-immediate", "first")
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
var firstSnap []*model.Job
|
|
||||||
got := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
for snap := range repo.Watch(ctx) {
|
|
||||||
firstSnap = snap
|
|
||||||
close(got)
|
|
||||||
cancel()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-got:
|
|
||||||
case <-time.After(100 * time.Millisecond):
|
|
||||||
t.Fatal("first yield took >100ms; expected immediate (G-002)")
|
|
||||||
}
|
|
||||||
|
|
||||||
elapsed := time.Since(start)
|
|
||||||
if elapsed > 100*time.Millisecond {
|
|
||||||
t.Errorf("first yield took %v; expected immediate (G-002)", elapsed)
|
|
||||||
}
|
|
||||||
if len(firstSnap) != 1 || firstSnap[0].ID != "job-immediate" {
|
|
||||||
t.Errorf("first snapshot = %+v, want job-immediate", firstSnap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestJobRepoWatch_StopsOnConsumerBreak verifies the yield==false path: the
|
|
||||||
// range loop returns promptly when the consumer breaks after the first yield.
|
|
||||||
func TestJobRepoWatch_StopsOnConsumerBreak(t *testing.T) {
|
|
||||||
withFastWatch(t, 10*time.Millisecond)
|
|
||||||
repo, cleanup := openJobTestDB(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
insertJob(t, repo, ctx, "job-break", "break")
|
|
||||||
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
defer close(done)
|
|
||||||
for range repo.Watch(ctx) {
|
|
||||||
break // stop pulling immediately after the first snapshot
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
// success: range returned
|
|
||||||
case <-time.After(500 * time.Millisecond):
|
|
||||||
t.Fatal("watch did not stop on consumer break within 500ms")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestJobRepoWatch_StopsOnCtxCancel verifies the loop exits promptly after
|
|
||||||
// ctx is cancelled.
|
|
||||||
func TestJobRepoWatch_StopsOnCtxCancel(t *testing.T) {
|
|
||||||
withFastWatch(t, 10*time.Millisecond)
|
|
||||||
repo, cleanup := openJobTestDB(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
|
|
||||||
insertJob(t, repo, ctx, "job-cancel", "cancel")
|
|
||||||
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
defer close(done)
|
|
||||||
for range repo.Watch(ctx) {
|
|
||||||
// drain until cancelled
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Let at least one tick land, then cancel.
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
// success
|
|
||||||
case <-time.After(500 * time.Millisecond):
|
|
||||||
t.Fatal("watch did not stop on ctx cancel within 500ms")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,21 +12,6 @@ import (
|
|||||||
//go:embed migrations/*.sql
|
//go:embed migrations/*.sql
|
||||||
var migrationsFS embed.FS
|
var migrationsFS embed.FS
|
||||||
|
|
||||||
// MigrationVersion returns the name of the highest applied migration
|
|
||||||
// (e.g. "0005_node_capacity.sql"). Returns ("", nil) if no migrations
|
|
||||||
// have been applied (fresh or empty database).
|
|
||||||
func MigrationVersion(ctx context.Context, db *sql.DB) (string, error) {
|
|
||||||
var name string
|
|
||||||
err := db.QueryRowContext(ctx, `SELECT name FROM schema_migrations ORDER BY name DESC LIMIT 1`).Scan(&name)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("query migration version: %w", err)
|
|
||||||
}
|
|
||||||
return name, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func migrate(db *sql.DB) error {
|
func migrate(db *sql.DB) error {
|
||||||
entries, err := migrationsFS.ReadDir("migrations")
|
entries, err := migrationsFS.ReadDir("migrations")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestMigrationVersion(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
db, err := Open(filepath.Join(dir, "test.db"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open db: %v", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
version, err := MigrationVersion(ctx, db)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Empty the migrations table → should return ("", nil).
|
|
||||||
if _, err := db.ExecContext(ctx, "DELETE FROM schema_migrations"); err != nil {
|
|
||||||
t.Fatalf("clear migrations: %v", err)
|
|
||||||
}
|
|
||||||
version, err = MigrationVersion(ctx, db)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("migration version after clear: %v", err)
|
|
||||||
}
|
|
||||||
if version != "" {
|
|
||||||
t.Errorf("MigrationVersion after clear = %q, want empty", version)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
-- Node kind and OS columns (v0.6 P01, REQ-049).
|
|
||||||
-- Nullable for backward compatibility: existing rows get NULL, which
|
|
||||||
-- the Go scanNode helper maps to "" (empty string). New rows from
|
|
||||||
-- `orca init` get kind='localhost', os=<detected>; proxmox joins get
|
|
||||||
-- kind='proxmox', os='pve'.
|
|
||||||
ALTER TABLE nodes ADD COLUMN kind TEXT;
|
|
||||||
ALTER TABLE nodes ADD COLUMN os TEXT;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_nodes_kind ON nodes(kind);
|
|
||||||
@@ -6,8 +6,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"iter"
|
|
||||||
"log/slog"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||||
@@ -38,8 +36,8 @@ func (r *NodeRepo) Insert(ctx context.Context, n *model.Node) error {
|
|||||||
return fmt.Errorf("marshal metadata: %w", err)
|
return fmt.Errorf("marshal metadata: %w", err)
|
||||||
}
|
}
|
||||||
_, err = r.db.ExecContext(ctx,
|
_, err = r.db.ExecContext(ctx,
|
||||||
`INSERT INTO nodes (id, name, address, state, joined_at, last_seen, metadata, kind, os) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
`INSERT INTO nodes (id, name, address, state, joined_at, last_seen, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
n.ID, n.Name, n.Address, string(n.State), n.JoinedAt, n.LastSeen, string(metaJSON), n.Kind, n.OS)
|
n.ID, n.Name, n.Address, string(n.State), n.JoinedAt, n.LastSeen, string(metaJSON))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("insert node: %w", err)
|
return fmt.Errorf("insert node: %w", err)
|
||||||
}
|
}
|
||||||
@@ -48,19 +46,13 @@ func (r *NodeRepo) Insert(ctx context.Context, n *model.Node) error {
|
|||||||
|
|
||||||
func (r *NodeRepo) Get(ctx context.Context, id string) (*model.Node, error) {
|
func (r *NodeRepo) Get(ctx context.Context, id string) (*model.Node, error) {
|
||||||
row := r.db.QueryRowContext(ctx,
|
row := r.db.QueryRowContext(ctx,
|
||||||
`SELECT id, name, address, state, joined_at, last_seen, metadata, kind, os FROM nodes WHERE id = ?`, id)
|
`SELECT id, name, address, state, joined_at, last_seen, metadata FROM nodes WHERE id = ?`, id)
|
||||||
return scanNode(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *NodeRepo) GetByName(ctx context.Context, name string) (*model.Node, error) {
|
|
||||||
row := r.db.QueryRowContext(ctx,
|
|
||||||
`SELECT id, name, address, state, joined_at, last_seen, metadata, kind, os FROM nodes WHERE name = ? ORDER BY joined_at ASC LIMIT 1`, name)
|
|
||||||
return scanNode(row)
|
return scanNode(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *NodeRepo) List(ctx context.Context) ([]*model.Node, error) {
|
func (r *NodeRepo) List(ctx context.Context) ([]*model.Node, error) {
|
||||||
rows, err := r.db.QueryContext(ctx,
|
rows, err := r.db.QueryContext(ctx,
|
||||||
`SELECT id, name, address, state, joined_at, last_seen, metadata, kind, os FROM nodes ORDER BY joined_at ASC`)
|
`SELECT id, name, address, state, joined_at, last_seen, metadata FROM nodes ORDER BY joined_at ASC`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list nodes: %w", err)
|
return nil, fmt.Errorf("list nodes: %w", err)
|
||||||
}
|
}
|
||||||
@@ -77,40 +69,6 @@ func (r *NodeRepo) List(ctx context.Context) ([]*model.Node, error) {
|
|||||||
return nodes, rows.Err()
|
return nodes, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *NodeRepo) Watch(ctx context.Context) iter.Seq[[]*model.Node] {
|
|
||||||
return func(yield func([]*model.Node) bool) {
|
|
||||||
ticker := time.NewTicker(watchInterval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for {
|
|
||||||
rows, err := r.db.QueryContext(ctx,
|
|
||||||
`SELECT id, name, address, state, joined_at, last_seen, metadata, kind, os FROM nodes ORDER BY joined_at ASC`)
|
|
||||||
if err != nil {
|
|
||||||
slog.Default().Warn("watch nodes: query failed", "error", err)
|
|
||||||
// fall through to the select to wait for the next tick
|
|
||||||
} else {
|
|
||||||
snapshot := make([]*model.Node, 0)
|
|
||||||
for rows.Next() {
|
|
||||||
n, scanErr := scanNode(rows)
|
|
||||||
if scanErr != nil {
|
|
||||||
slog.Default().Warn("watch nodes: scan failed", "error", scanErr)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
snapshot = append(snapshot, n)
|
|
||||||
}
|
|
||||||
rows.Close()
|
|
||||||
if !yield(snapshot) {
|
|
||||||
return // consumer stopped pulling
|
|
||||||
}
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *NodeRepo) UpdateState(ctx context.Context, id string, state model.NodeState) error {
|
func (r *NodeRepo) UpdateState(ctx context.Context, id string, state model.NodeState) error {
|
||||||
res, err := r.db.ExecContext(ctx,
|
res, err := r.db.ExecContext(ctx,
|
||||||
`UPDATE nodes SET state = ?, last_seen = ? WHERE id = ?`,
|
`UPDATE nodes SET state = ?, last_seen = ? WHERE id = ?`,
|
||||||
@@ -125,23 +83,6 @@ func (r *NodeRepo) UpdateState(ctx context.Context, id string, state model.NodeS
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateLastSeenAndOS refreshes the last_seen timestamp and os field
|
|
||||||
// of an existing node without changing its id or joined_at. Used by
|
|
||||||
// `orca init` re-runs to refresh the localhost node (D-036 idempotency).
|
|
||||||
func (r *NodeRepo) UpdateLastSeenAndOS(ctx context.Context, id, os string) error {
|
|
||||||
res, err := r.db.ExecContext(ctx,
|
|
||||||
`UPDATE nodes SET last_seen = ?, os = ? WHERE id = ?`,
|
|
||||||
time.Now().UTC(), os, id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("update node last_seen+os: %w", err)
|
|
||||||
}
|
|
||||||
rows, _ := res.RowsAffected()
|
|
||||||
if rows == 0 {
|
|
||||||
return ErrNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *NodeRepo) Delete(ctx context.Context, id string) error {
|
func (r *NodeRepo) Delete(ctx context.Context, id string) error {
|
||||||
res, err := r.db.ExecContext(ctx, `DELETE FROM nodes WHERE id = ?`, id)
|
res, err := r.db.ExecContext(ctx, `DELETE FROM nodes WHERE id = ?`, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -163,10 +104,8 @@ func scanNode(s scanner) (*model.Node, error) {
|
|||||||
n model.Node
|
n model.Node
|
||||||
state string
|
state string
|
||||||
metaJSON sql.NullString
|
metaJSON sql.NullString
|
||||||
kind sql.NullString
|
|
||||||
os sql.NullString
|
|
||||||
)
|
)
|
||||||
err := s.Scan(&n.ID, &n.Name, &n.Address, &state, &n.JoinedAt, &n.LastSeen, &metaJSON, &kind, &os)
|
err := s.Scan(&n.ID, &n.Name, &n.Address, &state, &n.JoinedAt, &n.LastSeen, &metaJSON)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -179,8 +118,5 @@ func scanNode(s scanner) (*model.Node, error) {
|
|||||||
return nil, fmt.Errorf("unmarshal metadata: %w", err)
|
return nil, fmt.Errorf("unmarshal metadata: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Map SQL NULL → "" for backward compatibility with pre-0006 rows.
|
|
||||||
n.Kind = kind.String
|
|
||||||
n.OS = os.String
|
|
||||||
return &n, nil
|
return &n, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,272 +100,3 @@ func TestNodeRepo_Delete(t *testing.T) {
|
|||||||
t.Errorf("expected ErrNotFound, got %v", err)
|
t.Errorf("expected ErrNotFound, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNodeRepo_KindOS_RoundTrip(t *testing.T) {
|
|
||||||
repo, cleanup := openTestDB(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
n := &model.Node{
|
|
||||||
ID: "kind-os-1", Name: "localhost", Address: "localhost:8443",
|
|
||||||
State: model.NodeStateReady, JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(),
|
|
||||||
Kind: string(model.NodeKindLocalhost), OS: "ubuntu",
|
|
||||||
}
|
|
||||||
if err := repo.Insert(ctx, n); err != nil {
|
|
||||||
t.Fatalf("insert: %v", err)
|
|
||||||
}
|
|
||||||
got, err := repo.Get(ctx, "kind-os-1")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get: %v", err)
|
|
||||||
}
|
|
||||||
if got.Kind != "localhost" {
|
|
||||||
t.Errorf("kind = %q, want localhost", got.Kind)
|
|
||||||
}
|
|
||||||
if got.OS != "ubuntu" {
|
|
||||||
t.Errorf("os = %q, want ubuntu", got.OS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNodeRepo_NullKindOS_EmptyString(t *testing.T) {
|
|
||||||
repo, cleanup := openTestDB(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
// Insert with empty Kind/OS — simulates a pre-0006 row or a node
|
|
||||||
// that doesn't set kind/os.
|
|
||||||
n := &model.Node{
|
|
||||||
ID: "null-kind-os", Name: "legacy", Address: "addr",
|
|
||||||
JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(),
|
|
||||||
}
|
|
||||||
if err := repo.Insert(ctx, n); err != nil {
|
|
||||||
t.Fatalf("insert: %v", err)
|
|
||||||
}
|
|
||||||
got, err := repo.Get(ctx, "null-kind-os")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get: %v", err)
|
|
||||||
}
|
|
||||||
if got.Kind != "" {
|
|
||||||
t.Errorf("kind = %q, want empty string for NULL", got.Kind)
|
|
||||||
}
|
|
||||||
if got.OS != "" {
|
|
||||||
t.Errorf("os = %q, want empty string for NULL", got.OS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNodeRepo_GetByName(t *testing.T) {
|
|
||||||
repo, cleanup := openTestDB(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
_ = repo.Insert(ctx, &model.Node{
|
|
||||||
ID: "by-name-1", Name: "localhost", Address: "addr",
|
|
||||||
JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(),
|
|
||||||
Kind: "localhost", OS: "ubuntu",
|
|
||||||
})
|
|
||||||
|
|
||||||
got, err := repo.GetByName(ctx, "localhost")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get by name: %v", err)
|
|
||||||
}
|
|
||||||
if got.ID != "by-name-1" {
|
|
||||||
t.Errorf("id = %q, want by-name-1", got.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = repo.GetByName(ctx, "nonexistent")
|
|
||||||
if err != ErrNotFound {
|
|
||||||
t.Errorf("expected ErrNotFound, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNodeRepo_UpdateLastSeenAndOS(t *testing.T) {
|
|
||||||
repo, cleanup := openTestDB(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
original := time.Now().UTC().Add(-1 * time.Hour)
|
|
||||||
n := &model.Node{
|
|
||||||
ID: "update-os-1", Name: "localhost", Address: "addr",
|
|
||||||
JoinedAt: original, LastSeen: original,
|
|
||||||
Kind: "localhost", OS: "ubuntu",
|
|
||||||
}
|
|
||||||
if err := repo.Insert(ctx, n); err != nil {
|
|
||||||
t.Fatalf("insert: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := repo.UpdateLastSeenAndOS(ctx, "update-os-1", "debian"); err != nil {
|
|
||||||
t.Fatalf("update last_seen+os: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := repo.Get(ctx, "update-os-1")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get: %v", err)
|
|
||||||
}
|
|
||||||
if got.OS != "debian" {
|
|
||||||
t.Errorf("os = %q, want debian", got.OS)
|
|
||||||
}
|
|
||||||
if !got.LastSeen.After(original) {
|
|
||||||
t.Errorf("last_seen not refreshed: %v", got.LastSeen)
|
|
||||||
}
|
|
||||||
if !got.JoinedAt.Equal(original) {
|
|
||||||
t.Errorf("joined_at changed: was %v, now %v (D-036 violation)", original, got.JoinedAt)
|
|
||||||
}
|
|
||||||
if got.ID != "update-os-1" {
|
|
||||||
t.Errorf("id changed: %q (D-036 violation)", got.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func insertNode(t *testing.T, repo *NodeRepo, ctx context.Context, id, name string) {
|
|
||||||
t.Helper()
|
|
||||||
if err := repo.Insert(ctx, &model.Node{
|
|
||||||
ID: id,
|
|
||||||
Name: name,
|
|
||||||
Address: "addr",
|
|
||||||
State: model.NodeStateReady,
|
|
||||||
JoinedAt: time.Now().UTC(),
|
|
||||||
LastSeen: time.Now().UTC(),
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("insert node %s: %v", id, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNodeRepoWatch_YieldsSnapshots(t *testing.T) {
|
|
||||||
withFastWatch(t, 10*time.Millisecond)
|
|
||||||
repo, cleanup := openTestDB(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
insertNode(t, repo, ctx, "node-1", "alpha")
|
|
||||||
|
|
||||||
var snapshots [][]*model.Node
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
defer close(done)
|
|
||||||
for snap := range repo.Watch(ctx) {
|
|
||||||
snapshots = append(snapshots, snap)
|
|
||||||
if len(snapshots) >= 40 {
|
|
||||||
cancel()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
insertNode(t, repo, context.Background(), "node-2", "beta")
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Fatal("watch did not complete within 2s")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(snapshots) == 0 {
|
|
||||||
t.Fatal("expected at least one snapshot, got none")
|
|
||||||
}
|
|
||||||
if len(snapshots[0]) != 1 || snapshots[0][0].ID != "node-1" {
|
|
||||||
t.Errorf("first snapshot = %+v, want only node-1", snapshots[0])
|
|
||||||
}
|
|
||||||
foundBoth := false
|
|
||||||
for _, snap := range snapshots[1:] {
|
|
||||||
ids := make(map[string]bool, len(snap))
|
|
||||||
for _, n := range snap {
|
|
||||||
ids[n.ID] = true
|
|
||||||
}
|
|
||||||
if ids["node-1"] && ids["node-2"] {
|
|
||||||
foundBoth = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !foundBoth {
|
|
||||||
t.Errorf("no snapshot contained both nodes; snapshots=%v", snapshots)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNodeRepoWatch_ImmediateFirstYield(t *testing.T) {
|
|
||||||
withFastWatch(t, 200*time.Millisecond)
|
|
||||||
repo, cleanup := openTestDB(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
insertNode(t, repo, ctx, "node-immediate", "first")
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
var firstSnap []*model.Node
|
|
||||||
got := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
for snap := range repo.Watch(ctx) {
|
|
||||||
firstSnap = snap
|
|
||||||
close(got)
|
|
||||||
cancel()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-got:
|
|
||||||
case <-time.After(100 * time.Millisecond):
|
|
||||||
t.Fatal("first yield took >100ms; expected immediate (G-002)")
|
|
||||||
}
|
|
||||||
|
|
||||||
elapsed := time.Since(start)
|
|
||||||
if elapsed > 100*time.Millisecond {
|
|
||||||
t.Errorf("first yield took %v; expected immediate (G-002)", elapsed)
|
|
||||||
}
|
|
||||||
if len(firstSnap) != 1 || firstSnap[0].ID != "node-immediate" {
|
|
||||||
t.Errorf("first snapshot = %+v, want node-immediate", firstSnap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNodeRepoWatch_StopsOnConsumerBreak(t *testing.T) {
|
|
||||||
withFastWatch(t, 10*time.Millisecond)
|
|
||||||
repo, cleanup := openTestDB(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
insertNode(t, repo, ctx, "node-break", "break")
|
|
||||||
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
defer close(done)
|
|
||||||
for range repo.Watch(ctx) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
case <-time.After(500 * time.Millisecond):
|
|
||||||
t.Fatal("watch did not stop on consumer break within 500ms")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNodeRepoWatch_StopsOnCtxCancel(t *testing.T) {
|
|
||||||
withFastWatch(t, 10*time.Millisecond)
|
|
||||||
repo, cleanup := openTestDB(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
|
|
||||||
insertNode(t, repo, ctx, "node-cancel", "cancel")
|
|
||||||
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
defer close(done)
|
|
||||||
for range repo.Watch(ctx) {
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
case <-time.After(500 * time.Millisecond):
|
|
||||||
t.Fatal("watch did not stop on ctx cancel within 500ms")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func Open(path string) (*sql.DB, error) {
|
func Open(path string) (*sql.DB, error) {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
path = certpaths.DBPath()
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get home dir: %w", err)
|
||||||
|
}
|
||||||
|
path = filepath.Join(home, ".orca", "orca.db")
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
return nil, fmt.Errorf("create db dir: %w", err)
|
return nil, fmt.Errorf("create db dir: %w", err)
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# install.sh — 1-liner installer for orca
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# curl -fsSL https://git.cloudinit.dev/coreci/orca/raw/branch/main/scripts/install.sh | bash
|
|
||||||
# curl -fsSL https://git.cloudinit.dev/coreci/orca/raw/branch/main/scripts/install.sh | bash -s -- --system
|
|
||||||
# curl -fsSL https://git.cloudinit.dev/coreci/orca/raw/branch/main/scripts/install.sh | bash -s -- --version v0.4.2
|
|
||||||
#
|
|
||||||
# Options:
|
|
||||||
# --system Install at system level (/usr/local/bin/orca, namespace /root/.orca). Requires root.
|
|
||||||
# --version <tag> Pin a specific version (e.g. v0.4.2). Default: latest release.
|
|
||||||
# --help, -h Show this help.
|
|
||||||
#
|
|
||||||
# Behavior:
|
|
||||||
# - Downloads the release tarball from the public Gitea release URL.
|
|
||||||
# - Extracts the orca binary to the install path.
|
|
||||||
# - If an existing orca binary is found, reads its version and prints
|
|
||||||
# "updated from X to Y" (in-place update; preserves config/db/certs).
|
|
||||||
# - Idempotent: re-running with the same version reinstalls the binary.
|
|
||||||
# - Never touches the namespace dir (~/.orca or /root/.orca) — that's user state.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
GITEA_URL="${GITEA_URL:-https://git.cloudinit.dev}"
|
|
||||||
GITEA_OWNER="${GITEA_OWNER:-coreci}"
|
|
||||||
GITEA_REPO="${GITEA_REPO:-orca}"
|
|
||||||
|
|
||||||
SYSTEM=false
|
|
||||||
VERSION=""
|
|
||||||
INSTALL_BIN=""
|
|
||||||
NAMESPACE_DIR=""
|
|
||||||
|
|
||||||
err() { echo "install: error: $*" >&2; exit 1; }
|
|
||||||
info() { echo "install: $*"; }
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
sed -n '2,/^$/p' "$0" | sed 's/^# \?//' >&2
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- parse args ------------------------------------------------------------
|
|
||||||
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
--system) SYSTEM=true; shift ;;
|
|
||||||
--version) VERSION="${2:-}"; shift 2 ;;
|
|
||||||
--version=*) VERSION="${1#*=}"; shift ;;
|
|
||||||
--help|-h) usage ;;
|
|
||||||
*) err "unknown argument: $1 (try --help)" ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# --- determine install paths ----------------------------------------------
|
|
||||||
|
|
||||||
if [ "$SYSTEM" = "true" ]; then
|
|
||||||
if [ "$(id -u)" -ne 0 ]; then
|
|
||||||
err "--system requires root (uid 0). Re-run with sudo or drop --system for user-level install."
|
|
||||||
fi
|
|
||||||
INSTALL_BIN="/usr/local/bin/orca"
|
|
||||||
NAMESPACE_DIR="/root/.orca"
|
|
||||||
else
|
|
||||||
INSTALL_BIN="${HOME}/.local/bin/orca"
|
|
||||||
NAMESPACE_DIR="${HOME}/.orca"
|
|
||||||
fi
|
|
||||||
|
|
||||||
INSTALL_DIR="$(dirname "$INSTALL_BIN")"
|
|
||||||
|
|
||||||
# --- determine version ----------------------------------------------------
|
|
||||||
|
|
||||||
if [ -z "$VERSION" ]; then
|
|
||||||
info "querying latest release from ${GITEA_URL}..."
|
|
||||||
VERSION="$(curl -fsSL "${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/latest" \
|
|
||||||
| sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
|
|
||||||
| head -1)"
|
|
||||||
if [ -z "$VERSION" ]; then
|
|
||||||
err "could not determine latest release version from Gitea API"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
info "version: ${VERSION}"
|
|
||||||
|
|
||||||
# --- detect arch ----------------------------------------------------------
|
|
||||||
|
|
||||||
ARCH="$(uname -m)"
|
|
||||||
case "$ARCH" in
|
|
||||||
x86_64) ARCH=amd64 ;;
|
|
||||||
aarch64|arm64) ARCH=arm64 ;;
|
|
||||||
armv7l) ARCH=armv7 ;;
|
|
||||||
*) err "unsupported architecture: ${ARCH} (supported: amd64, arm64, armv7)" ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
|
||||||
TARBALL="orca-${VERSION}-${OS}-${ARCH}.tar.gz"
|
|
||||||
|
|
||||||
# --- find asset download URL ----------------------------------------------
|
|
||||||
|
|
||||||
info "locating asset ${TARBALL}..."
|
|
||||||
ASSET_URL="$(curl -fsSL "${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/tags/${VERSION}" \
|
|
||||||
| sed -n 's/.*"browser_download_url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
|
|
||||||
| grep "/${TARBALL}\$" \
|
|
||||||
| head -1)"
|
|
||||||
|
|
||||||
if [ -z "$ASSET_URL" ]; then
|
|
||||||
err "could not find asset ${TARBALL} in release ${VERSION}. Check that the release exists and has a linux-${ARCH} tarball."
|
|
||||||
fi
|
|
||||||
info "asset: ${ASSET_URL}"
|
|
||||||
|
|
||||||
# --- in-place update detection -------------------------------------------
|
|
||||||
|
|
||||||
OLD_VERSION=""
|
|
||||||
if [ -x "$INSTALL_BIN" ]; then
|
|
||||||
OLD_VERSION="$("$INSTALL_BIN" version --json 2>/dev/null | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1 || echo "")"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- download + extract ---------------------------------------------------
|
|
||||||
|
|
||||||
TMPDIR="$(mktemp -d)"
|
|
||||||
trap 'rm -rf "$TMPDIR"' EXIT
|
|
||||||
|
|
||||||
info "downloading..."
|
|
||||||
curl -fsSL -o "${TMPDIR}/${TARBALL}" "$ASSET_URL"
|
|
||||||
|
|
||||||
info "extracting..."
|
|
||||||
tar -xzf "${TMPDIR}/${TARBALL}" -C "$TMPDIR"
|
|
||||||
|
|
||||||
if [ ! -f "${TMPDIR}/orca" ]; then
|
|
||||||
err "tarball did not contain an 'orca' binary"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- install --------------------------------------------------------------
|
|
||||||
|
|
||||||
mkdir -p "$INSTALL_DIR"
|
|
||||||
install -m 0755 "${TMPDIR}/orca" "$INSTALL_BIN"
|
|
||||||
|
|
||||||
# --- report ---------------------------------------------------------------
|
|
||||||
|
|
||||||
if [ -n "$OLD_VERSION" ]; then
|
|
||||||
if [ "$OLD_VERSION" = "$VERSION" ]; then
|
|
||||||
info "✓ reinstalled orca ${VERSION} at ${INSTALL_BIN}"
|
|
||||||
else
|
|
||||||
info "✓ updated orca from ${OLD_VERSION} to ${VERSION} at ${INSTALL_BIN}"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
info "✓ installed orca ${VERSION} to ${INSTALL_BIN}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$SYSTEM" = "true" ]; then
|
|
||||||
info " namespace root: ${NAMESPACE_DIR} (use 'orca --system init' to initialize)"
|
|
||||||
else
|
|
||||||
info " namespace root: ${NAMESPACE_DIR} (use 'orca init' to initialize)"
|
|
||||||
if ! echo "$PATH" | grep -q "$INSTALL_DIR"; then
|
|
||||||
info " NOTE: $INSTALL_DIR is not on your PATH. Add it:"
|
|
||||||
info " export PATH=\"\$PATH:$INSTALL_DIR\""
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
info " verify: ${INSTALL_BIN} version"
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# install_test.sh — tests for scripts/install.sh
|
|
||||||
#
|
|
||||||
# Tests install.sh against the real public Gitea releases (REQ-045 made
|
|
||||||
# the repo + releases publicly accessible). Uses real existing release
|
|
||||||
# tags (v0.4.1, v0.4.2) so no mock infrastructure is needed.
|
|
||||||
#
|
|
||||||
# Tests:
|
|
||||||
# 1. user-level install (binary at ~/.local/bin/orca)
|
|
||||||
# 2. in-place update (v0.4.1 -> v0.4.2) preserves namespace state
|
|
||||||
# 3. idempotent re-install (v0.4.2 -> v0.4.2)
|
|
||||||
# 4. --system install (requires root; /usr/local/bin/orca)
|
|
||||||
# 5. --system without root fails with error
|
|
||||||
#
|
|
||||||
# Usage: bash scripts/install_test.sh
|
|
||||||
# sudo bash scripts/install_test.sh (to include --system tests)
|
|
||||||
#
|
|
||||||
# Each test is wrapped in `timeout 30` to prevent hangs. The whole
|
|
||||||
# suite is wrapped in `timeout 120`.
|
|
||||||
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
INSTALL_SH="$SCRIPT_DIR/install.sh"
|
|
||||||
|
|
||||||
PASS=0
|
|
||||||
FAIL=0
|
|
||||||
|
|
||||||
ok() { echo " PASS: $1"; PASS=$((PASS+1)); }
|
|
||||||
fail() { echo " FAIL: $1"; FAIL=$((FAIL+1)); }
|
|
||||||
|
|
||||||
# Kill any background processes on exit (defensive — no background procs
|
|
||||||
# expected in this version, but keeps the harness safe).
|
|
||||||
trap 'kill 0 2>/dev/null || true' EXIT
|
|
||||||
|
|
||||||
run_install() {
|
|
||||||
timeout 30 bash "$INSTALL_SH" "$@" 2>&1
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "=== Test 1: user-level install (v0.4.1) ==="
|
|
||||||
FAKE_HOME="$(mktemp -d)"
|
|
||||||
export HOME="$FAKE_HOME"
|
|
||||||
if run_install --version v0.4.1 > /tmp/it1.log 2>&1; then
|
|
||||||
if [ -x "$FAKE_HOME/.local/bin/orca" ]; then
|
|
||||||
ok "binary at ~/.local/bin/orca"
|
|
||||||
else
|
|
||||||
fail "binary not at ~/.local/bin/orca"
|
|
||||||
fi
|
|
||||||
INSTALLED_VER="$(timeout 5 "$FAKE_HOME/.local/bin/orca" version --json 2>/dev/null | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
|
|
||||||
if [ "$INSTALLED_VER" = "v0.4.1" ]; then
|
|
||||||
ok "installed version is v0.4.1"
|
|
||||||
else
|
|
||||||
fail "installed version is '${INSTALLED_VER}', expected v0.4.1"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
fail "user install exited non-zero"; cat /tmp/it1.log
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "=== Test 2: in-place update (v0.4.1 -> v0.4.2) preserves namespace ==="
|
|
||||||
mkdir -p "$FAKE_HOME/.orca"
|
|
||||||
echo "preserve-me" > "$FAKE_HOME/.orca/orca.db"
|
|
||||||
if run_install --version v0.4.2 > /tmp/it2.log 2>&1; then
|
|
||||||
if grep -q "updated orca from v0.4.1 to v0.4.2" /tmp/it2.log; then
|
|
||||||
ok "update message printed"
|
|
||||||
else
|
|
||||||
fail "update message not printed"; cat /tmp/it2.log
|
|
||||||
fi
|
|
||||||
if [ "$(cat "$FAKE_HOME/.orca/orca.db" 2>/dev/null)" = "preserve-me" ]; then
|
|
||||||
ok "namespace state preserved during update"
|
|
||||||
else
|
|
||||||
fail "namespace state was modified or removed during update"
|
|
||||||
fi
|
|
||||||
INSTALLED_VER="$(timeout 5 "$FAKE_HOME/.local/bin/orca" version --json 2>/dev/null | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
|
|
||||||
if [ "$INSTALLED_VER" = "v0.4.2" ]; then
|
|
||||||
ok "binary updated to v0.4.2"
|
|
||||||
else
|
|
||||||
fail "binary version is '${INSTALLED_VER}', expected v0.4.2"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
fail "update exited non-zero"; cat /tmp/it2.log
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "=== Test 3: idempotent re-install (v0.4.2 -> v0.4.2) ==="
|
|
||||||
if run_install --version v0.4.2 > /tmp/it3.log 2>&1; then
|
|
||||||
if grep -q "reinstalled orca v0.4.2" /tmp/it3.log; then
|
|
||||||
ok "reinstall message printed"
|
|
||||||
else
|
|
||||||
fail "reinstall message not printed"; cat /tmp/it3.log
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
fail "reinstall exited non-zero"; cat /tmp/it3.log
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "=== Test 4: --system install (requires root) ==="
|
|
||||||
if [ "$(id -u)" -eq 0 ]; then
|
|
||||||
if run_install --system --version v0.4.1 > /tmp/it4.log 2>&1; then
|
|
||||||
if [ -x /usr/local/bin/orca ]; then
|
|
||||||
ok "binary at /usr/local/bin/orca"
|
|
||||||
else
|
|
||||||
fail "binary not at /usr/local/bin/orca"
|
|
||||||
fi
|
|
||||||
if grep -q "namespace root: /root/.orca" /tmp/it4.log; then
|
|
||||||
ok "--system reports /root/.orca namespace"
|
|
||||||
else
|
|
||||||
fail "--system did not report /root/.orca namespace"; cat /tmp/it4.log
|
|
||||||
fi
|
|
||||||
rm -f /usr/local/bin/orca
|
|
||||||
else
|
|
||||||
fail "--system install exited non-zero"; cat /tmp/it4.log
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo " SKIP: --system test (not running as root)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "=== Test 5: --system without root fails ==="
|
|
||||||
if [ "$(id -u)" -ne 0 ]; then
|
|
||||||
if run_install --system --version v0.4.1 2>&1 | grep -q "requires root"; then
|
|
||||||
ok "--system without root correctly errors"
|
|
||||||
else
|
|
||||||
fail "--system without root did not error"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo " SKIP: --system-without-root test (running as root)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== Results: $PASS passed, $FAIL failed ==="
|
|
||||||
rm -rf "$FAKE_HOME" /tmp/it1.log /tmp/it2.log /tmp/it3.log /tmp/it4.log 2>/dev/null
|
|
||||||
exit $FAIL
|
|
||||||
@@ -136,39 +136,3 @@ tea releases create "$VERSION" \
|
|||||||
--asset "$TARBALL"
|
--asset "$TARBALL"
|
||||||
|
|
||||||
info "✓ release $VERSION published"
|
info "✓ release $VERSION published"
|
||||||
|
|
||||||
# --- publish container image to gitea registry (REQ-046) ------------------
|
|
||||||
# Skipped gracefully if docker is not on PATH (e.g. local dev without docker).
|
|
||||||
# The .coreci.yml release pipeline has a dedicated container-publish step
|
|
||||||
# that runs in a docker:24-cli image with docker-in-docker.
|
|
||||||
|
|
||||||
CONTAINER_REGISTRY="${CONTAINER_REGISTRY:-git.cloudinit.dev}"
|
|
||||||
CONTAINER_OWNER="${CONTAINER_OWNER:-coreci}"
|
|
||||||
CONTAINER_IMAGE="${CONTAINER_IMAGE:-orca}"
|
|
||||||
IMAGE="${CONTAINER_REGISTRY}/${CONTAINER_OWNER}/${CONTAINER_IMAGE}"
|
|
||||||
|
|
||||||
if ! command -v docker >/dev/null 2>&1; then
|
|
||||||
info "docker not found on PATH — skipping container image publish (CI handles it)."
|
|
||||||
else
|
|
||||||
info "building container image ${IMAGE}:${VERSION}..."
|
|
||||||
docker build \
|
|
||||||
--build-arg VERSION="$VERSION" \
|
|
||||||
--build-arg GIT_COMMIT="$GIT_COMMIT" \
|
|
||||||
--build-arg BUILD_TIME="$BUILD_TIME" \
|
|
||||||
-t "${IMAGE}:${VERSION}" \
|
|
||||||
-t "${IMAGE}:latest" \
|
|
||||||
"$REPO_ROOT"
|
|
||||||
|
|
||||||
if [ -z "${GITEA_TOKEN:-}" ]; then
|
|
||||||
info "GITEA_TOKEN not set — skipping docker push (image built locally only)."
|
|
||||||
else
|
|
||||||
info "logging in to ${CONTAINER_REGISTRY}..."
|
|
||||||
echo "$GITEA_TOKEN" | docker login "$CONTAINER_REGISTRY" -u cloudinit-bot --password-stdin
|
|
||||||
info "pushing ${IMAGE}:${VERSION}..."
|
|
||||||
docker push "${IMAGE}:${VERSION}"
|
|
||||||
info "pushing ${IMAGE}:latest..."
|
|
||||||
docker push "${IMAGE}:latest"
|
|
||||||
docker logout "$CONTAINER_REGISTRY"
|
|
||||||
info "✓ container image ${IMAGE}:${VERSION} published"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|||||||
Reference in New Issue
Block a user