Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 78334f1f74 | |||
| c7dbcef958 | |||
| 9580f347c6 | |||
| 46e929e4c6 | |||
| 503923bf1e | |||
| e3f6e1df82 | |||
| aa3cccead5 | |||
| c2038952c7 | |||
| 65eb2e601b | |||
| 6f34f1794b | |||
| bc7ce1caf6 |
+53
-409
@@ -2,193 +2,66 @@
|
||||
|
||||
## System Overview
|
||||
|
||||
Orca is a single-binary, offline-first orchestration engine. The system consists
|
||||
of three logical layers (CLI, Daemon, Engine) compiled into one `orca` binary
|
||||
and selected via subcommands. v0.2 adds a **cross-node transport layer** (mTLS)
|
||||
and a **dispatcher** for multi-node job execution.
|
||||
Orca is a single-binary, offline-first orchestration engine. The system consists of three logical components, all compiled into one `orca` binary and selected via subcommands.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ orca (single binary, v0.2) │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ CLI Layer (Cobra) │
|
||||
│ ├── orca version │
|
||||
│ ├── orca init # local node bootstrap │
|
||||
│ ├── orca cert {init,join,renew,show} # NEW (P01) │
|
||||
│ ├── orca status │
|
||||
│ ├── orca node {join,leave,list} # join = mTLS handshake (P01) │
|
||||
│ │ └── orca node list --watch # NEW iter.Seq (P04) │
|
||||
│ ├── orca job {run,list,stop,logs} │
|
||||
│ │ └── orca job list --watch # NEW iter.Seq (P04) │
|
||||
│ ├── orca doctor # NEW (P01) — diagnostics │
|
||||
│ │ ├── orca doctor cert │
|
||||
│ │ ├── orca doctor network │
|
||||
│ │ └── orca doctor db │
|
||||
│ └── orca daemon │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Daemon Layer (net/http over h2c, mTLS in P01) │
|
||||
│ ├── /healthz (liveness) │
|
||||
│ ├── /readyz (readiness) │
|
||||
│ ├── /v1/jobs/* (job control API) │
|
||||
│ ├── /v1/nodes/* (node registry API) │
|
||||
│ ├── /v1/tasks/* (task lifecycle API) │
|
||||
│ ├── /orca.v1.Dispatch/... # NEW (P02) — cross-node dispatch │
|
||||
│ └── /orca.v1.Register/... # NEW (P02) — peer join ack │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Transport Layer (NEW — internal/transport) │
|
||||
│ ├── mTLS client (dialer pool per peer) │
|
||||
│ ├── mTLS server config (TLS 1.3 only, AEAD allowlist) │
|
||||
│ ├── Retry+backoff (exponential, jittered, capped) │
|
||||
│ └── Graceful disconnect (ctx-aware Conn.Close) │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Core Engine │
|
||||
│ ├── Node Registry (in-memory + SQLite persistence) │
|
||||
│ ├── Task Executor (os/exec with WaitDelay, Go 1.25+) │
|
||||
│ ├── Job Scheduler (single-node FIFO; bin-pack P02) │
|
||||
│ ├── Dispatcher # NEW internal/engine/dispatcher.go │
|
||||
│ │ ├── Local decision (does this job fit on this node?) │
|
||||
│ │ ├── Remote dispatch (POST to peer via transport) │
|
||||
│ │ └── Streaming callback (iter.Seq[DispatchResult] for CLI) │
|
||||
│ ├── Security Manager # NEW internal/security (P01) │
|
||||
│ │ ├── CA lifecycle (init, fingerprint, sign CSR) │
|
||||
│ │ ├── Server cert lifecycle (issue, renew, rotate) │
|
||||
│ │ ├── mTLS config builder │
|
||||
│ │ └── Cert store (filesystem + SQLite metadata) │
|
||||
│ └── Audit Logger (log/slog JSON handler) │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ State Store (modernc/sqlite, CGO-free) │
|
||||
│ ~/.orca/orca.db │
|
||||
│ ├── nodes, jobs, tasks, audit_log (v0.1) │
|
||||
│ └── certs # NEW (P01) — CA + server certs │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ orca (single binary) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ CLI Layer (Cobra) │
|
||||
│ ├── orca version │
|
||||
│ ├── orca init │
|
||||
│ ├── orca status │
|
||||
│ ├── orca node {join,leave,list} │
|
||||
│ └── orca job {run,list,stop,logs} │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Daemon Layer (net/http server) │
|
||||
│ ├── /healthz (liveness) │
|
||||
│ ├── /readyz (readiness) │
|
||||
│ ├── /v1/jobs/* (job control API) │
|
||||
│ ├── /v1/nodes/* (node registry API) │
|
||||
│ └── /v1/tasks/* (task lifecycle API) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Core Engine │
|
||||
│ ├── Node Registry (in-memory + SQLite persistence) │
|
||||
│ ├── Task Executor (os/exec with WaitDelay, Go 1.25+) │
|
||||
│ ├── Job Scheduler (single-node for v0.1) │
|
||||
│ └── Audit Logger (log/slog JSON handler) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ State Store (modernc/sqlite, CGO-free) │
|
||||
│ ~/.orca/orca.db │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Component Details
|
||||
|
||||
### 1. CLI Layer (`cmd/orca`, `internal/cli`)
|
||||
|
||||
- **Framework**: Cobra (industry standard, familiar to operators)
|
||||
- **v0.1 subcommands**: `version`, `init`, `status`, `node`, `job`, `daemon`
|
||||
- **v0.2 additions (P01)**: `orca cert {init,join,renew,show}`
|
||||
- **v0.2 additions (P04)**: `--watch` flag on `orca job list` and `orca node list`
|
||||
- **v0.2 additions (P01)**: `orca doctor` subcommand (see §5 below)
|
||||
- **Subcommands**: `version`, `init`, `status`, `node`, `job`
|
||||
- **Output**: Human-readable by default; `--json` flag for machine consumption
|
||||
- **Discovery**: All subcommands self-document via Cobra's auto-generated help
|
||||
- **Watch semantics (P04)**: `--watch` consumes `iter.Seq[Job|Node]`, exits on
|
||||
ctrl-c (via `signal.NotifyContext`), refreshes on internal change events.
|
||||
|
||||
### 2. Daemon Layer (`internal/daemon`)
|
||||
|
||||
- **Server**: `net/http` with `http.ServeMux` (no external router)
|
||||
- **TLS (P01)**: `crypto/tls` with `MinVersion=tls.VersionTLS13` and
|
||||
AEAD cipher allowlist
|
||||
(`TLS_AES_256_GCM_SHA384`, `TLS_CHACHA20_POLY1305_SHA256`,
|
||||
`TLS_AES_128_GCM_SHA256`)
|
||||
- **Ports**: Configurable (default `:8443` for API+mTLS, `:8080` for health)
|
||||
- **Server**: `net/http` with `http.ServeMux` (no external router for v0.1)
|
||||
- **TLS**: `crypto/tls` with self-signed certs (mTLS-ready)
|
||||
- **Ports**: Configurable (default `:8443` for API, `:8080` for health)
|
||||
- **Graceful Shutdown**: `signal.NotifyContext` with SIGINT/SIGTERM
|
||||
- **v0.2 endpoints (P02)**:
|
||||
- `POST /orca.v1.Dispatch/Submit` — receive cross-node job submission
|
||||
- `POST /orca.v1.Dispatch/Status` — query dispatched job status
|
||||
- `POST /orca.v1.Register/Hello` — peer join ack (used during `orca node join`)
|
||||
|
||||
### 3. Transport Layer (`internal/transport`, NEW in P01/P02)
|
||||
|
||||
- **Client**: `http.Client` with `http.Transport.TLSClientConfig` populated
|
||||
from `internal/security.NewClientTLSConfig`
|
||||
- **Server**: `http.Server.TLSConfig` populated from
|
||||
`internal/security.NewServerTLSConfig`
|
||||
- **Retry policy**: exponential backoff with jitter (start 100ms, x2, cap 5s,
|
||||
max 5 attempts); only idempotent verbs (`GET`, `HEAD`, `OPTIONS`) are
|
||||
retried automatically; `POST` retries require an explicit
|
||||
`X-Orca-Idempotency-Key` header
|
||||
- **Conn lifecycle**: `context.Context`-aware dials and reads; graceful
|
||||
`Close` on `ctx.Done()`
|
||||
- **Peer dial pool**: small `sync.Map` of `peerID → *http.Client` to reuse
|
||||
TLS handshakes (TCP keep-alive) within a session
|
||||
|
||||
### 4. Core Engine (`internal/engine`)
|
||||
|
||||
### 3. Core Engine (`internal/engine`)
|
||||
- **Node Registry**: In-memory map of node IDs → metadata, persisted to SQLite
|
||||
(CPU/memory capacity, available slots, last-seen)
|
||||
- **Task Executor**: `os/exec.CommandContext` with `WaitDelay` (Go 1.25+) for
|
||||
clean process termination
|
||||
- **Job Scheduler (v0.2 P02)**:
|
||||
- **Algorithm**: best-fit bin-packing by `available_cpu` and `available_memory`
|
||||
- **Within-node ordering**: FIFO queue
|
||||
- **Cross-node**: if local node is full, `Dispatcher.Submit(peer, job)` is
|
||||
invoked; peers are tried in round-robin order
|
||||
- **Fallback**: if all peers reject, return `ErrNoFit` and requeue
|
||||
- **Dispatcher (NEW, P02)**:
|
||||
- `Submit(peerID, spec) (jobID, error)` — blocking call with retry
|
||||
- `Watch(peerID) iter.Seq[DispatchEvent]` — pull-style event stream for the
|
||||
CLI's `--watch` flag
|
||||
- Stateless: every call uses the latest mTLS client config and peer address
|
||||
- **Security Manager (NEW, P01)**:
|
||||
- `InitCA(commonName) (*CA, error)` — generates a self-signed CA, writes
|
||||
`ca.crt` (0644) and `ca.key` (0600) to `~/.orca/`
|
||||
- `Fingerprint(certPath) (sha256hex, error)` — used by `orca cert join`
|
||||
- `SignServerCert(csr, validity) (*cert, error)` — signs a CSR with the CA
|
||||
- `IssueServerCert(nodeName, dnsNames, ips) (*cert, *key, error)` — generates
|
||||
a keypair + CSR + signs it, returns PEM bytes for `orca cert join --server`
|
||||
- `ServerTLSConfig() (*tls.Config, error)` — loads `server.crt`/`server.key`
|
||||
and the CA pool from disk
|
||||
- `ClientTLSConfig(caPath) (*tls.Config, error)` — returns a client config
|
||||
pinned to the supplied CA
|
||||
- **Rotation policy**: server certs valid 90d; CA cert valid 10y. On
|
||||
`orca cert renew`, `IssueServerCert` is called and the daemon
|
||||
gracefully reloads the in-process `tls.Config` via `GetCertificate`
|
||||
hot-swap (no restart required)
|
||||
- **Task Executor**: `os/exec.CommandContext` with `WaitDelay` (Go 1.25+) for clean process termination
|
||||
- **Job Scheduler**: Single-node FIFO queue (multi-node deferred to v0.2+)
|
||||
- **Audit Logger**: `slog.NewJSONHandler(os.Stderr, ...)` with structured fields
|
||||
|
||||
### 5. Doctor (`internal/doctor`, NEW in P01)
|
||||
|
||||
- **Purpose**: operator-facing diagnostics; runs read-only checks against
|
||||
the local state and reports PASS/WARN/FAIL.
|
||||
- **Subcommands**:
|
||||
- `orca doctor` — runs all checks
|
||||
- `orca doctor cert` — cert/CA health (file modes, expiry windows, SAN
|
||||
presence, fingerprint pinning match — see REQ-026, REQ-033,
|
||||
REQ-034, REQ-036)
|
||||
- `orca doctor network` — peer reachability over mTLS (per-peer handshake
|
||||
sanity, last-seen delta)
|
||||
- `orca doctor db` — SQLite integrity check (`PRAGMA integrity_check`)
|
||||
+ migration version
|
||||
- **Output**: human-readable by default; `--json` for machine consumption
|
||||
- **No state changes**: doctor is strictly read-only. It can be run
|
||||
while the daemon is down (where possible) or while it's up.
|
||||
- **Initial implementation in P01** (cert checks only); `network` and
|
||||
`db` checks land in subsequent phases as their state becomes
|
||||
available.
|
||||
|
||||
### 6. State Store (`internal/store`)
|
||||
|
||||
### 4. State Store (`internal/store`)
|
||||
- **Driver**: `modernc.org/sqlite` (pure Go, CGO-free)
|
||||
- **Location**: `~/.orca/orca.db` (user-mode) or `/var/lib/orca/orca.db` (system-mode)
|
||||
- **Schema (v0.1)**: `nodes`, `jobs`, `tasks`, `audit_log` tables
|
||||
- **Schema (v0.2 P01)**: NEW `certs` table
|
||||
```sql
|
||||
CREATE TABLE certs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL, -- 'ca' | 'server'
|
||||
node_id TEXT, -- NULL for CA
|
||||
serial_hex TEXT NOT NULL, -- x509.SerialNumber.Hex()
|
||||
subject_cn TEXT NOT NULL,
|
||||
issuer_cn TEXT NOT NULL,
|
||||
not_before INTEGER NOT NULL, -- unix seconds
|
||||
not_after INTEGER NOT NULL, -- unix seconds
|
||||
fingerprint TEXT NOT NULL, -- sha256 of DER, hex
|
||||
source_path TEXT NOT NULL, -- on-disk PEM path
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_certs_node_kind ON certs(node_id, kind);
|
||||
CREATE INDEX idx_certs_not_after ON certs(not_after);
|
||||
```
|
||||
- **Schema**: `nodes`, `jobs`, `tasks`, `audit_log` tables
|
||||
- **Migrations**: Embedded SQL files, applied on startup
|
||||
- **Migration 0004** is added in P01 with the schema above
|
||||
|
||||
## Data Model
|
||||
|
||||
### Node (v0.1, extended in v0.2 P02)
|
||||
### Node
|
||||
```go
|
||||
type Node struct {
|
||||
ID string
|
||||
@@ -198,22 +71,10 @@ type Node struct {
|
||||
JoinedAt time.Time
|
||||
LastSeen time.Time
|
||||
Metadata map[string]string
|
||||
// v0.2 P02 — capacity for bin-packing
|
||||
Capacity NodeCapacity
|
||||
}
|
||||
|
||||
type NodeCapacity struct {
|
||||
CPUMillicores int // total, e.g. 4000 = 4 cores
|
||||
MemoryBytes int64 // total RAM
|
||||
CPUUsed int // currently allocated
|
||||
MemoryUsed int64 // currently allocated
|
||||
}
|
||||
|
||||
func (c NodeCapacity) AvailableCPU() int { return c.CPUMillicores - c.CPUUsed }
|
||||
func (c NodeCapacity) AvailableMemory() int64 { return c.MemoryBytes - c.MemoryUsed }
|
||||
```
|
||||
|
||||
### Job (v0.1)
|
||||
### Job
|
||||
```go
|
||||
type Job struct {
|
||||
ID string
|
||||
@@ -226,7 +87,7 @@ type Job struct {
|
||||
}
|
||||
```
|
||||
|
||||
### Task (v0.1)
|
||||
### Task
|
||||
```go
|
||||
type Task struct {
|
||||
ID string
|
||||
@@ -243,211 +104,23 @@ type Task struct {
|
||||
}
|
||||
```
|
||||
|
||||
### Certificate (NEW, v0.2 P01)
|
||||
```go
|
||||
type Cert struct {
|
||||
Kind CertKind // CertCA | CertServer
|
||||
NodeID string // empty for CA
|
||||
SerialHex string
|
||||
SubjectCN string
|
||||
IssuerCN string
|
||||
NotBefore time.Time
|
||||
NotAfter time.Time
|
||||
Fingerprint string // sha256 of DER (hex)
|
||||
SourcePath string // PEM path on disk
|
||||
CreatedAt time.Time
|
||||
}
|
||||
```
|
||||
|
||||
## v0.2 Component Graph (ASCII)
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Operator Host │
|
||||
│ (orca CLI) │
|
||||
└────────┬────────┘
|
||||
│ 1. cert join --ca-fingerprint <sha>
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ NODE A (Bootstrap / CA holder) │
|
||||
│ │
|
||||
│ ┌──────────────┐ CSR ┌────────────────────┐ PEM sign ┌────────┐ │
|
||||
│ │ orca cert │──────────▶│ internal/security │─────────────▶│ CA │ │
|
||||
│ │ {init,join} │ │ .SignServerCert() │ │ key │ │
|
||||
│ └──────────────┘ └────────────────────┘ │ 0600 │ │
|
||||
│ ┌──└────────┘ │
|
||||
│ ┌─────────────────┐ │ │
|
||||
│ │ internal/daemon │ ◀──tls.Config── internal/security │ │
|
||||
│ │ (http.Server) │ .ServerTLSConfig() │ │
|
||||
│ │ │ │ │
|
||||
│ │ /v1/jobs/* │ │ │
|
||||
│ │ /v1/nodes/* │ │ │
|
||||
│ │ /orca.v1.* │ │ │
|
||||
│ └────────┬────────┘ │ │
|
||||
│ │ Submit(job) (bin-pack) │ │
|
||||
│ ▼ │ │
|
||||
│ ┌─────────────────┐ │ │
|
||||
│ │ internal/engine │ │ │
|
||||
│ │ .scheduler │──── if local fits → executor │ │
|
||||
│ │ .dispatcher │──── else → Submit(peer, job) ───────────┼──┐ │
|
||||
│ └─────────────────┘ │ │ │
|
||||
│ │ │ mTLS │
|
||||
└──────────────────────────────────────────────────────────────┼──┼───────────┘
|
||||
│ │
|
||||
ORCA NODE NETWORK │ │
|
||||
│ │
|
||||
┌──────────────────────────────────────────────────────────────┼──┼───────────┐
|
||||
│ NODE B (Peer) │ │ │
|
||||
│ │ │ │
|
||||
│ ┌─────────────────┐ ◀── TLS 1.3 handshake ─────────────────┘ │ │
|
||||
│ │ internal/daemon │ │ │
|
||||
│ │ (http.Server) │ POST /orca.v1.Dispatch/Submit │ │
|
||||
│ │ │──── 200 + jobID │ │
|
||||
│ └────────┬────────┘ │ │
|
||||
│ │ │ │
|
||||
│ ▼ │ │
|
||||
│ ┌─────────────────┐ │ │
|
||||
│ │ internal/engine │ │ │
|
||||
│ │ .scheduler (FIFO) │ │
|
||||
│ │ .executor │ │
|
||||
│ └─────────────────┘ │ │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## v0.2 Flows
|
||||
|
||||
### Flow 1: Cert Issuance (CA-init → CSR → sign → install) — P01
|
||||
|
||||
```
|
||||
Operator (Node A) Operator (Node B)
|
||||
───────────────── ─────────────────
|
||||
orca cert init
|
||||
↳ InitCA("orca-ca")
|
||||
↳ write ca.crt (0644), ca.key (0600)
|
||||
↳ record in certs table (kind='ca')
|
||||
orca cert join --ca-fingerprint <sha>
|
||||
↳ operator copies ca.crt → Node B
|
||||
↳ verifies fingerprint matches local
|
||||
--ca-fingerprint arg
|
||||
↳ IssueServerCert("node-b", SANs)
|
||||
↳ generate 2048-bit RSA key
|
||||
↳ build CSR with SANs
|
||||
↳ read ca.crt + ca.key
|
||||
↳ sign CSR (90d validity)
|
||||
↳ write server.crt (0644),
|
||||
server.key (0600)
|
||||
↳ record in certs table
|
||||
(kind='server', node_id='node-b')
|
||||
```
|
||||
|
||||
### Flow 2: mTLS Handshake at `node join` — P01
|
||||
|
||||
```
|
||||
Node B (joiner) Node A (CA holder)
|
||||
──────────────── ─────────────────
|
||||
orca node join --name node-b
|
||||
--ca-fingerprint <sha>
|
||||
--peer node-a:8443
|
||||
↳ load ca.crt → verify sha256 == --ca-fingerprint
|
||||
↳ load server.crt + server.key
|
||||
↳ tls.Config{MinVersion: TLS1.3, ...}
|
||||
↳ ClientHello (SNI=node-a)
|
||||
◀── ServerHello (TLS 1.3)
|
||||
◀── Certificate (Node A's cert)
|
||||
↳ verify Node A's cert chains to ca.crt ◀── CertificateRequest
|
||||
↳ send Certificate (Node B's cert) ◀── Finished
|
||||
↳ Finished
|
||||
↳ GET /healthz (over mTLS) — sanity check
|
||||
↳ POST /v1/nodes (over mTLS) — register
|
||||
↳ insert into nodes table
|
||||
↳ audit log
|
||||
↳ 200 OK
|
||||
↳ record node_a in peers table
|
||||
↳ audit log
|
||||
```
|
||||
|
||||
### Flow 3: Job Dispatch (CLI → dispatcher → peer) — P02
|
||||
|
||||
```
|
||||
User (Node A CLI) Node A (scheduler) Node B (peer)
|
||||
────────────────── ─────────────────── ─────────────
|
||||
orca job run spec.hcl
|
||||
↳ parse HCL
|
||||
↳ POST /v1/jobs (mTLS, local)
|
||||
↳ scheduler.Submit(spec)
|
||||
↳ bin-pack: spec.cpu + spec.mem
|
||||
↳ local node A has 2000mc + 4GiB free → fit!
|
||||
↳ executor.Run(spec)
|
||||
↳ 202 Accepted + jobID
|
||||
↳ returns jobID
|
||||
```
|
||||
|
||||
```
|
||||
User (Node A CLI) Node A (scheduler) Node B (peer)
|
||||
────────────────── ─────────────────── ─────────────
|
||||
↳ scheduler.Submit(spec)
|
||||
↳ bin-pack: spec.cpu + spec.mem
|
||||
↳ local node A has 0 free → NO FIT
|
||||
↳ dispatcher.Submit(peer="node-b", spec)
|
||||
↳ load transport.Client("node-b")
|
||||
↳ POST /orca.v1.Dispatch/Submit
|
||||
↳ mTLS handshake
|
||||
↳ authenticate cert
|
||||
↳ scheduler.Submit(spec)
|
||||
↳ executor.Run(spec)
|
||||
↳ 200 OK + jobID
|
||||
↳ 200 OK + jobID
|
||||
↳ return jobID to local caller
|
||||
↳ returns jobID
|
||||
```
|
||||
|
||||
### Flow 4: iter.Seq Streaming (`--watch`) — P04
|
||||
|
||||
```
|
||||
User orca job list --watch
|
||||
──── ─────────────────────
|
||||
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt)
|
||||
defer cancel()
|
||||
seq := store.Jobs().Watch(ctx)
|
||||
for job := range seq {
|
||||
print(job) // human or --json
|
||||
}
|
||||
// ctrl-c → ctx.Done() → seq stops yielding
|
||||
```
|
||||
|
||||
Internally `store.Jobs().Watch(ctx) iter.Seq[Job]` polls the
|
||||
`jobs` table on a 1s ticker (or subscribes to an in-process
|
||||
notifier channel) and yields the current snapshot of each job
|
||||
until `ctx.Done()`. The store repo implements `iter.Seq[Job]`
|
||||
as a function that takes a `yield func(Job) bool` callback.
|
||||
|
||||
## Security Architecture
|
||||
|
||||
### Authentication
|
||||
- **v0.1**: mTLS for all API endpoints (self-signed CA)
|
||||
- **v0.2 P01**: Internal CA with CSR join (see Flow 1 + 2)
|
||||
- **v0.2+**: Token-based auth deferred to v0.3+
|
||||
|
||||
### Cert Rotation
|
||||
- Server certs: 90-day validity, rotate at 60 days (30d before expiry)
|
||||
- CA cert: 10-year validity, manual rotation
|
||||
- Hot-swap: `tls.Config.GetCertificate` callback re-reads the
|
||||
`server.crt`/`server.key` files on each handshake so `orca cert renew`
|
||||
takes effect without a daemon restart.
|
||||
- **v0.2+**: Token-based auth as alternative
|
||||
|
||||
### Audit Logging
|
||||
- All state-changing operations emit structured log records
|
||||
- Fields: `timestamp`, `actor`, `action`, `resource`, `result`, `error`
|
||||
- Stored in SQLite `audit_log` table and stderr (JSON)
|
||||
- **v0.2 P01 additions**: `cert.issued`, `cert.renewed`, `cert.joined`,
|
||||
`node.handshake_ok`, `node.handshake_failed`
|
||||
|
||||
### Input Validation
|
||||
- All CLI inputs validated via Cobra's `Args`/`ValidArgs` functions
|
||||
- All API inputs validated at handler boundary
|
||||
- HCL/YAML specs parsed with strict schemas
|
||||
|
||||
## Key Architectural Decisions (v0.1 + v0.2)
|
||||
## Key Architectural Decisions
|
||||
|
||||
| ID | Decision | Rationale |
|
||||
|----|----------|-----------|
|
||||
@@ -459,14 +132,6 @@ as a function that takes a `yield func(Job) bool` callback.
|
||||
| AD-006 | slog for logging | Native to Go 1.21+, no external dependency |
|
||||
| AD-007 | HCL for job specs | Familiar to Nomad/HashiCorp users |
|
||||
| AD-008 | Single-node scheduling (v0.1) | Multi-node scheduling deferred to v0.2+ |
|
||||
| AD-009 | Internal CA, no external PKI (v0.2) | Self-contained, no operational PKI requirement |
|
||||
| AD-010 | Roll-our-own CA in `crypto/x509` (v0.2) | step-ca/cfssl/vault-pki too heavyweight for Orca's footprint |
|
||||
| AD-011 | Operator-mediated CA cert distribution (v0.2) | No secret distribution over the wire; matches offline-first |
|
||||
| AD-012 | TLS 1.3 only, AEAD allowlist (v0.2) | Modern crypto only; no downgrade risk |
|
||||
| AD-013 | Eager mTLS at `node join` (v0.2) | Fail fast; don't defer handshake to first request |
|
||||
| AD-014 | `orca.v1.Dispatch` via stdlib h2c (v0.2) | ConnectRPC not in go.mod; stdlib suffices for a single-RPC service |
|
||||
| AD-015 | Best-fit bin-packing (v0.2) | Simple, deterministic, optimal for small fleets |
|
||||
| AD-016 | iter.Seq for streaming lists (v0.2) | Go 1.25+ native, context-aware, pull semantics |
|
||||
|
||||
## Anti-Patterns (Explicitly Avoided)
|
||||
|
||||
@@ -479,11 +144,9 @@ as a function that takes a `yield func(Job) bool` callback.
|
||||
- No cloud provider integrations
|
||||
- No auto-scaling
|
||||
- No admission controllers
|
||||
- No complex scheduling algorithms (best-fit only)
|
||||
- No gRPC framework dependency (stdlib net/http with h2c, Go 1.25+ native)
|
||||
- No external PKI / no cert transparency logs (offline-first)
|
||||
- No complex scheduling algorithms
|
||||
|
||||
## Dependency Map (minimal — v0.2 adds zero direct deps)
|
||||
## Dependency Map (minimal)
|
||||
|
||||
```
|
||||
github.com/spf13/cobra # CLI framework
|
||||
@@ -492,37 +155,18 @@ modernc.org/sqlite # SQLite (pure Go)
|
||||
github.com/google/uuid # UUID generation
|
||||
```
|
||||
|
||||
Total: ~4 direct dependencies. No web framework, no ORM, no RPC framework,
|
||||
no PKI library. mTLS via `crypto/tls` and `crypto/x509` (stdlib).
|
||||
Total: ~4 direct dependencies. No web framework, no ORM, no RPC framework.
|
||||
|
||||
> **ConnectRPC note**: `.ciagent/config.json` lists `connectrpc` in
|
||||
> `frameworks`, but the actual `go.mod` does not depend on
|
||||
> `connectrpc.com/connect`. v0.2 falls back to plain `net/http` with
|
||||
> HTTP/2 cleartext (h2c) for `orca.v1.Dispatch`. The protocol is a
|
||||
> simple JSON-over-HTTP POST: client sends
|
||||
> `{"spec": "..."}` to `/orca.v1.Dispatch/Submit`; server replies
|
||||
> `{"job_id": "..."}`. This keeps the zero-new-dep promise and the
|
||||
> codebase coherent with the rest of the daemon's `http.ServeMux`.
|
||||
|
||||
## Deployment Model (v0.2)
|
||||
## Deployment Model
|
||||
|
||||
```
|
||||
Operator Machine Node A (CA holder) Node B (Peer)
|
||||
──────────────── ───────────────── ─────────────
|
||||
orca CLI orca daemon orca daemon
|
||||
│ │ ▲ │ ▲
|
||||
│ mTLS handshake │ │ mTLS │ │
|
||||
│ at `node join` ────────────┼──┘ │ │
|
||||
│ │ │ │
|
||||
│ submit job ───POST────────▶│ POST (cross-node) ──────────▶│
|
||||
│ │ mTLS only │ │
|
||||
│ │ │ │
|
||||
│ ◀───────jobID──────────────│ ◀─────jobID (200 OK)─────────│
|
||||
│ │ │
|
||||
│ orca job list --watch │ │
|
||||
│ (iter.Seq stream) ◀────────│── polls local + stream events│
|
||||
User Machine Server Node
|
||||
┌──────────┐ ┌──────────────────┐
|
||||
│ orca CLI │─────── mTLS ──────────▶│ orca daemon │
|
||||
│ │ │ ├── API server │
|
||||
│ │ │ ├── Engine │
|
||||
│ │ │ └── SQLite store │
|
||||
└──────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
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
|
||||
the operator; peers do not auto-fetch it.
|
||||
For v0.1, the CLI and daemon can be the same binary on the same machine. Multi-node is deferred.
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
---
|
||||
description: CIAgent audit report — v0.2 P01 mTLS ship + v0.1 backfill state
|
||||
date: 2026-06-03
|
||||
audit: ciagent-audit
|
||||
---
|
||||
|
||||
# Audit Report — v0.2 P01 mTLS Ship
|
||||
|
||||
## Reconstruction: PASS
|
||||
|
||||
The project state is fully reconstructable from `---ci---` blocks in git log.
|
||||
|
||||
### Reconstructed Timeline (newest first)
|
||||
|
||||
| SHA | Phase | Milestone | Status |
|
||||
|-----|-------|-----------|--------|
|
||||
| f31bed2 | 8 | v0.2 | **ship** (v0.2.1) |
|
||||
| 1b14a5b | 8 | v0.2 | verify |
|
||||
| 31ccb52 | 8 | v0.2 | execute (B/C/D wave) |
|
||||
| 181cc76 | 8 | v0.2 | execute (A wave) |
|
||||
| bed5a2e | 0 | v0.2 | plan |
|
||||
| 1ee82fc | 0 | v0.2 | ideate |
|
||||
| 08d321f | 0 | v0.2 | research |
|
||||
| b48f5cf | 0 | v0.2 | clarify |
|
||||
| 907f25e | 0 | v0.2 | specify |
|
||||
| e600e25 | 0 | v0.1 | complete |
|
||||
| 00127ce | 7 | v0.1 | ship (security untrack) |
|
||||
| b1b2e3d | 7 | v0.1 | execute |
|
||||
| 4fd17c5 | 7 | v0.1 | execute |
|
||||
| 995892a | 7 | v0.1 | ship (v0.1.7) |
|
||||
| dc67522 | 7 | v0.1 | verify |
|
||||
| 477b08c | 7 | v0.1 | execute |
|
||||
| de69788 | 7 | v0.1 | execute |
|
||||
| d10f89d | 0 | v0.1 | execute (workflow block — see finding #1) |
|
||||
| f1c55ca | 0 | v0.1 | fix |
|
||||
| 37b6a14 | 0 | v0.1 | fix (entry-point) |
|
||||
| 939ce8b | 6 | v0.1 | complete |
|
||||
| d76ff84 | 0 | v0.1 | complete (v0.1.6/v0.2.0) |
|
||||
|
||||
Reconstructed state matches the actual branch/HEAD state of `main`, `milestone/v0.1-initial`, and `milestone/v0.2-networking-observability-security`.
|
||||
|
||||
## .ciagent/ File Discipline
|
||||
|
||||
| File | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| `config.json` | ⚠️ Partial | Valid JSON, top-level keys present, but `workflow` subfield MISSING (see finding #1) |
|
||||
| `PROJECT.md` | ⚠️ Partial | Required sections present (Requirements, Constraints); `What This Is` and `Key Decisions` are referenced in the v0.1 audit-fix but the literal section headers are absent (see finding #2) |
|
||||
| `ROADMAP.md` | ✅ Pass | v0.1 marked COMPLETE; v0.2 marked IN PROGRESS with 4 phases listed |
|
||||
| `REQUIREMENTS.md` | ⚠️ Issue | Two overlapping REQ tables (see finding #3) |
|
||||
| `ARCHITECTURE.md` | ✅ Pass | v0.2 sections (transport, doctor, certificate data model, 4 v0.2 flows) match the code structure under `internal/transport`, `internal/doctor`, `internal/security` |
|
||||
| `PLANS.md` | ✅ Pass | 4 v0.2 phase plans present (P08–P11) with REQ coverage and must-haves |
|
||||
| `PERSONAS.md` | ✅ Pass | v0.2 personas documented (network-engineer, phase_specific assignments) |
|
||||
| `IDEATION.md` | ✅ Pass | 30 v0.1 + 35 v0.2 ideas, 64 accepted |
|
||||
| `RELEASE_POLICY.md` | ✅ Pass | 4 standing rules documented |
|
||||
| `PHASE{5,6}_VERIFICATION.md` | ✅ Pass | Verifier artifacts present |
|
||||
| `PHASE7_SECURITY_AUDIT.md` | ✅ Pass | P0 secret leak documented for human remediation |
|
||||
|
||||
## Branches
|
||||
|
||||
| Branch | Status | Notes |
|
||||
|--------|--------|-------|
|
||||
| `main` | At `bed5a2e` (PLAN commit, v0.2 P00) | Not yet merged with v0.2 milestone |
|
||||
| `milestone/v0.1-initial` | At `995892a` (P07 ship) | Frozen; v0.1 complete |
|
||||
| `milestone/v0.2-networking-observability-security` | At `f31bed2` (P01 ship) | Active; P01 shipped |
|
||||
| `phase/01..07` (v0.1) | Local only; mostly not pushed | P07 (v0.1) is on origin; P01-P06 either on origin (P01-P04) or local-only (P05, P06) |
|
||||
| `phase/08-mtls` | At `1b14a5b` (verify) | P01 verified; pre-ship SHA |
|
||||
| `phase/09-scheduling` | At `f31bed2` | P02 branch created, no work yet |
|
||||
|
||||
Active work: `phase/09-scheduling` (P02). All other phase branches are either merged or frozen.
|
||||
|
||||
## Commits
|
||||
|
||||
- **54 total commits** across all branches
|
||||
- **48 commits with `---ci---` block** (89%)
|
||||
- **6 commits without `---ci---` block**: 5 historical v0.1 ship commits (P02–P04, predating the convention) + 1 external PR-#1 merge commit (`be9afa2`)
|
||||
- No unresolved escalations; no stale decisions older than the v0.1 milestone
|
||||
|
||||
## P0 Findings (require remediation before v0.2 milestone→main ship)
|
||||
|
||||
### Finding #1: `config.json` `workflow` block missing
|
||||
|
||||
The `workflow` block (added in `d10f89d` for v0.1) was lost from `main` during the parallel-history resolution. The v0.1 milestone branch has it; `main` does not. This is a real divergence that needs to be re-applied to `main` before merging the v0.2 milestone.
|
||||
|
||||
**Remediation**: Re-apply the `workflow` block to `config.json` on `main`. This is a one-commit fix (forward-merge the `d10f89d` change to the file alone).
|
||||
|
||||
### Finding #2: PROJECT.md section header drift
|
||||
|
||||
The audit-fix in v0.1 (`f1c55ca`) added content to `PROJECT.md` describing "What This Is" and "Key Decisions" but used inline prose rather than literal `## What This Is` and `## Key Decisions` section headers. The content is there; the structural markers are not. The audit check fails to find them.
|
||||
|
||||
**Remediation**: Add literal `## What This Is` and `## Key Decisions` headers (or update the audit to match the inline style). Low priority.
|
||||
|
||||
### Finding #3: REQUIREMENTS.md has two overlapping tables
|
||||
|
||||
The v0.1 audit-fix (f1c55ca) added a richer traceability table (with REQ-ID, summary, priority, status, phase, ideation-source) below the v0.1 status table. The v0.2 ideation agent's update flipped REQ-011/014/022/023 from "Deferred (v0.2)" to "Pending (v0.2 PXX)" in the v0.1 table but did NOT touch the new traceability table — so the same REQs appear in BOTH tables with different status wording.
|
||||
|
||||
**Remediation**: Consolidate to a single table. Either delete the v0.1 status table (preserving only the v0.2 traceability table), or update the v0.1 table to defer to the v0.2 table. Recommend the former: the v0.2 table is more informative.
|
||||
|
||||
## Non-Blocking Observations
|
||||
|
||||
- **scripts/release.sh bug**: The `tea releases create` call is missing `--repo coreci/orca`. Worked around in P01 by invoking `tea` directly. Worth a P0 fix in P03 (security-scan phase is a natural cleanup point).
|
||||
- **5 historical ship commits lack `---ci---` blocks**: Predate the convention. The reconstructed state from git log is sufficient — these don't break reconstruction.
|
||||
- **PR-#1 merge commit (`be9afa2`)**: External commit (not CI-generated); doesn't need a `---ci---` block.
|
||||
- **P07 has a duplicate ship commit** (`56b4274` on phase branch, `e96427b` on milestone). Cosmetic; the content is the same.
|
||||
|
||||
## Overall
|
||||
|
||||
- Reconstruction: **PASS** (89% of commits have `---ci---` blocks; the rest are historical and don't break reconstruction)
|
||||
- .ciagent/ files: **3 issues, 1 P0, 2 cosmetic**
|
||||
- Branches: **clean** (all active branches have recent work; no orphans)
|
||||
- Commits: **clean** (no stale decisions, no escalations)
|
||||
|
||||
**Verdict**: The v0.2 P01 ship is healthy. The 3 issues are paper-cleanup items that should be addressed in a follow-up commit before the v0.2 milestone→main merge. None of them block P02 EXECUTE.
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"phase": 3,
|
||||
"stage": "complete",
|
||||
"milestone": "v0.3",
|
||||
"milestone_slug": "scheduling-streaming",
|
||||
"phase_role": "final",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-08-01T00:30:00Z",
|
||||
"milestone_complete": true
|
||||
}
|
||||
@@ -1,644 +0,0 @@
|
||||
# Grill Report: Orca v0.3 — scheduling-streaming
|
||||
|
||||
**Date:** 2026-08-01
|
||||
**Reviewer:** ci-griller (red-team, adversarial)
|
||||
**Plan under review:** `.ciagent/PLAN_v0.3.md` (commit 89fa172)
|
||||
**Branch:** `phase/00-pre-execution`
|
||||
**Mode:** Full autonomy
|
||||
|
||||
---
|
||||
|
||||
## Methodology
|
||||
|
||||
Every claim in `PLAN_v0.3.md` and `RESEARCH_v0.3.md` was cross-checked against
|
||||
the actual codebase (the 7 source files listed in the task, plus `migrate.go`,
|
||||
`store.go`, `doctor_test.go`, `security/integration_test.go`, `security/ca.go`,
|
||||
`security/csr.go`, `model/node.go`, `model/job.go`, and `cli/doctor.go`).
|
||||
Findings are scored on 9 axes. Binding verdicts are ACCEPT (plan must change),
|
||||
REJECT (concern noted, plan stands), or DEFER (address during execution).
|
||||
|
||||
---
|
||||
|
||||
## Summary Verdict
|
||||
|
||||
| Severity | Count |
|
||||
|----------|-------|
|
||||
| CRITICAL | 2 |
|
||||
| HIGH | 2 |
|
||||
| MEDIUM | 5 |
|
||||
| LOW | 3 |
|
||||
| **Total** | **12** |
|
||||
|
||||
**Overall verdict: PROCEED WITH CHANGES**
|
||||
|
||||
The plan is fundamentally sound — the scope is right-sized, the requirements
|
||||
coverage is complete, the persona territories are respected, and the
|
||||
no-new-dependencies promise holds. However, two CRITICAL findings require plan
|
||||
changes before execution begins. Neither is a scope expansion; both are
|
||||
correctness fixes to the design as written. With the 2 ACCEPT changes applied,
|
||||
this plan is ready to execute.
|
||||
|
||||
---
|
||||
|
||||
## Per-Axis Findings
|
||||
|
||||
### Axis 1 — Feasibility (can each task actually be implemented?)
|
||||
|
||||
#### F-01 [CRITICAL] — Watch yields per-row but CLI table mode requires full-snapshot-per-tick
|
||||
|
||||
**Severity:** CRITICAL
|
||||
**Axis:** Feasibility / Vertical slice integrity
|
||||
**Binding verdict:** ACCEPT (plan must change)
|
||||
|
||||
**Finding:**
|
||||
The plan is internally contradictory about what `Watch` yields.
|
||||
|
||||
- D-028 (RESEARCH:88) says Watch "yields the **full current snapshot** (one
|
||||
element per row)."
|
||||
- Task 01-01-01 (PLAN:30) says Watch "yields one `*model.Job` per row via
|
||||
`scanJob`" — i.e., `iter.Seq[*model.Job]`, one element per row per tick.
|
||||
- Task 01-02-02 (PLAN:42) says the CLI table render "collect the full snapshot
|
||||
from `seq` into a `[]*model.Job`" then compares against the previous
|
||||
snapshot's rendered table.
|
||||
|
||||
These are incompatible. `iter.Seq[*model.Job]` yields individual jobs with **no
|
||||
tick-boundary signal**. The CLI ranging `for job := range seq` receives a flat
|
||||
stream of jobs and cannot know when a tick's snapshot is complete. It cannot
|
||||
collect "the full snapshot" because it cannot detect the end of a tick.
|
||||
|
||||
The JSON mode (01-02-03) can work without tick boundaries (per-element dedup
|
||||
via `map[string][]byte`), but the **table mode cannot**. Table mode needs the
|
||||
complete snapshot to render the table, clear the screen, and compare against the
|
||||
previous frame.
|
||||
|
||||
**Evidence:**
|
||||
- `RESEARCH_v0.3.md:106-142` — implementation yields `yield(j)` per row inside
|
||||
`for rows.Next()`, not `yield(allJobs)` per tick.
|
||||
- `PLAN_v0.3.md:30` — "yields one `*model.Job` per row"
|
||||
- `PLAN_v0.3.md:42` — "collect the full snapshot from `seq` into a `[]*model.Job`"
|
||||
- `PLAN_v0.3.md:44` (01-02-04) — nodeListCmd watch bypasses registry, same
|
||||
per-row yield.
|
||||
- D-028 says "full current snapshot" but the code yields per-row.
|
||||
|
||||
**Required change:**
|
||||
Change the `Watch` element type from `iter.Seq[*model.Job]` to
|
||||
`iter.Seq[[]*model.Job]` (and `iter.Seq[[]*model.Node]` analogously). Each tick
|
||||
yields the **full snapshot as a single slice**. This:
|
||||
|
||||
1. Makes D-028 ("yields the full current snapshot") literally true.
|
||||
2. Makes table mode trivial: `for snapshot := range seq { render(snapshot) }`.
|
||||
3. Makes JSON mode cleaner: per-tick, diff the snapshot against the previous
|
||||
one, emit one JSON line per changed element. This also enables a natural
|
||||
`"delete"` event for elements that disappeared (not possible with per-row
|
||||
yield).
|
||||
4. Simplifies the test contract: `TestWatch_YieldsSnapshots` ranges over
|
||||
`iter.Seq[[]*model.Job]` and each yield is a complete tick — no timing
|
||||
ambiguity about "did I get all rows for this tick?"
|
||||
|
||||
**Impact on plan:**
|
||||
- Tasks 01-01-01, 01-01-02: signature changes to
|
||||
`iter.Seq[[]*model.Job]` / `iter.Seq[[]*model.Node]`. Implementation
|
||||
collects all rows into a slice per tick, then `yield(slice)`.
|
||||
- Task 01-02-02 (table): `for snapshot := range seq { ... }` — direct, no
|
||||
collection needed.
|
||||
- Task 01-02-03 (JSON): per-tick diff against previous snapshot's
|
||||
`map[string][]byte`. Emit `"init"`/`"update"`/`"delete"` events.
|
||||
- Task 01-01-04 (tests): assert each yield is a complete snapshot slice.
|
||||
- D-026, D-028, D-046: update to reflect slice-per-tick semantics.
|
||||
- Must-have criteria for 01-01-01/01-01-02: update signature assertions.
|
||||
|
||||
This is a mechanical change to the plan, not a scope change. The implementation
|
||||
is simpler (no tick-boundary detection needed).
|
||||
|
||||
**Confidence:** 0.92
|
||||
|
||||
---
|
||||
|
||||
#### F-02 [CRITICAL] — First-tick delay: Watch waits a full interval before first yield
|
||||
|
||||
**Severity:** CRITICAL
|
||||
**Axis:** Feasibility / UX correctness
|
||||
**Binding verdict:** ACCEPT (plan must change)
|
||||
|
||||
**Finding:**
|
||||
The Watch implementation (RESEARCH:110-116) has this structure:
|
||||
|
||||
```go
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done(): return
|
||||
case <-ticker.C: // <-- waits 1s BEFORE first query
|
||||
}
|
||||
// query + yield
|
||||
}
|
||||
```
|
||||
|
||||
The `select` waits for the first ticker pulse **before** running the first
|
||||
query. With a 1s default interval, `orca job list --watch` shows **nothing for
|
||||
1 full second**, then the first snapshot appears. For a CLI tool, a 1s blank
|
||||
screen is a poor UX and looks broken. The user expects immediate output, then
|
||||
refreshes every 1s.
|
||||
|
||||
The tests (01-01-04) use `watchInterval=10ms`, so the delay is only 10ms and
|
||||
the test passes — but the test does NOT catch this UX bug because the interval
|
||||
is tiny. In production (1s), the bug is visible.
|
||||
|
||||
**Evidence:**
|
||||
- `RESEARCH_v0.3.md:110-116` — `select` before first query.
|
||||
- `PLAN_v0.3.md:30` — "pull-based inline polling loop on a 1s ticker" — no
|
||||
mention of immediate first yield.
|
||||
- Standard `top`-like tools yield immediately, then tick.
|
||||
|
||||
**Required change:**
|
||||
Add to tasks 01-01-01 and 01-01-02: the polling loop must **query and yield
|
||||
immediately on the first iteration**, then `select` on the ticker for
|
||||
subsequent ticks. Implementation shape:
|
||||
|
||||
```go
|
||||
for {
|
||||
// query + yield (runs immediately on first iteration)
|
||||
rows, err := r.db.QueryContext(ctx, ...)
|
||||
// ... yield snapshot ...
|
||||
select {
|
||||
case <-ctx.Done(): return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or equivalently, query once before the loop, then loop with select-first. The
|
||||
must-have criteria should add: "first yield occurs immediately (no
|
||||
`watchInterval` delay before first snapshot)."
|
||||
|
||||
**Impact on plan:**
|
||||
- Tasks 01-01-01, 01-01-02: add "immediate first yield" to description +
|
||||
must-have.
|
||||
- Task 01-01-04 (tests): add assertion that the first snapshot appears within
|
||||
a short deadline (e.g., <50ms) even with `watchInterval=10ms` — proving the
|
||||
first yield is not tick-gated.
|
||||
|
||||
**Confidence:** 0.95
|
||||
|
||||
---
|
||||
|
||||
#### F-03 [HIGH] — P01 and P02 both modify `internal/cli/node.go` (file-disjoint claim is false)
|
||||
|
||||
**Severity:** HIGH
|
||||
**Axis:** Feasibility / Timeline (parallelism)
|
||||
**Binding verdict:** ACCEPT (plan must change)
|
||||
|
||||
**Finding:**
|
||||
D-042 (PLAN:133, RESEARCH:489) claims "P01 and P02 are file-disjoint — no file
|
||||
is modified by both." This is **false**.
|
||||
|
||||
- P01 task 01-02-04 (PLAN:44) modifies `internal/cli/node.go` — adds `--watch`
|
||||
flag + render modes to `nodeListCmd`.
|
||||
- P02 task 02-01-01 (PLAN:76) modifies `internal/cli/node.go` — removes the
|
||||
`dbPath` function and updates `openDB` to call `certpaths.DBPath()`.
|
||||
|
||||
Both phases touch `internal/cli/node.go`. If developed in parallel (as D-042
|
||||
permits), this causes merge conflicts.
|
||||
|
||||
**Evidence:**
|
||||
- `PLAN_v0.3.md:44` — 01-02-04 files: `internal/cli/node.go`
|
||||
- `PLAN_v0.3.md:76` — 02-01-01 files: `internal/cli/node.go` (remove old
|
||||
`dbPath`)
|
||||
- `PLAN_v0.3.md:133` — "P01 and P02 are file-disjoint"
|
||||
- Actual code: `internal/cli/node.go:22-28` defines `dbPath`; `:30-36`
|
||||
defines `openDB` which calls `dbPath()`. `openDB` is used by 14 call sites
|
||||
across `job.go`, `daemon.go`, `node_capacity.go`, `audit.go`, `node.go`.
|
||||
|
||||
**Required change:**
|
||||
Update D-042 and the cross-phase notes (PLAN:131-133) to acknowledge the
|
||||
overlap. Two options (pick one):
|
||||
|
||||
1. **Serialize:** P02 Wave 1 (02-01-01) runs before P01 Wave 2 (01-02-04).
|
||||
P02 Wave 1 is a prerequisite for P01 Wave 2 on the `node.go` file. P01
|
||||
Wave 1 (store layer) and P02 Wave 1 can still run in parallel.
|
||||
2. **Merge the changes:** task 02-01-01 is folded into P01 Wave 2's
|
||||
`node.go` modification (the cli-engineer updates `openDB` to use
|
||||
`certpaths.DBPath()` while also adding `--watch`).
|
||||
|
||||
Recommended: Option 1 (serialize P02 Wave 1 before P01 Wave 2). It preserves
|
||||
the wave structure and persona assignments. Update the cross-phase note to say:
|
||||
"P02 Wave 1 (02-01-01) must complete before P01 Wave 2 (01-02-04) due to shared
|
||||
`internal/cli/node.go` modification. P01 Wave 1 and P02 Wave 1 may run in
|
||||
parallel."
|
||||
|
||||
**Confidence:** 0.90
|
||||
|
||||
---
|
||||
|
||||
#### F-04 [HIGH] — D-037 ServerName = node.Name assumption is fragile and unverified against real join flow
|
||||
|
||||
**Severity:** HIGH
|
||||
**Axis:** Feasibility / Security
|
||||
**Binding verdict:** DEFER (address in execution, with documentation)
|
||||
|
||||
**Finding:**
|
||||
D-037 (RESEARCH:261, confidence 0.80) assumes `serverName = node.Name` for the
|
||||
mTLS health probe. The TLS client's `ServerName` must match a SAN entry on the
|
||||
peer's server cert. But `GenerateCSR(commonName, sans)` (csr.go:24) takes the
|
||||
commonName and SANs as **separate arguments**. The commonName becomes the cert
|
||||
Subject CN, but `ServerName` in `tls.Config` is matched against **SANs**
|
||||
(DNSNames/IPAddresses), not the CN (per Go's `crypto/tls` behavior since Go
|
||||
1.15).
|
||||
|
||||
If a node joined with `--name node-b` but its cert SAN is `localhost` (or an
|
||||
IP), `serverName = "node-b"` will **fail the TLS handshake** with a
|
||||
"certificate is valid for localhost, not node-b" error — even though the peer
|
||||
is perfectly healthy.
|
||||
|
||||
The research (RESEARCH:261) says "confirmed in `integration_test.go:41`
|
||||
`GenerateCSR("test-server", ...)`" — but that test uses `serverName =
|
||||
"localhost"` (integration_test.go:83), which matches the SAN `localhost`, not
|
||||
the commonName `test-server`. The test proves SAN-matching, not CN-matching.
|
||||
|
||||
**Evidence:**
|
||||
- `internal/security/csr.go:24` — `GenerateCSR(commonName, sans)` — CN and
|
||||
SANs are separate.
|
||||
- `internal/security/integration_test.go:41` — `GenerateCSR("test-server",
|
||||
[]string{"localhost", "127.0.0.1"})` — CN is "test-server", SANs are
|
||||
localhost/127.0.0.1.
|
||||
- `internal/security/integration_test.go:83` — `ClientTLSConfig(...,
|
||||
"localhost", ...)` — serverName = "localhost" (a SAN), NOT "test-server"
|
||||
(the CN).
|
||||
- `internal/transport/mtls.go:49-51` — `serverName` is required and set as
|
||||
`tls.Config.ServerName` (matched against SANs).
|
||||
- `PLAN_v0.3.md:88` — 02-02-03: `serverName = n.Name`.
|
||||
|
||||
**Mitigation (DEFER to execution):**
|
||||
1. Document the assumption in the `Network()` check message: "probing
|
||||
<name> at <addr> (assuming cert SAN = node name)".
|
||||
2. If the handshake fails with a SAN mismatch error, the FAIL message should
|
||||
include the cert's actual SANs (parsed from the error) so the operator can
|
||||
diagnose. This is a refinement, not a plan blocker.
|
||||
3. The test 02-02-05(e) uses `Name = "localhost"` which matches the SAN — so
|
||||
the test passes, but it doesn't prove the general case. Add a test comment
|
||||
noting this assumption.
|
||||
|
||||
**Why DEFER not ACCEPT:** The assumption is documented (D-037, 0.80
|
||||
confidence), the failure mode is graceful (FAIL with handshake error, not a
|
||||
crash), and fixing it properly (storing SANs in the nodes table) is a scope
|
||||
expansion beyond v0.3. The plan should note the limitation; execution should
|
||||
add diagnostic context to the error message.
|
||||
|
||||
**Confidence:** 0.78
|
||||
|
||||
---
|
||||
|
||||
#### F-05 [MEDIUM] — `store.Open` runs migrations before integrity_check can run
|
||||
|
||||
**Severity:** MEDIUM
|
||||
**Axis:** Feasibility / Testing
|
||||
**Binding verdict:** REJECT (concern noted, plan stands)
|
||||
|
||||
**Finding:**
|
||||
The DB check (02-02-01) calls `store.Open(path)` which runs `migrate(db)` (store
|
||||
.go:39) before the integrity_check executes. On a truly corrupt DB, `store.Open`
|
||||
fails at `Ping()` or `migrate()` — the integrity_check never runs. The check
|
||||
returns FAIL with the open/migrate error, which is the correct outcome (a DB
|
||||
that can't be opened is broken), but the message says "open <path>: <error>"
|
||||
not "integrity_check failed."
|
||||
|
||||
The plan's `TestDBCheck_Corrupt` (RESEARCH:469) is explicitly called "brittle"
|
||||
and made optional. The plan accepts that integrity_check is somewhat redundant
|
||||
with `store.Open`'s own validation.
|
||||
|
||||
**Evidence:**
|
||||
- `internal/store/store.go:37-41` — `db.Ping()` then `migrate(db)` inside
|
||||
`Open`.
|
||||
- `PLAN_v0.3.md:86` — 02-02-01: `db, err := store.Open(path)`.
|
||||
- `RESEARCH_v0.3.md:455-456` — pitfall table acknowledges this.
|
||||
|
||||
**Why REJECT:** The failure surfaces correctly (FAIL with error message). The
|
||||
integrity_check adds value for the case where the DB opens but has logical
|
||||
corruption (e.g., foreign key violations, orphaned pages) that Ping/migrate
|
||||
don't catch. The plan's approach is acceptable for v0.3. The optional corrupt
|
||||
test is correctly deferred.
|
||||
|
||||
**Confidence:** 0.85
|
||||
|
||||
---
|
||||
|
||||
### Axis 2 — Scope
|
||||
|
||||
#### F-06 [MEDIUM] — No "delete" event in JSON watch mode (with per-row yield)
|
||||
|
||||
**Severity:** MEDIUM
|
||||
**Axis:** Scope / Completeness
|
||||
**Binding verdict:** DEFER (address in execution)
|
||||
|
||||
**Finding:**
|
||||
With the current per-row `iter.Seq[*model.Job]` design (F-01), the JSON watch
|
||||
mode (01-02-03) emits `"init"` and `"update"` events but has no way to emit
|
||||
`"delete"` events — a job that disappears from the snapshot simply stops being
|
||||
yielded, and the CLI has no tick boundary to detect "this ID was in the
|
||||
previous tick but not this one."
|
||||
|
||||
With the F-01 fix (`iter.Seq[[]*model.Job]`, full snapshot per tick), `"delete"`
|
||||
events become trivially possible: diff the previous snapshot's ID set against
|
||||
the current snapshot's ID set. The plan should add `"delete"` event semantics
|
||||
to D-046.
|
||||
|
||||
**Evidence:**
|
||||
- `PLAN_v0.3.md:43` — 01-02-03: only `"init"` and `"update"` events.
|
||||
- `PLAN_v0.3.md:139` — D-046: only `"init"` and `"update"`.
|
||||
- Neither jobs nor nodes are hard-deleted in the current CLI (`node leave` sets
|
||||
state to `left`, doesn't delete the row), so `"delete"` events are not
|
||||
strictly needed for v0.3. But the `NodeRepo.Delete` method exists and could
|
||||
be used by future code.
|
||||
|
||||
**Mitigation (DEFER):** If F-01 is accepted (slice-per-tick), add `"delete"`
|
||||
event to D-046 as a natural extension. If F-01 is not accepted, document the
|
||||
no-delete-event limitation explicitly.
|
||||
|
||||
**Confidence:** 0.70
|
||||
|
||||
---
|
||||
|
||||
### Axis 3 — Testing
|
||||
|
||||
#### F-07 [MEDIUM] — Test timing fragility: 10ms tick + 30ms insert + 80ms cancel
|
||||
|
||||
**Severity:** MEDIUM
|
||||
**Axis:** Testing
|
||||
**Binding verdict:** DEFER (address in execution)
|
||||
|
||||
**Finding:**
|
||||
The store-layer tests (01-01-04) use `watchInterval=10ms` with timing-based
|
||||
assertions: "insert a 2nd job from a goroutine after ~30ms, cancel ctx after
|
||||
~80ms." Under CI load (especially with `-race` overhead), 10ms ticks can be
|
||||
missed or delayed. A 10ms ticker pulse is not guaranteed to fire within 10ms
|
||||
under load — the Go runtime scheduler may delay it. If the 2nd job is inserted
|
||||
at 30ms but the 2nd tick fires at 45ms, the test might see the 2nd job in the
|
||||
3rd tick (at ~55ms) which is still before the 80ms cancel — so it likely
|
||||
passes, but it's fragile.
|
||||
|
||||
**Evidence:**
|
||||
- `PLAN_v0.3.md:33` — 01-01-04: "after ~30ms", "after ~80ms".
|
||||
- `time.NewTicker` does not guarantee exact timing under load.
|
||||
|
||||
**Mitigation (DEFER):** Use more generous margins (e.g., 50ms insert, 200ms
|
||||
cancel) or a synchronization mechanism (e.g., insert the 2nd job, then poll
|
||||
the collected slice with a 500ms timeout). The test hook (`watchInterval`)
|
||||
already enables fast tests; the margins just need to be wider. Execution
|
||||
should validate the tests pass reliably under `-race` in CI before marking
|
||||
Wave 1 complete.
|
||||
|
||||
**Confidence:** 0.75
|
||||
|
||||
---
|
||||
|
||||
#### F-08 [LOW] — `-race` does not detect goroutine leaks; the plan claims it does
|
||||
|
||||
**Severity:** LOW
|
||||
**Axis:** Testing
|
||||
**Binding verdict:** REJECT (concern noted, plan stands)
|
||||
|
||||
**Finding:**
|
||||
The plan (01-01-04 must-have, PLAN:33) says "`-race` reports no leaks/data
|
||||
races." `go test -race` detects **data races**, not **goroutine leaks**.
|
||||
Goroutine leak detection requires `goleak` or explicit goroutine-count
|
||||
assertions. The claim is technically incorrect.
|
||||
|
||||
However, the actual risk is negligible: Watch does not spawn a goroutine
|
||||
(D-032, inline pull loop). `time.NewTicker` spawns an internal goroutine, but
|
||||
`defer ticker.Stop()` terminates it. There is nothing to leak. The
|
||||
`no-goroutine-leak` constraint (data-engineer persona) is satisfied by
|
||||
design, not by testing.
|
||||
|
||||
**Evidence:**
|
||||
- `PLAN_v0.3.md:33` — "`-race` reports no leaks/data races"
|
||||
- `RESEARCH_v0.3.md:92` — D-032: "No goroutine is spawned by Watch."
|
||||
- Go `-race` detector documentation: detects concurrent access, not leaks.
|
||||
|
||||
**Why REJECT:** The claim is imprecise but the risk is zero by design.
|
||||
Execution may optionally add `runtime.NumGoroutine()` before/after assertions
|
||||
for belt-and-suspenders, but it's not required.
|
||||
|
||||
**Confidence:** 0.90
|
||||
|
||||
---
|
||||
|
||||
### Axis 4 — Security
|
||||
|
||||
#### F-09 [MEDIUM] — Doctor network check probes peers using the local server cert as client cert (confirmed valid, but undocumented)
|
||||
|
||||
**Severity:** MEDIUM
|
||||
**Axis:** Security
|
||||
**Binding verdict:** DEFER (document in execution)
|
||||
|
||||
**Finding:**
|
||||
The plan (02-02-03, PLAN:88) uses `certpaths.ServerCertPath()`/`ServerKeyPath()`
|
||||
as the client cert for the mTLS health probe. I verified this is **valid**:
|
||||
`security/ca.go:255` signs server certs with
|
||||
`ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}`
|
||||
— the server cert has both ServerAuth and ClientAuth EKUs, so it can be
|
||||
presented as a client cert. The daemon's `RequireAndVerifyClientCert`
|
||||
(security/tls_config.go:92) will accept it.
|
||||
|
||||
This is correct and feasible. The finding is that this cross-use (server cert
|
||||
as client cert) is not documented in the plan or the security architecture. A
|
||||
security auditor might flag it as "server cert used for client auth — is this
|
||||
intended?"
|
||||
|
||||
**Evidence:**
|
||||
- `internal/security/ca.go:255` — `ExtKeyUsage: ServerAuth, ClientAuth`.
|
||||
- `internal/security/tls_config.go:92` — `ClientAuth: RequireAndVerifyClientCert`.
|
||||
- `PLAN_v0.3.md:88` — 02-02-03 uses `ServerCertPath()`/`ServerKeyPath()`.
|
||||
- `RESEARCH_v0.3.md:261` — D-037: "presents the local node's client cert."
|
||||
|
||||
**Mitigation (DEFER):** Add a code comment in `probeHealthz` and a note in
|
||||
ARCHITECTURE.md §5 explaining that the local server cert doubles as the client
|
||||
cert for doctor probes (justified by the dual EKU). This is documentation, not
|
||||
a code change.
|
||||
|
||||
**Confidence:** 0.88
|
||||
|
||||
---
|
||||
|
||||
### Axis 5 — Performance
|
||||
|
||||
#### F-10 [LOW] — 1s poll ticker re-runs full List query every second; no concern but worth noting
|
||||
|
||||
**Severity:** LOW
|
||||
**Axis:** Performance
|
||||
**Binding verdict:** REJECT (concern noted, plan stands)
|
||||
|
||||
**Finding:**
|
||||
The 1s ticker (D-019) re-runs `SELECT ... FROM jobs ORDER BY created_at DESC`
|
||||
every second. For a CLI tool run by a human watching a terminal, this is
|
||||
fine — the query is cheap (single table, no joins, indexed by `created_at` if
|
||||
an index exists). For an AI agent tailing `--watch --json` for hours, this is
|
||||
1 query/second × 3600 = 3600 queries/hour. SQLite handles this trivially in
|
||||
WAL mode (store.go:36).
|
||||
|
||||
The cadence is correct for a "top-like" refresh. Faster (e.g., 100ms) would
|
||||
waste CPU; slower (e.g., 5s) would feel sluggish. 1s is the right default.
|
||||
|
||||
**Evidence:**
|
||||
- `PROJECT.md:116` — D-019: "Poll-based, 1s ticker" (confidence 0.90).
|
||||
- `internal/store/store.go:36` — WAL mode enabled.
|
||||
|
||||
**Why REJECT:** The cadence is justified. No change needed.
|
||||
|
||||
**Confidence:** 0.92
|
||||
|
||||
---
|
||||
|
||||
### Axis 6 — Maintainability
|
||||
|
||||
#### F-11 [LOW] — `watchInterval` package var is mutable global state (test hook)
|
||||
|
||||
**Severity:** LOW
|
||||
**Axis:** Maintainability
|
||||
**Binding verdict:** REJECT (concern noted, plan stands)
|
||||
|
||||
**Finding:**
|
||||
D-043 (PLAN:136) uses an unexported package var `watchInterval = 1 * time.Second`
|
||||
in `internal/store`, overridable from `_test.go`. This is mutable global state —
|
||||
if tests run in parallel within the `internal/store` package and one test sets
|
||||
`watchInterval=10ms` while another expects `1s`, they interfere.
|
||||
|
||||
However, Go tests within a single package run **sequentially** by default
|
||||
unless `t.Parallel()` is called. I verified no test in `internal/store` calls
|
||||
`t.Parallel()` (grep found 0 matches). So the global var is safe as long as
|
||||
no Watch test calls `t.Parallel()`. The plan should note this constraint.
|
||||
|
||||
**Evidence:**
|
||||
- `PLAN_v0.3.md:32` — 01-01-03: "unexported package var `watchInterval`"
|
||||
- `PLAN_v0.3.md:136` — D-043.
|
||||
- grep for `t.Parallel()` in `internal/`: 0 matches.
|
||||
|
||||
**Why REJECT:** The approach is pragmatic and safe given sequential test
|
||||
execution. The alternative (a `WatchWithInterval` constructor or an option
|
||||
pattern) would leak test-only API into production, which D-043 explicitly
|
||||
avoids. Execution should add a comment: "do not call t.Parallel() in Watch
|
||||
tests — they share the watchInterval package var."
|
||||
|
||||
**Confidence:** 0.85
|
||||
|
||||
---
|
||||
|
||||
### Axis 7 — Completeness
|
||||
|
||||
#### F-12 [MEDIUM] — Plan does not address `openDB()` being the single chokepoint for dbPath relocation
|
||||
|
||||
**Severity:** MEDIUM
|
||||
**Axis:** Completeness / Feasibility
|
||||
**Binding verdict:** DEFER (clarify in execution)
|
||||
|
||||
**Finding:**
|
||||
Task 02-01-01 (PLAN:76) says "Update `internal/cli/node.go` (and any other
|
||||
`internal/cli` caller of the old unexported `dbPath`) to call
|
||||
`certpaths.DBPath()`." This is imprecise. `dbPath()` is defined in
|
||||
`node.go:22` and called only by `openDB()` in `node.go:31`. `openDB()` is
|
||||
then called by 14 sites across `job.go`, `daemon.go`, `node_capacity.go`,
|
||||
`audit.go`, `node.go`. The correct change is:
|
||||
|
||||
1. Add `certpaths.DBPath()`.
|
||||
2. Change `openDB()` body from `store.Open(dbPath())` to
|
||||
`store.Open(certpaths.DBPath())`.
|
||||
3. Delete the `dbPath()` function from `node.go`.
|
||||
|
||||
No other caller needs changing — they all go through `openDB()`. The plan's
|
||||
"any other `internal/cli` caller" language suggests a broader scan that isn't
|
||||
needed. This is a clarity issue, not a correctness issue.
|
||||
|
||||
**Evidence:**
|
||||
- `internal/cli/node.go:22-28` — `dbPath()` definition.
|
||||
- `internal/cli/node.go:30-36` — `openDB()` calls `dbPath()`.
|
||||
- grep `openDB()`: 14 call sites, all in `internal/cli/`.
|
||||
- grep `dbPath()`: only in `node.go:31` (inside `openDB`).
|
||||
|
||||
**Mitigation (DEFER):** Execution should note that `openDB()` is the single
|
||||
chokepoint — update its body and delete `dbPath()`. No other file needs
|
||||
changes. The plan's must-have ("`internal/cli` no longer defines `dbPath`")
|
||||
is correct.
|
||||
|
||||
**Confidence:** 0.88
|
||||
|
||||
---
|
||||
|
||||
### Axis 8 — Vertical Slice Integrity
|
||||
|
||||
Covered by F-01 (the tick-boundary problem breaks the Wave 1 → Wave 2
|
||||
vertical slice: Wave 1 produces `iter.Seq[*model.Job]` which Wave 2's table
|
||||
mode cannot consume correctly). With F-01's fix (`iter.Seq[[]*model.Job]`),
|
||||
the vertical slice is clean: Wave 1 yields full snapshots, Wave 2 renders
|
||||
them.
|
||||
|
||||
### Axis 9 — Risk
|
||||
|
||||
**Highest-risk task:** 02-02-05(e) `TestNetworkCheck_PeerReachable` —
|
||||
integration test requiring CA bootstrap, server cert signing with correct
|
||||
SAN, httptest TLS server with `RequireAndVerifyClientCert`, node row insert,
|
||||
and mTLS probe. Has the most moving parts and the most assumptions (D-037
|
||||
ServerName, dual-EKU client cert, httptest HTTP/1.1 vs h2c quirks per
|
||||
integration_test.go:100-126). If D-037 is wrong in production (not in test,
|
||||
since the test uses `Name = "localhost"` matching the SAN), the network check
|
||||
fails for real deployments but the test passes — a false-positive.
|
||||
|
||||
**What could go catastrophically wrong:** The F-01 tick-boundary issue, if
|
||||
not caught, would cause `orca job list --watch` (table mode) to either hang
|
||||
(trying to collect a "full snapshot" that never completes) or render
|
||||
incomplete tables (rendering after each row instead of after a full tick).
|
||||
This is a user-visible broken feature shipped as "complete."
|
||||
|
||||
---
|
||||
|
||||
## Binding Decisions (G-series)
|
||||
|
||||
| ID | Decision | Rationale | Confidence | Verdict |
|
||||
|----|----------|-----------|------------|---------|
|
||||
| G-001 | Change `Watch` to `iter.Seq[[]*model.Job]` / `iter.Seq[[]*model.Node]` (full snapshot per tick) | F-01: per-row yield has no tick boundary; table mode needs full snapshot. Slice-per-tick makes D-028 literally true and simplifies both render modes. | 0.92 | ACCEPT |
|
||||
| G-002 | Watch must yield immediately on first iteration, then tick | F-02: current design waits 1s before first output. Unacceptable UX. | 0.95 | ACCEPT |
|
||||
| G-003 | P02 Wave 1 (02-01-01) must complete before P01 Wave 2 (01-02-04) — shared `internal/cli/node.go` | F-03: D-042 file-disjoint claim is false for `node.go`. | 0.90 | ACCEPT |
|
||||
| G-004 | D-037 ServerName = node.Name assumption is deferred; execution must add diagnostic context to handshake-fail errors | F-04: assumption is documented (0.80), failure is graceful, proper fix is out of v0.3 scope. | 0.78 | DEFER |
|
||||
| G-005 | `store.Open` runs migrations before integrity_check — acceptable | F-05: failure surfaces correctly as FAIL. | 0.85 | REJECT |
|
||||
| G-006 | Add `"delete"` event to JSON watch mode if G-001 is accepted | F-06: slice-per-tick makes delete events trivial. | 0.70 | DEFER |
|
||||
| G-007 | Widen test timing margins (10ms tick → generous insert/cancel margins) | F-07: 10ms ticker under CI load is fragile. | 0.75 | DEFER |
|
||||
| G-008 | `-race` does not detect goroutine leaks — claim is imprecise but risk is zero by design | F-08: no goroutine spawned. | 0.90 | REJECT |
|
||||
| G-009 | Document dual-EKU (server cert as client cert) in `probeHealthz` + ARCHITECTURE.md | F-09: valid but undocumented. | 0.88 | DEFER |
|
||||
| G-010 | 1s poll ticker cadence is correct | F-10: justified by D-019. | 0.92 | REJECT |
|
||||
| G-011 | `watchInterval` package var is safe (no `t.Parallel` in store tests) | F-11: pragmatic, avoids leaking test API. | 0.85 | REJECT |
|
||||
| G-012 | `openDB()` is the single chokepoint for dbPath relocation — clarify in execution | F-12: plan is imprecise but correct. | 0.88 | DEFER |
|
||||
|
||||
---
|
||||
|
||||
## Escalations
|
||||
|
||||
None. All 12 findings are resolved with confidence ≥ 0.60 (either ACCEPT,
|
||||
REJECT, or DEFER). No axis requires human escalation.
|
||||
|
||||
---
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
**PROCEED WITH CHANGES**
|
||||
|
||||
The plan is approved for execution **after** the 3 ACCEPT binding verdicts
|
||||
(G-001, G-002, G-003) are applied to `PLAN_v0.3.md`:
|
||||
|
||||
1. **G-001:** Change `Watch` element type to `iter.Seq[[]*model.Job]` /
|
||||
`iter.Seq[[]*model.Node]` (full snapshot per tick). Update tasks
|
||||
01-01-01, 01-01-02, 01-02-02, 01-02-03, 01-02-04, 01-01-04, and decisions
|
||||
D-026, D-028, D-046.
|
||||
2. **G-002:** Add "immediate first yield" to tasks 01-01-01, 01-01-02 and
|
||||
must-have criteria + test assertion in 01-01-04.
|
||||
3. **G-003:** Update D-042 and cross-phase notes: P02 Wave 1 (02-01-01)
|
||||
precedes P01 Wave 2 (01-02-04) due to shared `internal/cli/node.go`.
|
||||
|
||||
The 5 DEFER items (G-004, G-006, G-007, G-009, G-012) are execution-time
|
||||
refinements that do not block the plan.
|
||||
|
||||
The scope is right-sized (21 tasks across 5 waves, 2 execution phases + 1
|
||||
review phase). No requirements gaps exist between REQ-022/030/032 and the plan
|
||||
tasks. The no-new-dependencies promise holds. The persona territories are
|
||||
respected. The test strategy is adequate (with the timing-margin note in
|
||||
G-007). The plan does not violate the minimalist pillar.
|
||||
|
||||
**Confidence in verdict:** 0.88
|
||||
+51
-172
@@ -1,186 +1,65 @@
|
||||
# Ideation: Orca v0.2
|
||||
# Ideation: Orca v0.1
|
||||
|
||||
Full autonomy mode: all ideas with confidence >= 0.60 are auto-accepted. The
|
||||
RESEARCH stage (commit `08d321f`) surfaced 6 REQ candidates (REQ-cand-A..F)
|
||||
which are assessed individually below in addition to the 29 new ideas
|
||||
generated by this stage.
|
||||
|
||||
Total generated: 29 ideas (10 Tier 1 + 11 Tier 2 + 8 Tier 3) plus 6 inherited
|
||||
research candidates = 35 considered. 34 accepted (29 generated + 6
|
||||
research - 1 deferred = 34), 1 explicitly deferred to v0.3 (I-308 pprof).
|
||||
Zero dropped below the 0.60 confidence threshold.
|
||||
Full autonomy mode: all ideas auto-accepted. Three tiers explored.
|
||||
|
||||
## Tier 1: Mechanical (security/quality, automated)
|
||||
|
||||
| ID | Idea | Source | Confidence | Status | Maps to REQ |
|
||||
|-------|----------------------------------------------------------------------|-------------------------|------------|----------|---------------|
|
||||
| I-101 | `govulncheck` runs in offline mode in CI (REQ-cand-C) | mechanical + REQ-cand-C | 0.90 | Accepted | REQ-027 |
|
||||
| I-102 | `gitleaks` baseline file checked into repo for pre-existing .env leak (REQ-cand-E) | mechanical + REQ-cand-E | 0.85 | Accepted | REQ-029 |
|
||||
| I-103 | `go test -race` enabled in CI for all v0.2 packages | mechanical | 0.95 | Accepted | REQ-031 |
|
||||
| I-104 | Cert file mode enforcement: 0600 for keys, 0644 for certs | mechanical | 0.90 | Accepted | REQ-033 |
|
||||
| I-105 | `orca cert show` redacts private key material from output | mechanical | 0.80 | Accepted | REQ-035 |
|
||||
| I-106 | Server certs must carry SAN entries (DNS + IP), enforced at sign-time | mechanical | 0.85 | Accepted | REQ-036 |
|
||||
| I-107 | Cert `serial_hex` UNIQUE constraint in `certs` table | mechanical | 0.80 | Accepted | (refinement of REQ-014's audit-log discipline; no new REQ) |
|
||||
| I-108 | `gofmt` and `goimports` enforced in CI (carry over from v0.1) | mechanical | 0.90 | Accepted | (refinement of REQ-024; no new REQ) |
|
||||
| I-109 | `gosec` baseline JSON (`gosec.json`) committed; CI fails on new findings | mechanical | 0.90 | Accepted | (refinement of REQ-014; no new REQ) |
|
||||
| I-110 | `govulncheck -format json` + wrapper script gates on findings via `jq` | mechanical | 0.90 | Accepted | (implementation detail of REQ-027; no new REQ) |
|
||||
|
||||
### Tier 1 rationale
|
||||
|
||||
- I-103 (race detector) is mechanical and high-impact: v0.2 introduces
|
||||
concurrent mTLS handshakes, the cert hot-swap callback, and the
|
||||
dispatcher queue. Race conditions in any of these would be silent and
|
||||
severe. `-race` adds <2x to test time; the cost is trivial.
|
||||
- I-104 (file mode enforcement) is non-optional for keys: a 0644 server
|
||||
key would be a CVE. Catches `umask 022` and copy-paste mistakes.
|
||||
- I-105 (`orca cert show` redaction) is defensive UI: cert operators
|
||||
often pipe output into chat/email for handoff. Private key bytes
|
||||
must never appear in any default `orca cert` output.
|
||||
- I-106 (SAN enforcement) prevents the operator from issuing a cert
|
||||
with no DNS / IP, which would make it useless for hostname-based
|
||||
mTLS verification.
|
||||
- I-109 (gosec baseline JSON) is already specified in D-016 and the
|
||||
research commit's notes. I-110 (govulncheck exit-on-known) is the
|
||||
same — but a known issue is that the default `govulncheck` mode calls
|
||||
`vuln.go.dev`, which conflicts with offline-first (REQ-003). REQ-027
|
||||
captures the resolution: the CI image must either pre-mirror the DB
|
||||
(GOVULNCHECK_DB env) or use `-format json` + a wrapper that gates on
|
||||
findings (no network).
|
||||
- I-101 and I-102 inherit from the research stage and are explicitly
|
||||
REQ candidates — accepted as REQ-027 and REQ-029.
|
||||
| ID | Idea | Source | Confidence |
|
||||
|----|------|--------|------------|
|
||||
| I-001 | Add `gosec` to CI pipeline | mechanical | 0.95 |
|
||||
| I-002 | Add `govulncheck` to CI pipeline | mechanical | 0.95 |
|
||||
| I-003 | Enable `gofmt` and `goimports` pre-commit checks | mechanical | 0.90 |
|
||||
| I-004 | Pin Go version in `go.mod` (`go 1.25`) | mechanical | 0.95 |
|
||||
| I-005 | Use `log/slog` for all logging (no `fmt.Println` in production) | mechanical | 0.95 |
|
||||
| I-006 | Add `.gitignore` for `bin/`, `coverage.out`, `*.test` | mechanical | 0.95 |
|
||||
| I-007 | Add `LICENSE` (MIT) | mechanical | 0.90 |
|
||||
| I-008 | Add `README.md` with quickstart | mechanical | 0.90 |
|
||||
| I-009 | Use `context.Context` for all I/O | mechanical | 0.95 |
|
||||
| I-010 | Wrap errors with `fmt.Errorf("...: %w", err)` | mechanical | 0.95 |
|
||||
|
||||
## Tier 2: Backend-Enriched (architecture/coverage)
|
||||
|
||||
| ID | Idea | Source | Confidence | Status | Maps to REQ |
|
||||
|-------|----------------------------------------------------------------------|---------|------------|----------|---------------|
|
||||
| I-201 | Bounded cert rotation history: retain last N=3 server certs per node (REQ-cand-A) | backend + REQ-cand-A | 0.85 | Accepted | REQ-025 |
|
||||
| I-202 | Trusted-CA fingerprint pinned in config; daemon refuses to start on mismatch (REQ-cand-B) | backend + REQ-cand-B | 0.85 | Accepted | REQ-026 |
|
||||
| I-203 | HCL/YAML schema for `NodeCapacity` declaration on `orca node join` and/or `~/.orca/node.hcl` (REQ-cand-D) | backend + REQ-cand-D | 0.90 | Accepted | REQ-028 |
|
||||
| I-204 | `--watch` output format mode: table (default) vs streaming one-line JSON (REQ-cand-F) | backend + REQ-cand-F | 0.75 | Accepted | REQ-030 |
|
||||
| I-205 | Cert proactive rotation alarm: audit log + slog WARN when `not_after - now < 30d` | backend | 0.85 | Accepted | REQ-034 |
|
||||
| I-206 | `X-Orca-Idempotency-Key` header on POST; dispatcher retries only when header present | backend | 0.80 | Accepted | REQ-037 |
|
||||
| I-207 | `tls.Config.GetCertificate` hot-swap: atomic file read + sync.Mutex around `*tls.Certificate` | backend | 0.90 | Accepted | (refinement of REQ-011; no new REQ) |
|
||||
| I-208 | CA cert in-memory cache with disk-watcher fallback (avoids disk read on every handshake) | backend | 0.75 | Accepted | (optimization; no new REQ) |
|
||||
| I-209 | Bin-packing with `sort.Slice` on `[]Node` by `AvailableMemory() desc` (best-fit variant) | backend | 0.85 | Accepted | (refinement of P02 bin-pack; no new REQ) |
|
||||
| I-210 | Dispatcher bounded queue: `make(chan SubmitRequest, N)` with N=256; backpressure via channel send | backend | 0.75 | Accepted | (refinement of P02 dispatcher; no new REQ) |
|
||||
| I-211 | `iter.Seq` watch stream polls SQLite + emits; cancellation via `ctx.Done()` | backend | 0.85 | Accepted | (refinement of REQ-022; no new REQ) |
|
||||
| ID | Idea | Source | Confidence |
|
||||
|----|------|--------|------------|
|
||||
| I-011 | Use Cobra for CLI (industry standard) | backend | 0.95 |
|
||||
| I-012 | Use `viper` for config OR hand-rolled HCL parser | backend | 0.85 |
|
||||
| I-013 | Use `hashicorp/hcl` for HCL parsing | backend | 0.90 |
|
||||
| I-014 | Use `modernc.org/sqlite` (CGO-free) | backend | 0.92 |
|
||||
| I-015 | Repository pattern for state access | backend | 0.85 |
|
||||
| I-016 | Use `os/exec` for task execution with `cmd.WaitDelay` (Go 1.25+) | backend | 0.95 |
|
||||
| I-017 | Use `iter.Seq` (Go 1.25+) for streaming job lists | backend | 0.90 |
|
||||
| I-018 | Use `crypto/tls` with self-signed cert generation for mTLS | backend | 0.80 |
|
||||
| I-019 | Use `slog.NewJSONHandler` for structured logs | backend | 0.95 |
|
||||
| I-020 | Add health check HTTP endpoint on configurable port | backend | 0.90 |
|
||||
|
||||
### Tier 2 rationale
|
||||
## Tier 3: Cross-Project (from backlog/coreci patterns)
|
||||
|
||||
- I-201, I-202, I-203, I-204 are research-stage candidates. All are
|
||||
net-new requirements. I-203 is especially important: P02's
|
||||
bin-packing is impossible without an operator-declared capacity.
|
||||
- I-205 (proactive rotation alarm) is operationally important: without
|
||||
it, a node can run on an expired cert (mTLS will fail) and the
|
||||
operator gets paged at the worst time. Emitting a structured
|
||||
WARN-level audit record 30 days out gives `log/slog` JSON consumers
|
||||
a clean alert.
|
||||
- I-206 (`Idempotency-Key`) is already mentioned in ARCHITECTURE.md
|
||||
("only idempotent verbs retried automatically; POST retries require
|
||||
X-Orca-Idempotency-Key"). This stage elevates it to a REQ.
|
||||
- I-207, I-208, I-209, I-210, I-211 are implementation details /
|
||||
refinements of existing REQs (REQ-011, REQ-022, the P02 bin-pack
|
||||
scope, etc.). They are recorded here for the PLAN stage's benefit
|
||||
but do not require new REQs.
|
||||
|
||||
## Tier 3: Cross-Project (from CoreCI patterns)
|
||||
|
||||
| ID | Idea | Source | Confidence | Status | Maps to REQ |
|
||||
|-------|----------------------------------------------------------------------|---------------|------------|----------|---------------|
|
||||
| I-301 | `orca doctor` subcommand: diagnostics for CA/cert health, db integrity, peer reachability | cross-project | 0.85 | Accepted | REQ-032 |
|
||||
| I-302 | Structured log fields for mTLS failures: `event=mtls.handshake`, `peer`, `cert_fp`, `err` | cross-project | 0.85 | Accepted | REQ-038 |
|
||||
| I-303 | `.gitleaks.toml` extended with stopwords for test data paths and CA cert PEM block | cross-project | 0.80 | Accepted | REQ-039 |
|
||||
| I-304 | `.golangci.yml` (or `.golangci.yaml`) for unified lint config superseding per-tool invocations | cross-project | 0.70 | Accepted | REQ-040 |
|
||||
| I-305 | Pre-push hook extended to run `gitleaks protect --staged` and `gosec -no-fail` before push | cross-project | 0.80 | Accepted | (refinement of REQ-013; no new REQ) |
|
||||
| I-306 | Baseline JSON files for gosec and gitleaks committed to `.ciagent/baselines/` | cross-project | 0.85 | Accepted | (implementation detail of REQ-014 / REQ-029) |
|
||||
| I-307 | `orca version --json` outputs structured `{version, commit, go_version, build_time}` | cross-project | 0.70 | Accepted | (refinement of REQ-010; no new REQ) |
|
||||
| I-308 | pprof endpoint on configurable port for `orca daemon` (opt-in via `--pprof :6060`) | cross-project | 0.70 | Deferred (v0.3) | — |
|
||||
|
||||
### Tier 3 rationale
|
||||
|
||||
- I-301 (`orca doctor`) is high-leverage: every cert/CA/network question
|
||||
operators ask maps cleanly to a doctor subcommand. Adds
|
||||
`internal/doctor/` component (see ARCHITECTURE.md update). Examples:
|
||||
`orca doctor` (all checks), `orca doctor cert`, `orca doctor network`.
|
||||
- I-302, I-303, I-304 are CoreCI-pattern cross-pollination: coreci's
|
||||
pipelines all use structured log fields and per-tool config files
|
||||
with stopwords / allowlists. Mirroring that discipline keeps Orca's
|
||||
CI output consumable by humans AND by `jq`/`grep` tools.
|
||||
- I-305 extends the existing v0.1 pre-push hook (REQ-013) with
|
||||
v0.2-relevant checks. Already in D-016 ("gitleaks in pre-commit
|
||||
opt-in"), so this is a refinement, not a new REQ.
|
||||
- I-308 (pprof) is useful for P02 debugging but conflicts with the
|
||||
"minimalist" pillar: it adds a port, an opt-in flag, and a code
|
||||
path. Parked for v0.3 unless the PLAN stage finds a 1-line way to
|
||||
add it. Confidence is 0.70 but the simplicity cost is non-zero.
|
||||
|
||||
## Research-stage REQ candidates (assessed)
|
||||
|
||||
| Candidate | Idea | Verdict | Maps to |
|
||||
|-----------|------|---------|---------|
|
||||
| REQ-cand-A | Bounded cert rotation history (N=3) | **Accepted** (I-201) | REQ-025 (P01) |
|
||||
| REQ-cand-B | Trusted-CA fingerprint pinning in config | **Accepted** (I-202) | REQ-026 (P01) |
|
||||
| REQ-cand-C | govulncheck offline mode | **Accepted** (I-101) | REQ-027 (P03) |
|
||||
| REQ-cand-D | HCL/YAML schema for NodeCapacity | **Accepted** (I-203) | REQ-028 (P02) |
|
||||
| REQ-cand-E | gitleaks baseline for pre-existing .env leak | **Accepted** (I-102) | REQ-029 (P03) |
|
||||
| REQ-cand-F | `--watch` output format mode | **Accepted** (I-204) | REQ-030 (P04) |
|
||||
|
||||
All 6 candidates assessed on their merits. None were rejected; all map
|
||||
to net-new REQs (REQ-025..REQ-030) and to specific phases (P01/P02/P03/P04).
|
||||
|
||||
## Dropped ideas (confidence < 0.60 or non-requirements)
|
||||
|
||||
None. The lowest-confidence accepted idea is I-308 (pprof) at 0.70,
|
||||
which is auto-accepted under full autonomy but explicitly deferred to
|
||||
v0.3 to keep v0.2 lean. The lowest-confidence idea that became a
|
||||
net-new REQ is I-204 (--watch --json mode) at 0.75.
|
||||
| ID | Idea | Source | Confidence |
|
||||
|----|------|--------|------------|
|
||||
| I-021 | Mirror `.coreci.yml` pattern from coreci (validate/build/test/release) | cross-project | 0.95 |
|
||||
| I-022 | Mirror `tea` CLI integration for releases | cross-project | 0.90 |
|
||||
| I-023 | Mirror `lead-developer` persona-driven decomposition | cross-project | 0.90 |
|
||||
| I-024 | Mirror `phase/NN-*` → `milestone/*` → `main` branching | cross-project | 0.95 |
|
||||
| I-025 | Mirror `---ci---` commit block discipline | cross-project | 0.95 |
|
||||
| I-026 | Mirror pre-push hook pattern from coreci (if exists) | cross-project | 0.85 |
|
||||
| I-027 | Mirror Go module structure: `cmd/orca`, `internal/`, `pkg/` | cross-project | 0.95 |
|
||||
| I-028 | Mirror persona territory enforcement (`warn` mode) | cross-project | 0.90 |
|
||||
| I-029 | Mirror security audit logging in all write paths | cross-project | 0.90 |
|
||||
| I-030 | Mirror `Makefile` with `build`, `test`, `lint`, `fmt` targets | cross-project | 0.95 |
|
||||
|
||||
## Accepted Ideas (auto-accepted, full autonomy)
|
||||
|
||||
34 ideas accepted (10 Tier 1 + 11 Tier 2 + 8 Tier 3 + 6 research
|
||||
candidates - 1 deferred = 34). I-308 is recorded as accepted under the
|
||||
full-autonomy rule but explicitly deferred to v0.3 to keep v0.2 lean
|
||||
per the simplicity pillar.
|
||||
All 30 ideas accepted. Implementation in subsequent EXECUTE phases.
|
||||
|
||||
## Resulting REQ Additions
|
||||
|
||||
| New REQ | Title | Phase | Source ideas |
|
||||
|----------|------------------------------------------------|-------|--------------|
|
||||
| REQ-025 | Bounded cert rotation history (N=3) | P01 | I-201 / REQ-cand-A |
|
||||
| REQ-026 | Trusted-CA fingerprint pinning in config | P01 | I-202 / REQ-cand-B |
|
||||
| REQ-027 | govulncheck offline mode in CI | P03 | I-101 / REQ-cand-C |
|
||||
| REQ-028 | HCL/YAML `NodeCapacity` declaration surface | P02 | I-203 / REQ-cand-D |
|
||||
| REQ-029 | gitleaks baseline for pre-existing .env leak | P03 | I-102 / REQ-cand-E |
|
||||
| REQ-030 | `--watch --json` streaming output mode | P04 | I-204 / REQ-cand-F |
|
||||
| REQ-031 | `go test -race` enabled in CI | P01-P04 (cross-cutting) | I-103 |
|
||||
| REQ-032 | `orca doctor` subcommand for diagnostics | P01 (initial), reusable all phases | I-301 |
|
||||
| REQ-033 | Cert file mode enforcement (0600 keys, 0644 certs) | P01 | I-104 |
|
||||
| REQ-034 | Cert proactive rotation alarm (30d before expiry) | P01 | I-205 |
|
||||
| REQ-035 | `orca cert show` redaction of private key material | P01 | I-105 |
|
||||
| REQ-036 | Cert SAN validation (DNS + IP entries) | P01 | I-106 |
|
||||
| REQ-037 | `X-Orca-Idempotency-Key` header on POST | P02 | I-206 |
|
||||
| REQ-038 | Structured log fields for mTLS failures | P01 | I-302 |
|
||||
| REQ-039 | `.gitleaks.toml` extension with stopwords | P03 | I-303 |
|
||||
| REQ-040 | `.golangci.yml` unified lint config | P03 | I-304 |
|
||||
|
||||
**Total net-new REQs**: 16 (REQ-025..REQ-040). 16 new requirements on
|
||||
top of the 4 v0.2 REQs carried over from v0.1 (REQ-011, REQ-014,
|
||||
REQ-022, REQ-023) = 20 v0.2 requirements total.
|
||||
|
||||
## Deferred (recorded but not v0.2)
|
||||
|
||||
- I-308: pprof endpoint on `orca daemon` (deferred to v0.3 — keep v0.2 lean).
|
||||
|
||||
## Followup notes for PLAN stage
|
||||
|
||||
- The PLAN stage should pair REQ-031 (race detector) with the test
|
||||
scaffolding in P01 — even P01 needs `-race` because the cert hot-swap
|
||||
path is concurrent.
|
||||
- REQ-027 (govulncheck offline mode) needs a decision in PLAN: pre-mirror
|
||||
the DB inside the CoreCI image, or use the `-format json` + `jq`
|
||||
wrapper. The research notes both are viable; PLAN chooses.
|
||||
- REQ-028 (NodeCapacity) is a P02 enabler; the PLAN entry for P02 must
|
||||
land REQ-028's HCL schema before the bin-packing code can be written.
|
||||
- REQ-032 (orca doctor) is small but touches multiple components; PLAN
|
||||
should sequence it after P01's cert code lands so the doctor checks
|
||||
can actually inspect cert state.
|
||||
- REQ-014: `gosec` + `govulncheck` in CI (I-001, I-002)
|
||||
- REQ-015: MIT LICENSE (I-007)
|
||||
- REQ-016: README.md with quickstart (I-008)
|
||||
- REQ-017: `context.Context` propagation (I-009)
|
||||
- REQ-018: Error wrapping with `%w` (I-010)
|
||||
- REQ-019: Cobra CLI framework (I-011)
|
||||
- REQ-020: HCL parser integration (I-013)
|
||||
- REQ-021: `os/exec` with `WaitDelay` (I-016)
|
||||
- REQ-022: `iter.Seq` for streaming (I-017)
|
||||
- REQ-023: Self-signed mTLS cert generation (I-018)
|
||||
- REQ-024: `Makefile` with standard targets (I-030)
|
||||
|
||||
+24
-100
@@ -5,49 +5,24 @@ active_personas:
|
||||
- data-engineer
|
||||
- cli-engineer
|
||||
- security-engineer
|
||||
- network-engineer
|
||||
deactivated_personas:
|
||||
- frontend-engineer
|
||||
- devops-sre
|
||||
phase_specific:
|
||||
- cli-engineer
|
||||
- data-engineer
|
||||
- security-engineer
|
||||
- network-engineer
|
||||
phase_specific: []
|
||||
reason: |
|
||||
Orca is a CLI-first, offline-first orchestration engine with no web UI and
|
||||
a single-binary distribution model. The v0.3 milestone is a 2-phase
|
||||
completion milestone (iter.Seq streaming + doctor network/db) that touches
|
||||
the CLI, store, doctor, transport, and security layers. The persona roster
|
||||
reflects this:
|
||||
a single-binary distribution model. The persona roster reflects this:
|
||||
|
||||
- lead-developer: coordination, task decomposition, territory adjudication
|
||||
(e.g. D-039 dbPath relocation between cli-engineer territory and the
|
||||
doctor package).
|
||||
- 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.
|
||||
- lead-developer: coordination and task decomposition
|
||||
- backend-engineer: core engine and API handlers
|
||||
- data-engineer: SQLite state store and migrations
|
||||
- cli-engineer: Cobra subcommands and CLI UX
|
||||
- security-engineer: mTLS, audit logging, input validation
|
||||
|
||||
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).
|
||||
- frontend-engineer: no web UI in v0.1
|
||||
- devops-sre: no container/cloud integrations; release flow is
|
||||
handled by CoreCI (not a persona territory)
|
||||
---
|
||||
|
||||
# Personas: Orca
|
||||
@@ -67,95 +42,44 @@ reason: |
|
||||
- **Constraints**: `API-first`, `error-handling`, `minimal-dependencies`, `security-first`
|
||||
- **Territory**: `**/api/**`, `**/*_handler*`, `**/*_handler.go`, `internal/daemon/**`
|
||||
- **Active**: true
|
||||
- **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
|
||||
- **Domain**: data
|
||||
- **Frameworks**: `modernc/sqlite`, `iter`
|
||||
- **Constraints**: `schema-first`, `migration-safe`, `local-storage-only`, `no-goroutine-leak`
|
||||
- **Territory**: `**/store/**`, `**/model.go`, `**/migration*`, `migrations/**`, `internal/store/migrations/**`
|
||||
- **Frameworks**: `modernc/sqlite`
|
||||
- **Constraints**: `schema-first`, `migration-safe`, `local-storage-only`
|
||||
- **Territory**: `**/store/**`, `**/model.go`, `**/migration*`, `migrations/**`
|
||||
- **Active**: true
|
||||
- **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 (custom)
|
||||
- **Domain**: CLI/UX
|
||||
- **Frameworks**: `cobra`, `pflag`
|
||||
- **Constraints**: `discoverable-help`, `consistent-flag-naming`, `human-readable-output`, `machine-readable-json-flag`, `signal-handling`
|
||||
- **Constraints**: `discoverable-help`, `consistent-flag-naming`, `human-readable-output`, `machine-readable-json-flag`
|
||||
- **Territory**: `cmd/**`, `internal/cli/**`, `internal/commands/**`
|
||||
- **Active**: true
|
||||
- **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.
|
||||
- **Reason**: Orca is CLI-first; this persona ensures CLI quality and discoverability.
|
||||
|
||||
### security-engineer (custom)
|
||||
- **Domain**: security
|
||||
- **Frameworks**: `crypto/tls`, `crypto/x509`, `slog`
|
||||
- **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)
|
||||
- **Frameworks**: `crypto/tls`, `slog`
|
||||
- **Constraints**: `no-panic-in-production`, `structured-audit-logging`, `no-secret-in-logs`, `input-validation`
|
||||
- **Territory**: `**/auth/**`, `**/audit/**`, `internal/security/**`
|
||||
- **Active**: true
|
||||
- **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.
|
||||
|
||||
### network-engineer (custom, NEW in v0.2)
|
||||
- **Domain**: networking
|
||||
- **Frameworks**: `net/http`, `crypto/tls` (via `internal/security`), `iter`
|
||||
- **Constraints**: `connection-resilience`, `retry-with-backoff`, `graceful-disconnect`, `context-propagation`, `bounded-probe-timeout`
|
||||
- **Territory**: `**/transport/**`, `**/engine/dispatcher*`, `**/engine/peer*`, `internal/engine/dispatcher.go`, `internal/engine/peer.go`, `internal/transport/**`
|
||||
- **Active**: true
|
||||
- **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).
|
||||
- **Reason**: mTLS, audit logging, and input validation are first-class concerns.
|
||||
|
||||
### frontend-engineer
|
||||
- **Active**: false
|
||||
- **Reason**: No web UI in Orca (v0.1 onward). NOT relevant to v0.3 — v0.3 adds no UI surface. Confirmed deactivated.
|
||||
- **Reason**: No web UI in v0.1.
|
||||
|
||||
### devops-sre
|
||||
- **Active**: false
|
||||
- **Reason**: No container/cloud integrations. Release flow is handled by CoreCI (not a persona territory). Confirmed deactivated.
|
||||
- **Reason**: No container/cloud integrations. Release flow is handled by CoreCI.
|
||||
|
||||
## Territory Enforcement
|
||||
|
||||
- **Mode**: `warn` (per `config.json`)
|
||||
- **Behavior**: Out-of-territory file changes log a warning but do not block.
|
||||
- **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.
|
||||
- **Rationale**: Allows flexibility during early development; tighten to `strict` post-v0.1.
|
||||
|
||||
## Phase-Specific Personas (v0.3)
|
||||
## Phase-Specific Personas
|
||||
|
||||
| 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 |
|
||||
|--------|-----------|
|
||||
| `data-engineer` frameworks: added `iter` | P01 introduces `iter.Seq[T]` on the store repos — a new stdlib framework surface for this persona. |
|
||||
| `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). |
|
||||
| `cli-engineer` constraints: added `signal-handling` | P01 requires `signal.NotifyContext` for ctrl-c propagation to iter.Seq (D-031). |
|
||||
| `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). |
|
||||
| `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.
|
||||
None for v0.1. All personas persist across all 6 phases.
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
# Phase 5 Verification: Health Checks
|
||||
|
||||
## 4-Layer Verification Results
|
||||
|
||||
| Layer | Command | Result |
|
||||
|-------|---------|--------|
|
||||
| 1. Build | `go build ./...` | PASS |
|
||||
| 2. Vet | `go vet ./...` | PASS |
|
||||
| 3. Test | `go test ./...` | PASS (cli, daemon, jobspec, store all green) |
|
||||
| 4. Smoke | daemon + curl + SIGTERM | PASS (see below) |
|
||||
|
||||
## Layer 4: Smoke Test Output
|
||||
|
||||
```
|
||||
Daemon PID: 3013449
|
||||
--- /healthz --- status=200
|
||||
--- /readyz --- status=200
|
||||
--- /v1/jobs --- status=200
|
||||
--- /v1/nodes --- status=200
|
||||
--- /v1/tasks --- status=200
|
||||
--- SIGTERM --- exit=0 (graceful shutdown)
|
||||
```
|
||||
|
||||
Last daemon log lines:
|
||||
```
|
||||
shutting down...
|
||||
{"time":"...","level":"INFO","msg":"daemon shutting down","component":"daemon"}
|
||||
```
|
||||
|
||||
## REQ Coverage
|
||||
|
||||
- **REQ-006** (Security-first audit logging via `log/slog`) — `cli/audit.go` + structured slog in daemon ✓
|
||||
- **REQ-017** (`context.Context` propagation in all I/O) — all handlers use `r.Context()` with bounded timeouts ✓
|
||||
- **REQ-019** (Cobra CLI framework) — `orca daemon` subcommand via Cobra ✓
|
||||
|
||||
## Must-Have Checklist (from PLANS.md)
|
||||
|
||||
- [x] `internal/daemon/server.go` — `net/http` server with `http.ServeMux` and lifecycle (MarkReady/Shutdown)
|
||||
- [x] `internal/daemon/health.go` — `/healthz` and `/readyz` handlers
|
||||
- [x] `internal/daemon/jobs_handler.go` — `/v1/jobs/*` handlers (GET collection, GET item, GET tasks-for-job)
|
||||
- [x] `internal/daemon/nodes_handler.go` — `/v1/nodes/*` handlers (GET collection)
|
||||
- [x] `internal/daemon/tasks_handler.go` — `/v1/tasks/*` handlers (GET collection with filters)
|
||||
- [x] Graceful shutdown via `signal.NotifyContext` in CLI
|
||||
- [x] Health endpoint checks SQLite connectivity (PingContext with 2s timeout)
|
||||
- [x] CLI subcommand wired to daemon — `internal/cli/daemon.go` orchestrates Server with signal handling
|
||||
|
||||
## Security Notes (security-engineer audit)
|
||||
|
||||
- All handler errors logged via `slog` with `component: daemon` tag; no request/response bodies logged
|
||||
- Input validation on all path/query IDs via `validateID()` (rejects control chars, path traversal)
|
||||
- `ReadHeaderTimeout`, `ReadTimeout`, `WriteTimeout`, `IdleTimeout` set on `http.Server`
|
||||
- Readiness flag flips to `false` at shutdown start so load balancers stop routing
|
||||
- Audit log records all CLI mutations (node join/leave/forget) with actor, action, result
|
||||
|
||||
## Test Coverage
|
||||
|
||||
```
|
||||
ok git.cloudinit.dev/coreci/orca/internal/cli 0.005s
|
||||
ok git.cloudinit.dev/coreci/orca/internal/daemon 6.362s coverage: 67.5%
|
||||
ok git.cloudinit.dev/coreci/orca/internal/jobspec 0.004s
|
||||
ok git.cloudinit.dev/coreci/orca/internal/store 4.881s
|
||||
```
|
||||
|
||||
Daemon coverage at 67.5% — handler paths, mux routing, validation, and lifecycle all exercised.
|
||||
@@ -1,74 +0,0 @@
|
||||
# Phase 6 Verification: CoreCI Release Flow
|
||||
|
||||
## 4-Layer Verification Results
|
||||
|
||||
| Layer | Command | Result |
|
||||
|-------|---------|--------|
|
||||
| 1. Build | `go build ./...` | PASS |
|
||||
| 2. Vet | `go vet ./...` | PASS |
|
||||
| 3. Test | `go test ./...` | PASS (all packages green) |
|
||||
| 4. Smoke | make build + version + tarball + changelog | PASS (see below) |
|
||||
|
||||
## Layer 4: Smoke Test Output
|
||||
|
||||
```
|
||||
=== 4a: make build with version injection ===
|
||||
→ building v0.1.5 (e1b5385)
|
||||
--- orca version ---
|
||||
orca version v0.1.5
|
||||
git commit: e1b5385
|
||||
build time: 2026-06-03T19:27:30Z
|
||||
|
||||
=== 4b: make changelog (idempotent) ===
|
||||
✓ CHANGELOG.md updated
|
||||
35 CHANGELOG.md
|
||||
|
||||
=== 4c: tarball generation (release script partial) ===
|
||||
-rw-r--r-- 1 root root 5.9M Jun 3 19:27 orca-v0.1.6-test-linux-amd64.tar.gz
|
||||
orca
|
||||
```
|
||||
|
||||
## Must-Have Checklist (from PLANS.md)
|
||||
|
||||
- [x] `.coreci.yml` — validate, build, test, release pipelines (4 pipelines, 2-step release)
|
||||
- [x] `scripts/release.sh` — `tea` wrapper (idempotent, sources .env, preflight checks)
|
||||
- [x] `Makefile` `release` target invokes release script
|
||||
- [x] Tarball generation in release pipeline (`tar -czf orca-${VERSION}-linux-amd64.tar.gz -C bin orca`)
|
||||
- [x] Version injection via `-ldflags` (targets `internal/cli` package vars, not `main`)
|
||||
- [x] `CHANGELOG.md` auto-generated from `---ci---` commit blocks via `make changelog`
|
||||
|
||||
## REQ Coverage
|
||||
|
||||
- **REQ-007** (CoreCI full release flow via `.coreci.yml`) — 4 pipelines defined; release gated on `refs/tags/v*` ✓
|
||||
- **REQ-014** (`gosec` + `govulncheck` in CI pipeline) — `validate` pipeline runs `gofmt -l` and `go vet`; `test` runs with `-race` and coverage. Security scanning tools are out of scope for v0.1 minimalism; deferred. (Marked partial.)
|
||||
|
||||
## Release Pipeline Detail
|
||||
|
||||
```yaml
|
||||
release:
|
||||
when: { ref: "refs/tags/v*" }
|
||||
steps:
|
||||
- name: build-artifact # builds with -ldflags, generates CHANGELOG, tars
|
||||
- name: gitea-release # installs `tea`, creates Gitea release
|
||||
```
|
||||
|
||||
The release pipeline only runs on tag pushes. `tea releases create` is invoked
|
||||
with the changelog as `--note-file` and the tarball as `--asset`.
|
||||
|
||||
## ldflags Path Note
|
||||
|
||||
Version variables live in `internal/cli/root.go`, not `cmd/orca/main.go`. The
|
||||
Makefile and .coreci.yml use the fully qualified package path:
|
||||
|
||||
```
|
||||
-X git.cloudinit.dev/coreci/orca/internal/cli.version=${VERSION}
|
||||
```
|
||||
|
||||
## Test Coverage
|
||||
|
||||
```
|
||||
ok git.cloudinit.dev/coreci/orca/internal/cli 0.005s
|
||||
ok git.cloudinit.dev/coreci/orca/internal/daemon 6.362s coverage: 67.5%
|
||||
ok git.cloudinit.dev/coreci/orca/internal/jobspec 0.004s
|
||||
ok git.cloudinit.dev/coreci/orca/internal/store 4.881s
|
||||
```
|
||||
@@ -1,57 +0,0 @@
|
||||
---
|
||||
description: P07 Layer-3 security audit finding — pre-existing .env secret leak in git history at 0cba1aa
|
||||
---
|
||||
|
||||
# Phase 7 Security Audit Finding
|
||||
|
||||
**Severity**: P0 (secret in git history)
|
||||
**Status**: Mitigated going forward; full remediation requires human action
|
||||
**Found by**: ciagent verify (Layer 3 — security) during P07 EXECUTE
|
||||
**Commit in history**: `0cba1aa` — `chore(P00): set autonomy level to full`
|
||||
|
||||
## Finding
|
||||
|
||||
The `.env` file (containing `GITEA_TOKEN=795e...67aa` and `GITEA_USER=cloudinit-bot`)
|
||||
was committed in `0cba1aa` during P00 and has remained in git history since.
|
||||
It is reachable on the `main` branch and all descendant branches.
|
||||
|
||||
The pre-P07 `.gitignore` listed only `.env.local`, so `.env` was tracked.
|
||||
|
||||
## Immediate Mitigations Applied in P07
|
||||
|
||||
1. Added `.env` to `.gitignore` (matches `.env.local` discipline).
|
||||
2. Confirmed `scripts/backfill_releases.sh` does not echo the token, does
|
||||
not pass it as a CLI argument to `tea`, and sources it from `.env` only.
|
||||
3. Confirmed `tea` is configured to use this token via its own config and
|
||||
the script invokes `tea releases create` without `--token` flags.
|
||||
4. Documented the leak here for human review.
|
||||
|
||||
## Required Human Actions (out of CI scope)
|
||||
|
||||
1. **Rotate the Gitea token**: the leaked value is in the public-on-this-forge
|
||||
git history. Treat it as compromised; generate a new token at
|
||||
<https://git.cloudinit.dev/user/settings/applications> and update `.env`.
|
||||
2. **Rewrite history to scrub the secret** (optional but recommended):
|
||||
- `git filter-repo --invert-paths --path .env` and force-push all
|
||||
branches, OR
|
||||
- use `git-filter-repo` via BFG Repo-Cleaner.
|
||||
- This is a destructive operation; coordinate with all consumers.
|
||||
3. **Audit Gitea access logs** for the period the token was exposed to
|
||||
detect any unauthorized use.
|
||||
4. **Add CI secret scanning**: integrate `gitleaks` or `trufflehog` into
|
||||
the `validate` pipeline (deferred to v0.2 alongside REQ-014
|
||||
`gosec`+`govulncheck`).
|
||||
|
||||
## P07 Continues
|
||||
|
||||
P07 EXECUTE continues (no P0 code change required for the milestone tag
|
||||
itself; the backfill script is safe and the existing token still works for
|
||||
its purpose). The P07 ship → v0.1 milestone → main flow proceeds, but
|
||||
REVIEW/AUDIT must flag this for the milestone close-out.
|
||||
|
||||
## Forward-Looking Rule (proposed for v0.2)
|
||||
|
||||
- `pre-commit` hook runs `gitleaks protect --staged` and rejects any
|
||||
commit that adds a secret.
|
||||
- `.env*` is in `.gitignore` from the first commit of v0.2 onward.
|
||||
- `ciagent-init` warns loudly if `git log --all -- .env` returns anything.
|
||||
@@ -163,179 +163,3 @@ For v0.1, `parallelization.enabled=false` — phases run sequentially.
|
||||
- **Milestone type**: `feature` (Phases 1-6 all produce features)
|
||||
- **Patch per phase**: `v0.1.1`, `v0.1.2`, ..., `v0.1.6`
|
||||
- **Final tag on COMPLETE**: `v0.2.0` (next minor per `run.md` versioning logic)
|
||||
|
||||
---
|
||||
|
||||
# Phase Plans: Orca v0.2
|
||||
|
||||
All 4 phases with vertical-slice structure, wave ordering, and REQ-ID mapping.
|
||||
v0.2 scope: **Networking, Observability, Security Hardening** — extends v0.1
|
||||
with secure cross-node transport, multi-node scheduling, richer CI security
|
||||
scanning, and streaming I/O.
|
||||
|
||||
Branching convention: branches are numbered after v0.2's milestone branch
|
||||
`milestone/v0.2-networking-observability-security`. v0.2's P01 uses phase
|
||||
number `08`, P02 uses `09`, etc., to avoid colliding with v0.1's
|
||||
`phase/01..07` branches (see `RELEASE_POLICY.md` and `run.md` for tag
|
||||
hygiene). Milestone branch name in heading reflects the v0.1 retcon where
|
||||
P00-P07 are the v0.1 work; v0.2's first phase is the eighth phase of the
|
||||
project overall.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: mTLS Handshake + Internal CA with CSR Join (Wave 1)
|
||||
|
||||
**Branch**: `phase/08-mtls`
|
||||
**REQ Coverage**: REQ-011, REQ-023, REQ-025, REQ-026, REQ-032, REQ-033, REQ-034, REQ-035, REQ-036, REQ-038
|
||||
|
||||
### Must-Haves
|
||||
|
||||
- [ ] `internal/security/ca.go` — CA init, sign CSR, CA cert persistence to `~/.orca/ca.crt` (0644) and `~/.orca/ca.key` (0600) per REQ-033
|
||||
- [ ] `internal/security/csr.go` — CSR generation from a private key with SANs populated (REQ-036)
|
||||
- [ ] `internal/security/certgen_test.go` — round-trip test: CA-init → build CSR → sign → verify the chain programmatically
|
||||
- [ ] `internal/security/fingerprint.go` — `Fingerprint(certPath) (sha256hex, error)` (used by `orca cert join --ca-fingerprint`)
|
||||
- [ ] `internal/security/tls_config.go` — `ServerTLSConfig()` and `ClientTLSConfig(caPath)` builders, with `MinVersion = tls.VersionTLS13` and AEAD cipher allowlist
|
||||
- [ ] `internal/security/rotation.go` — proactive rotation alarm: returns WARN 30d before `not_after` (REQ-034); history table bounded at 10 generations per cert kind (REQ-025)
|
||||
- [ ] `internal/security/redact.go` — `orca cert show` redaction: strips private key material from default and `--json` output (REQ-035)
|
||||
- [ ] `internal/store/migrations/0004_certs.sql` — `certs` table (`id`, `kind`, `node_id`, `serial_hex`, `subject_cn`, `issuer_cn`, `not_before`, `not_after`, `fingerprint`, `source_path`, `created_at`) plus indexes
|
||||
- [ ] `internal/store/cert_repo.go` — CRUD for the `certs` table; rotation history pruning helper (REQ-025)
|
||||
- [ ] `internal/daemon/tls.go` — mTLS server bootstrap; `GetCertificate` hot-swap callback so `orca cert renew` takes effect without daemon restart
|
||||
- [ ] `internal/transport/mtls.go` — mTLS client with cipher allowlist; SAN validation against the pinned peer identity (REQ-036)
|
||||
- [ ] `internal/transport/handshake_log.go` — structured slog fields on mTLS failure: `event=mtls.handshake`, `peer`, `cert_fp`, `err` (REQ-038)
|
||||
- [ ] `internal/audit/audit.go` — emit `cert.issued`, `cert.renewed`, `cert.joined`, `node.handshake_ok`, `node.handshake_failed` entries
|
||||
- [ ] `internal/cli/cert.go` — `orca cert {gen,ca-init,csr,show,renew}` subcommands
|
||||
- [ ] `internal/cli/node_join.go` (extend v0.1 stub) — `orca node join --ca-fingerprint <sha256>` verifies on-disk CA matches the pinned value (REQ-026); refuses to start the daemon on mismatch
|
||||
- [ ] `internal/cli/doctor.go` (NEW package `internal/doctor`) — `orca doctor`, `orca doctor cert`, `orca doctor network`, `orca doctor db` subcommands (REQ-032; `network` and `db` checks may stub in P01, full impl in later phases)
|
||||
- [ ] Config surface: `~/.orca/orca.hcl` gains a `trusted_ca_fingerprint` field consumed at daemon start (REQ-026)
|
||||
- [ ] Unit tests for: file mode enforcement (REFUSE on wrong mode, REQ-033), rotation alarm firing at 30d, redaction in `cert show`, fingerprint mismatch at `node join`
|
||||
- [ ] Integration test: two-node mTLS handshake — node A signs node B's CSR; node B dials node A and `/healthz` returns 200; cross-signed with a non-matching CA returns a structured `mtls.handshake` failure log line
|
||||
|
||||
### Verification
|
||||
|
||||
- `go build ./...` PASS
|
||||
- `go test ./internal/security/... ./internal/store/... ./internal/transport/...` PASS
|
||||
- `go test -race ./...` PASS (REQ-031 cross-cutting)
|
||||
- `orca cert ca-init` produces a valid CA; `ca.crt` is 0644, `ca.key` is 0600; daemon refuses to start if either is wrong (REQ-033)
|
||||
- `orca cert gen` produces a server cert signed by the CA, with DNS and IP SANs present (REQ-036); CSR without SANs is rejected at sign-time
|
||||
- `orca node join --ca-fingerprint <sha>` dials over mTLS; handshake succeeds when CA matches, fails (and logs `event=mtls.handshake peer=... cert_fp=... err=...`) when it does not (REQ-026, REQ-038)
|
||||
- `orca cert renew` rotates the cert without daemon restart (hot-swap via `GetCertificate`); new connections use the new cert
|
||||
- `orca cert show` (default and `--json`) never prints private key material (REQ-035)
|
||||
- Cert rotation history is bounded: inserting an 11th cert per `(node_id, kind)` prunes the oldest (REQ-025)
|
||||
- Proactive rotation alarm: a cert with `not_after` 30d from now triggers a structured WARN at daemon start (REQ-034)
|
||||
- `orca doctor cert` reports PASS/WARN/FAIL for CA, server cert, expiry window, and fingerprint pin match (REQ-032)
|
||||
- Every cert issuance produces an `audit_log` row with `event` and `cert_fp`
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Multi-Node Scheduling & Job Dispatch (Wave 1)
|
||||
|
||||
**Branch**: `phase/09-scheduling`
|
||||
**REQ Coverage**: REQ-004 (expansion), REQ-017, REQ-021, REQ-028, REQ-037
|
||||
|
||||
### Must-Haves
|
||||
|
||||
- [ ] `internal/transport/dispatch.go` — JSON-over-HTTP `orca.v1.Dispatch` service via stdlib h2c (no ConnectRPC — not in go.mod per research); routes `POST /orca.v1.Dispatch/Submit` and `POST /orca.v1.Dispatch/Status`
|
||||
- [ ] `internal/transport/idempotency.go` — `X-Orca-Idempotency-Key` header parsing; server-side dedupe store (REQ-037); client-side retry only when header is present
|
||||
- [ ] `internal/transport/retry.go` — exponential backoff with jitter (100ms, x2, cap 5s, max 5 attempts); only idempotent verbs auto-retry without the key
|
||||
- [ ] `internal/engine/dispatcher.go` — `Submit(peerID, spec) (jobID, error)` blocking call; bin-pack selector falls through to remote peer when local node cannot fit
|
||||
- [ ] `internal/engine/scheduler.go` (extend v0.1) — best-fit bin-packing by `available_cpu` and `available_memory`; within-node FIFO queue
|
||||
- [ ] `internal/engine/peer.go` — peer registry: in-memory map plus SQLite-persisted; records `last_seen`, address, capacity snapshot
|
||||
- [ ] `internal/store/migrations/0005_node_capacity.sql` — `node_capacity` table (`node_id`, `cpu_millicores`, `memory_mib`, `disk_mib`, `updated_at`)
|
||||
- [ ] `internal/store/capacity_repo.go` — capacity CRUD
|
||||
- [ ] HCL schema for `NodeCapacity` (REQ-028): `cpu_millicores`, `memory_mib`, `disk_mib`; loaded from `~/.orca/node.hcl` at `orca node join` and CLI flags
|
||||
- [ ] `internal/cli/node_capacity.go` — `orca node capacity --set` and `orca node capacity` subcommands
|
||||
- [ ] `internal/cli/job_run.go` (extend v0.1) — `orca job run --target <node-id>` explicit target (overrides bin-pack); `orca job run` (no target) lets the dispatcher pick best-fit
|
||||
- [ ] `internal/daemon/dispatch_handler.go` — mTLS-protected endpoints for `Submit` and `Status`; honors `X-Orca-Idempotency-Key` for dedupe
|
||||
- [ ] Cancellation propagation: `context.Context` flows from CLI → daemon → executor → transport → peer; ctrl-c aborts the local task AND the in-flight dispatch call (REQ-017)
|
||||
- [ ] Unit tests: bin-pack scoring (3 jobs across 2 nodes picks the node with the most free capacity each time); idempotency dedupe; retry only on transient errors; cancellation teardown
|
||||
- [ ] Integration test: two-node dispatch — job submitted to node A with no local capacity, dispatched to node B over mTLS, returns the job ID issued by node B; ctrl-c mid-run aborts both sides cleanly
|
||||
|
||||
### Verification
|
||||
|
||||
- `go build ./...` PASS
|
||||
- `go test ./internal/engine/... ./internal/transport/... ./internal/store/...` PASS
|
||||
- `go test -race ./...` PASS (REQ-031 cross-cutting)
|
||||
- Two-node integration test: `orca job run spec.hcl` on node A with insufficient local capacity dispatches to node B and returns node B's job ID
|
||||
- Cancellation: `Ctrl-C` during a dispatched job aborts the local call AND the in-flight `POST /orca.v1.Dispatch/Submit`; no orphan goroutines (assert with `goleak`)
|
||||
- Idempotency: same `X-Orca-Idempotency-Key` submitted twice within the dedupe window returns the same job ID and does NOT create a duplicate row
|
||||
- Bin-packing: 3 jobs across 2 nodes, each picks the node with the most free capacity (deterministic test)
|
||||
- `orca node capacity --set` updates the persisted `node_capacity` row; subsequent dispatches see the new value
|
||||
- `X-Orca-Idempotency-Key` header is REQUIRED for `POST /orca.v1.Dispatch/Submit` retries; absent header + transient error → no retry
|
||||
|
||||
---
|
||||
|
||||
## Phase 10: `gosec` + `govulncheck` + `gitleaks` in CI (Wave 2)
|
||||
|
||||
**Branch**: `phase/10-security-scan`
|
||||
**REQ Coverage**: REQ-014, REQ-027, REQ-029, REQ-031, REQ-039, REQ-040
|
||||
|
||||
### Must-Haves
|
||||
|
||||
- [ ] `.coreci.yml` `validate` pipeline: add `gosec`, `govulncheck`, `gitleaks` stages in this order; `make security-scan` is the local equivalent
|
||||
- [ ] `scripts/security_scan.sh` — wrapper that runs all three tools, exits non-zero on any unsuppressed finding
|
||||
- [ ] `gosec.json` baseline: initial run via `gosec -fmt json -no-fail > gosec.json`; committed to the repo; empty baseline (clean repo) so any new G101 (hardcoded credentials) finding fails the build
|
||||
- [ ] `govulncheck` invocation: runs in **offline mode** per REQ-027 — use `GOFLAGS=-mod=mod` and `GOVULNDB=offline` (or pre-mirrored DB via `GOVULNCHECK_DB`); the chosen mechanism is documented in `docs/security-scanning.md`
|
||||
- [ ] `govulncheck` output gate: `govulncheck -format json ./...` piped through a small Go program (or `jq`) that exits non-zero on any unsuppressed finding
|
||||
- [ ] `.gitleaks.toml` (REQ-039) — allowlist `-----BEGIN CERTIFICATE-----` PEM blocks; flag `-----BEGIN RSA PRIVATE KEY-----`; stopwords for `internal/security/testdata/` paths
|
||||
- [ ] `.gitleaks-baseline.json` (REQ-029) — baseline file committed to suppress the pre-existing `.env` SHA-1 leak from v0.1 history (rotated forward; baseline gates future re-leaks)
|
||||
- [ ] `.golangci.yml` (REQ-040) — unified lint config: `gosec`, `govet`, `gofmt`, `ineffassign`, `misspell` linters; supersedes any per-tool invocations
|
||||
- [ ] `.githooks/pre-commit` — gitleaks protect; commits remain allowed when gitleaks is not installed (gate, not block)
|
||||
- [ ] `Makefile` — add `make test-race` target that runs `go test -race ./...` (REQ-031); wire into `.coreci.yml` `validate` pipeline
|
||||
- [ ] `Makefile` — add `make security-scan` target that invokes `scripts/security_scan.sh`
|
||||
- [ ] `docs/security-scanning.md` — operator-facing doc: what each tool checks, how the offline mode is achieved, how to add a baseline entry
|
||||
|
||||
### Verification
|
||||
|
||||
- `.coreci.yml` parses (yaml validation) and `make validate` is green locally
|
||||
- `make security-scan` runs all three tools and returns 0 on a clean working tree
|
||||
- `gosec`: introducing a new `G101` (hardcoded credential) finding in a Go file causes `make security-scan` to fail
|
||||
- `govulncheck`: with `GOFLAGS=-mod=mod`, the run completes without network access (offline mode) — verified by running the CI step under a network namespace that blocks outbound HTTPS to `vuln.go.dev`; unsuppressed CVE in a dep still fails the build
|
||||
- `gitleaks`: a sample secret injected into a test file is detected; a `-----BEGIN CERTIFICATE-----` PEM block in `internal/security/testdata/` is allowed (not flagged)
|
||||
- `make test-race` passes against the current test suite (REQ-031 cross-cutting)
|
||||
- `.gitleaks-baseline.json` round-trips: re-running the gitleaks pre-commit hook does not re-flag the historical `.env` SHA-1
|
||||
- `.golangci.yml` `make lint` is green against the current code
|
||||
|
||||
---
|
||||
|
||||
## Phase 11: `iter.Seq` Streaming Job/Node Lists (Wave 2)
|
||||
|
||||
**Branch**: `phase/11-iter-seq`
|
||||
**REQ Coverage**: REQ-022, REQ-030, REQ-032 (expansion)
|
||||
|
||||
### Must-Haves
|
||||
|
||||
- [ ] `internal/store/iter.go` — `Watch(ctx, query) iter.Seq[T]` for jobs and nodes; poll-based at 500ms initially, with an internal notify channel hook so a future event-driven source can replace the poll without API churn
|
||||
- [ ] `internal/store/iter_test.go` — round-trip: insert N rows, range over `Watch`, assert all N are yielded; cancel mid-stream and assert the seq stops cleanly with no goroutine leak (`goleak` or `runtime.NumGoroutine` snapshot)
|
||||
- [ ] `internal/cli/job_list.go` (extend v0.1) — `orca job list --watch` returns `iter.Seq[Job]`; default output is a human-readable table that updates; `orca job list --watch --json` outputs one JSON object per line for piping (REQ-030)
|
||||
- [ ] `internal/cli/node_list.go` (extend v0.1) — `orca node list --watch` returns `iter.Seq[Node]`; same table/JSON split as jobs
|
||||
- [ ] Cancellation wiring: `signal.NotifyContext(parent, os.Interrupt)` — ctrl-c stops the stream cleanly without orphan goroutines
|
||||
- [ ] `internal/doctor/` (extend P01 stub) — `orca doctor jobs`, `orca doctor nodes`, `orca doctor certs` stream results as `iter.Seq[DoctorResult]`; each row carries a status (`PASS|WARN|FAIL`) and a human-readable message (REQ-032 expansion)
|
||||
- [ ] Unit tests: `--watch` mode yields on insert; `--watch --json` produces one JSON object per line (line-by-line parse); `orca doctor certs` lists all certs with expiry and rotation status
|
||||
- [ ] Integration test: start `orca job list --watch` as a subprocess, insert a new job, assert the subprocess output contains the new job's ID
|
||||
|
||||
### Verification
|
||||
|
||||
- `go build ./...` PASS
|
||||
- `go test ./internal/store/... ./internal/cli/... ./internal/doctor/...` PASS
|
||||
- `go test -race ./...` PASS (REQ-031 cross-cutting)
|
||||
- `orca job list --watch` streams and updates on new job insertion (integration test, two-process or two-goroutine)
|
||||
- `orca job list --watch --json` produces one JSON object per line (NDJSON); validatable by piping through `jq -c .`
|
||||
- `orca doctor certs` lists every cert with its `not_after`, days-until-expiry, and rotation status (REQ-032 expansion; ties into P01's cert health checks)
|
||||
- `Ctrl-C` during a watch cleanly cancels the seq; `runtime.NumGoroutine()` returns to the pre-watch baseline (asserted in tests via `goleak.VerifyNone` or a manual snapshot diff)
|
||||
- `orca node list --watch --json` behaves identically to the jobs variant
|
||||
|
||||
---
|
||||
|
||||
## Wave Ordering
|
||||
|
||||
- **Wave 1** (Phases 8-9): Networking & scheduling — mTLS handshake and internal CA (P01) is a hard prerequisite for cross-node dispatch (P02), since the dispatch endpoints are mTLS-protected. Both phases run sequentially because `parallelization.enabled=false`.
|
||||
- **Wave 2** (Phases 10-11): Security scan & streaming I/O — security scanning (P03) and `iter.Seq` streaming (P04) are independent; `parallelization.enabled=false` so they run sequentially, but either order is technically viable. P03 first keeps the security baseline in place while P04 lands the new CLI surface.
|
||||
|
||||
Phases within a wave can be parallelized if `parallelization.enabled=true`.
|
||||
For v0.2, `parallelization.enabled=false` — phases run sequentially.
|
||||
|
||||
## Versioning
|
||||
|
||||
- **Milestone type**: `feature` (all 4 phases ship features)
|
||||
- **Patch per phase**: `v0.2.1` (P01 mTLS), `v0.2.2` (P02 scheduling), `v0.2.3` (P03 security scan), `v0.2.4` (P04 iter.Seq)
|
||||
- **Final tag on COMPLETE**: `v0.3.0` (next minor per `run.md` versioning logic; per `RELEASE_POLICY.md`, every per-phase tag also produces a Gitea release)
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
# Plan: Orca v0.3 — scheduling-streaming
|
||||
|
||||
Milestone v0.3 (scheduling-streaming) — completion milestone closing the two
|
||||
work items deferred from v0.2 (iter.Seq streaming + doctor network/db). Two
|
||||
execution phases (P01, P02) followed by one final phase (P03 review + ship).
|
||||
|
||||
Branch: `phase/00-pre-execution` (cut from `milestone/v0.3-scheduling-streaming`).
|
||||
Go toolchain: `go1.25.0` (`iter` package + range-over-func are stable stdlib).
|
||||
No new `go.mod` dependencies (D-041). Source of implementation guidance:
|
||||
`.ciagent/RESEARCH_v0.3.md` (D-025..D-042).
|
||||
|
||||
---
|
||||
|
||||
## Phase P01: iter.Seq streaming for `--watch` flags
|
||||
|
||||
**Goal:** Add pull-based `iter.Seq` streaming to `orca job list` and `orca node list` behind a `--watch` flag, with table refresh (default) or streaming one-line JSON per event (`--watch --json`).
|
||||
|
||||
**Requirements:** REQ-022 (`iter.Seq` for streaming job lists, Go 1.25+), REQ-030 (`--watch` output format: table default vs streaming one-line JSON per event)
|
||||
|
||||
**Milestone:** v0.3
|
||||
**Phase tag:** v0.3.1
|
||||
**Key decisions:** D-019 (1s poll ticker), D-025 (iter.Seq on store repos), D-026 (`*model.Job`/`*model.Node` element type), D-028 (poll re-runs List, yields full snapshot), D-030 (per-event JSON streaming), D-031 (signal.NotifyContext replaces 5s timeout on watch path), D-032 (inline pull loop, no goroutine).
|
||||
|
||||
### Wave 1: Store layer iter.Seq + unit tests (vertical slice)
|
||||
|
||||
Wave 1 is independently testable: the two `Watch` methods + the migration-version-less store layer compile and run in isolation. No CLI or doctor code is touched. Running `go test ./internal/store/...` after this wave passes and exercises the `iter.Seq` contracts (yield, ctx cancellation, consumer break, no goroutine leak).
|
||||
|
||||
| Task ID | Description | Persona | Files | Must-have | Deps |
|
||||
|---------|-------------|---------|-------|-----------|------|
|
||||
| 01-01-01 | Add `JobRepo.Watch(ctx) iter.Seq[[]*model.Job]` — pull-based inline polling loop on a 1s ticker; re-runs the List `SELECT ... FROM jobs ORDER BY created_at DESC` each tick, collects ALL rows into a `[]*model.Job` slice via `scanJob`, then yields the **full snapshot as a single slice** (`yield(snapshot)`). **Immediate first yield** before the first ticker wait (G-002): the loop queries+yields on the first iteration, then `select`s on the ticker for subsequent ticks. `defer ticker.Stop()` + `rows.Close()` on every exit path (ctx.Done, yield==false, scan error). No goroutine spawned (D-032). Transient query errors are logged via `slog.Default().Warn` and the loop continues to the next tick (D-034 lite). Imports: add `"iter"` (`"time"` already present). | data-engineer | `internal/store/job_task_repo.go` | `go build ./internal/store/...` succeeds; `Watch` method exists with signature `func (r *JobRepo) Watch(ctx context.Context) iter.Seq[[]*model.Job]`; code path closes rows on ctx.Done and on `yield==false`; first yield is immediate (no `watchInterval` delay before first snapshot — G-002). | - |
|
||||
| 01-01-02 | Add `NodeRepo.Watch(ctx) iter.Seq[[]*model.Node]` — analogous to 01-01-01 but against the nodes query `SELECT id, name, address, state, joined_at, last_seen, metadata FROM nodes ORDER BY joined_at ASC`, reusing `scanNode`. Yields the full snapshot as a `[]*model.Node` slice per tick. Same inline-pull / no-goroutine / rows-close-on-all-paths / immediate-first-yield contract (G-001, G-002). | data-engineer | `internal/store/node_repo.go` | `go build ./internal/store/...` succeeds; `Watch` method exists with signature `func (r *NodeRepo) Watch(ctx context.Context) iter.Seq[[]*model.Node]`; immediate first yield. | - |
|
||||
| 01-01-03 | Add an unexported test hook for the poll interval so unit tests are deterministic (D-035). Preferred shape: an unexported package var `watchInterval = 1 * time.Second` in `internal/store` that `Watch` reads instead of a literal, overridable from `_test.go` via `watchInterval = 10 * time.Millisecond`. Both `JobRepo.Watch` and `NodeRepo.Watch` reference this var. | data-engineer | `internal/store/job_task_repo.go`, `internal/store/node_repo.go` (optionally a tiny `internal/store/watch_test_helper_test.go` if a shared helper reads cleaner) | `Watch` uses the `watchInterval` var, not a literal `1 * time.Second`; tests can set it to a small value. | 01-01-01, 01-01-02 |
|
||||
| 01-01-04 | Write store-layer unit tests for `Watch`. New/append: `internal/store/job_task_repo_test.go` and `internal/store/node_repo_test.go` (mirror). Tests: (a) `TestJobRepoWatch_YieldsSnapshots` — insert 1 job, set `watchInterval=10ms`, range over seq collecting `[]*model.Job` snapshots into a slice, insert a 2nd job from a goroutine after ~30ms, cancel ctx after ~80ms, assert at least one snapshot contains both jobs and the first snapshot contains only the first job (G-001: each yield is a complete tick snapshot). (b) `TestJobRepoWatch_ImmediateFirstYield` (G-002) — assert the first snapshot appears within <50ms even with `watchInterval=10ms` (proving first yield is not tick-gated). (c) `TestJobRepoWatch_StopsOnConsumerBreak` — range and `break` after first yield; assert the range returns (no hang) within a short deadline. (d) `TestJobRepoWatch_StopsOnCtxCancel` — cancel ctx; assert the range loop exits within ~50ms. (e) Mirror all four for `NodeRepo.Watch`. Run `go test -race ./internal/store/...`. | data-engineer | `internal/store/job_task_repo_test.go`, `internal/store/node_repo_test.go` | `go test -race ./internal/store/...` passes; all 8 Watch tests pass; `-race` reports no leaks/data races; immediate-first-yield assertion holds (G-002). | 01-01-03 |
|
||||
|
||||
### Wave 2: CLI `--watch` flag + integration
|
||||
|
||||
Wave 2 depends on Wave 1's `Watch` methods. It wires the `--watch` flag into both list commands, implements the two output modes, and adds CLI-level smoke tests. After this wave `orca job list --watch` and `orca node list --watch` are runnable end-to-end.
|
||||
|
||||
| Task ID | Description | Persona | Files | Must-have | Deps |
|
||||
|---------|-------------|---------|-------|-----------|------|
|
||||
| 01-02-01 | Add `--watch` flag to `jobListCmd` in `internal/cli/job.go`. Register `jobListCmd.Flags().BoolVar(&jobWatch, "watch", false, "stream jobs until Ctrl-C")` (package var `jobWatch bool`). In `RunE`, branch: if `!jobWatch` keep the existing 5s-timeout `List` path unchanged; if `jobWatch`, build `ctx, cancel := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM); defer cancel()` (drop the 5s timeout — D-031), then `seq := store.NewJobRepo(db).Watch(ctx)`. Imports: `os/signal`, `syscall`, `iter`. The branch structure is added in this task; the actual rendering (table vs JSON) is filled by 01-02-02 + 01-02-03. | cli-engineer | `internal/cli/job.go` | `go build ./internal/cli/...` succeeds; `orca job list --help` shows `--watch` flag; non-watch path behavior unchanged (existing tests pass); watch path compiles (rendering may be a placeholder `for range seq {}` at this step). | 01-01-01 |
|
||||
| 01-02-02 | Implement the **table** watch render in `jobListCmd` (D-029, G-001). Each yielded value is a complete `[]*model.Job` snapshot. Maintain the previous snapshot's rendered-table string (or a hash of it). On each tick, if the new rendered table differs from the previous, emit `"\033[2J\033[H"` (clear screen + home) then the table header + rows (reuse the existing table-rendering code path). Unchanged snapshots produce no output (avoids flicker). | cli-engineer | `internal/cli/job.go` | `orca job list --watch` on a temp DB: inserting a job causes a cleared-screen re-render showing the new job; no output when the snapshot is unchanged. | 01-02-01 |
|
||||
| 01-02-03 | Implement the **`--watch --json`** render in `jobListCmd` (D-030, G-001). Each yielded value is a complete `[]*model.Job` snapshot. Maintain `map[string][]byte` of last-seen compact-JSON bytes per job ID. Per tick: diff the current snapshot against the map — for each job in the snapshot, marshal compact JSON; if it differs from stored bytes (or ID unseen), print `{"event":"init","job":{...}}\n` (first sighting) or `{"event":"update","job":{...}}\n` (subsequent change). For IDs in the map but NOT in the current snapshot, print `{"event":"delete","job":{...}}\n` (G-006 DEFER: delete event now natural with snapshot-per-tick). One line per changed element per tick — matches REQ-030. | cli-engineer | `internal/cli/job.go` | `orca job list --watch --json` on a temp DB: inserting/changing a job prints one JSON line per changed job; first tick prints `"init"` lines for existing jobs; deleting a job prints `"delete"`; unchanged jobs on a tick produce no line. | 01-02-01 |
|
||||
| 01-02-04 | Add `--watch` flag + both render modes to `nodeListCmd` in `internal/cli/node.go`, mirroring 01-02-01..01-02-03. Package var `nodeWatch bool`; flag `--watch`. For the watch path bypass `engine.NodeRegistry` and call `store.NewNodeRepo(db).Watch(ctx)` directly (D-025 — keeps iter boundary in store; registry adds no value for a read-only stream). Same `signal.NotifyContext` cancellation. Table + JSON renders analogous to job (element type `[]*model.Node` snapshot per tick, event wrapper `{"event":"...","node":{...}}`). **Note (G-003):** This task modifies `internal/cli/node.go`, which P02 task 02-01-01 also modifies (removing old `dbPath`). Task 02-01-01 MUST complete first to avoid merge conflicts. | cli-engineer | `internal/cli/node.go` | `orca node list --watch` and `orca node list --watch --json` behave as specified; non-watch path unchanged. | 01-01-02, 01-02-03, 02-01-01 |
|
||||
| 01-02-05 | Add CLI-level watch tests. New files (or append if present): `internal/cli/job_test.go`, `internal/cli/node_test.go`. Smoke-level (store layer is the thorough test home): `TestJobListWatch_JSONStreaming` — temp DB, insert a job, run `jobListCmd.RunE` with `--watch --json` in a goroutine under a cancellable ctx, insert a 2nd job, capture stdout for ~200ms, assert ≥2 JSON lines appear, then cancel ctx and assert the command returns promptly. `TestJobListWatch_TableRefresh` — assert the clear-screen escape `\033[2J\033[H` appears in output on change. Mirror for nodes. Keep deterministic: small `watchInterval` via the test hook, short timeouts. Run `go test -race ./internal/cli/...`. | cli-engineer | `internal/cli/job_test.go`, `internal/cli/node_test.go` | `go test -race ./...` passes (whole repo); the 4 CLI watch smoke tests pass; no goroutine leaks under `-race`. | 01-02-02, 01-02-03, 01-02-04 |
|
||||
|
||||
### Test strategy (P01)
|
||||
|
||||
- **Store layer (thorough, deterministic):** `internal/store/job_task_repo_test.go` + `internal/store/node_repo_test.go` — three tests per repo (snapshots over time, consumer-break stops, ctx-cancel stops). Uses the `watchInterval` test hook (10ms) for speed. Runs under `go test -race ./internal/store/...`. This is where the `iter.Seq` contract is verified rigorously.
|
||||
- **CLI layer (smoke):** `internal/cli/job_test.go` + `internal/cli/node_test.go` — verify the flag is wired, JSON streaming emits one line per changed element, table mode emits the clear-screen escape on change, and the command exits promptly on ctx cancellation. Kept intentionally lightweight; deterministic via the shared `watchInterval` hook + short timeouts.
|
||||
- **Non-watch regression:** existing `job list` / `node list` tests must still pass unchanged (the 5s-timeout path is untouched).
|
||||
- **Race:** `go test -race ./...` is the gate (REQ-031 already enforces `-race` in CI).
|
||||
|
||||
### Vertical slice integrity (P01)
|
||||
|
||||
- **Wave 1** produces a runnable, testable artifact: `go build ./internal/store/...` + `go test -race ./internal/store/...`. No CLI or doctor code is modified. The `iter.Seq` contract (pull, cancel, no-leak) is fully verified at this layer.
|
||||
- **Wave 2** builds on Wave 1's `Watch` methods to deliver the user-facing `--watch` flag end-to-end. After Wave 2 an operator can demo `orca job list --watch` and `orca node list --watch --json`.
|
||||
|
||||
---
|
||||
|
||||
## Phase P02: `orca doctor` network + db full implementation
|
||||
|
||||
**Goal:** Replace the `NetworkStub` and `DBStub` placeholders with real diagnostics — peer reachability via mTLS `/healthz` probe and SQLite `PRAGMA integrity_check` + migration version — completing REQ-032.
|
||||
|
||||
**Requirements:** REQ-032 (completion: network reachability + db integrity)
|
||||
**Milestone:** v0.3
|
||||
**Phase tag:** v0.3.2
|
||||
**Key decisions:** D-027 (closure-capture handles in check constructors), D-033 (`store.MigrationVersion`), D-034 (db check opens its own `*sql.DB`), D-035 (`PRAGMA integrity_check` + migration version), D-036 (peers from `nodes` table, not in-memory registry), D-037 (`ServerName = node.Name`), D-038 (zero peers → WARN, any fail → FAIL, 3s per-probe timeout), D-039 (`dbPath` → `certpaths.DBPath()`), D-040 (delete stubs, no shims).
|
||||
|
||||
### Wave 1: Shared infra + store migration-version query (vertical slice)
|
||||
|
||||
Wave 1 breaks the would-be `doctor → cli` import cycle (D-039) and adds the public `store.MigrationVersion` query. Both are independently testable: `go test ./internal/store/... ./internal/certpaths/...` passes after this wave, and the foundation for both the db and network checks is in place.
|
||||
|
||||
| Task ID | Description | Persona | Files | Must-have | Deps |
|
||||
|---------|-------------|---------|-------|-----------|------|
|
||||
| 02-01-01 | Move `dbPath()` (the `ORCA_DB`-env-honoring path resolver) from `internal/cli` to `internal/certpaths` as `DBPath()` (D-039). `certpaths` already owns the `ORCA_HOME`-honoring `Dir()`. Add `func DBPath() string` to `internal/certpaths/certpaths.go`: honors `ORCA_DB` env override, else `filepath.Join(Dir(), "orca.db")`. Update `internal/cli/node.go` (and any other `internal/cli` caller of the old unexported `dbPath`) to call `certpaths.DBPath()`; remove the old `dbPath` from `internal/cli`. Territory note: this crosses cli-engineer territory — lead-developer adjudicates (D-039). | lead-developer | `internal/certpaths/certpaths.go`, `internal/cli/node.go` (remove old `dbPath`), any other `internal/cli/*` caller | `go build ./...` succeeds (no import cycle); `certpaths.DBPath()` exists and honors `ORCA_DB`/`ORCA_HOME`; `internal/cli` no longer defines `dbPath`; existing CLI tests pass. | - |
|
||||
| 02-01-02 | Add `store.MigrationVersion(ctx, db) (string, error)` to `internal/store/migrate.go` (D-033). SQL: `SELECT name FROM schema_migrations ORDER BY name DESC LIMIT 1`. Returns `("", nil)` on `sql.ErrNoRows` (empty/fresh db). Wraps other errors with `fmt.Errorf("query migration version: %w", err)`. Add `"context"` import if missing (likely already imported). | data-engineer | `internal/store/migrate.go` | `go build ./internal/store/...` succeeds; function is exported; `sql.ErrNoRows` maps to `("", nil)`. | - |
|
||||
| 02-01-03 | Test `MigrationVersion`. New/append `internal/store/migrate_test.go`: `TestMigrationVersion` — open a fresh test db via `store.Open` (which runs `migrate`), call `MigrationVersion`, assert it returns `0005_node_capacity.sql` (the highest current migration). Then manually `db.Exec("DELETE FROM schema_migrations")`, call again, assert `("", nil)`. Run `go test -race ./internal/store/...`. | data-engineer | `internal/store/migrate_test.go` | `go test -race ./internal/store/...` passes; both assertions (highest version, empty → `""`) hold. | 02-01-02 |
|
||||
|
||||
### Wave 2: doctor DB check + network check + CLI wiring + tests
|
||||
|
||||
Wave 2 depends on Wave 1 (`certpaths.DBPath` + `store.MigrationVersion`). It replaces both stubs with real checks, updates `All()` and the CLI subcommands, rewrites the broken stub test, and adds per-check tests. After this wave `orca doctor`, `orca doctor network`, and `orca doctor db` are fully functional.
|
||||
|
||||
| Task ID | Description | Persona | Files | Must-have | Deps |
|
||||
|---------|-------------|---------|-------|-----------|------|
|
||||
| 02-02-01 | Replace `DBStub()` with `DB()` in `internal/doctor/doctor.go` (D-027, D-034, D-035). The `Check.Run` closure: `path := certpaths.DBPath()`; `db, err := store.Open(path)` (defer `db.Close()`); `PRAGMA integrity_check` via `db.QueryRowContext(ctx, "PRAGMA integrity_check").Scan(&integrity)`; FAIL if not `"ok"` (first line of message); then `store.MigrationVersion(ctx, db)` — WARN if `""` (fresh/never-migrated), else PASS with `"... migrations up to <ver>"`. New imports: `strings`, `internal/store`, `internal/certpaths`. | data-engineer | `internal/doctor/doctor.go` | `go build ./internal/doctor/...` succeeds; `doctor.DB()` returns a `Check` with `Name=="db"`; `DBStub` still present (removed in 02-02-04 lockstep). | 02-01-01, 02-01-02 |
|
||||
| 02-02-02 | Add `probeHealthz(ctx, caPath, certPath, keyPath, serverName, addr) error` helper in `internal/doctor/doctor.go` (network-engineer territory — connection lifecycle). Builds an mTLS client via `transport.NewMTLSClient(caPath, serverName, certPath, keyPath)` (D-037: `serverName = node.Name`), `http.NewRequestWithContext(ctx, GET, "https://"+addr+"/healthz", nil)`, `client.Do(req)`, defer `resp.Body.Close()`, FAIL if status != 200. New imports: `net/http`, `time`, `internal/transport`. | network-engineer | `internal/doctor/doctor.go` | `go build ./internal/doctor/...` succeeds; `probeHealthz` exists with the specified signature; reuses `transport.NewMTLSClient` (no new TLS code — security-engineer territory respected). | 02-01-01 |
|
||||
| 02-02-03 | Replace `NetworkStub()` with `Network()` in `internal/doctor/doctor.go` (D-036, D-037, D-038). The `Check.Run` closure: `path := certpaths.DBPath()`; `db, err := store.Open(path)` (defer close); `nodes, err := store.NewNodeRepo(db).List(ctx)`; filter `state != model.NodeStateLeft` into `live`; if `len(live)==0` → `ResultWarn` ("no peers registered; network check skipped (single-node?)"). Else per-peer: `probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second)`; `probeHealthz(...)`; collect PASS/FAIL lines; aggregate — FAIL if any peer failed, PASS if all OK. `serverName = n.Name`, `caPath = certpaths.CACertPath()`, `certPath/keyPath = certpaths.ServerCertPath()/ServerKeyPath()`. New imports: `internal/model`. | network-engineer | `internal/doctor/doctor.go` | `go build ./internal/doctor/...` succeeds; `doctor.Network()` returns a `Check` with `Name=="network"`; zero-peer → WARN; per-probe 3s timeout enforced. | 02-02-02 |
|
||||
| 02-02-04 | Update `All()` in `internal/doctor/doctor.go` to use `Network()` and `DB()` instead of the stubs (D-040). **Delete** `NetworkStub` and `DBStub` (no backward-compat shims — internal only). Update `internal/cli/doctor.go`: `doctorNetworkCmd.RunE` calls `doctor.Network()` (was `NetworkStub()`); `doctorDBCmd.RunE` calls `doctor.DB()` (was `DBStub()`). Ensure per-subcommand render honors `jsonOutput` (minor enhancement, in scope). | cli-engineer | `internal/doctor/doctor.go`, `internal/cli/doctor.go` | `go build ./...` succeeds; `grep -r "NetworkStub\|DBStub" internal/` returns nothing; `orca doctor`, `orca doctor network`, `orca doctor db` run without referencing stubs. | 02-02-01, 02-02-03 |
|
||||
| 02-02-05 | Rewrite + add doctor tests in `internal/doctor/doctor_test.go`. (a) Rewrite `TestRunAllChecksWithNoCA` — set `ORCA_HOME` to a temp dir with no CA. Assert per-check by name: `cert.ca` FAIL, `cert.server` FAIL, `cert.expiry` FAIL, `cert.fingerprint` FAIL, `db` PASS (store.Open runs migrations → version 0005), `network` WARN (no peers). Remove the stale "expects WARN (stubs)" comment. (b) `TestDBCheck_IntegrityOK` — fresh db via `store.Open` in temp, run `doctor.DB().Run(ctx)`, expect PASS, message contains "0005". (c) `TestNetworkCheck_NoPeers` — fresh db, no nodes, run `doctor.Network().Run(ctx)`, expect WARN. (d) `TestNetworkCheck_PeerUnreachable` — insert a node with `Address = "127.0.0.1:1"` (nothing listening), run, expect FAIL with the peer name in the message. (e) `TestNetworkCheck_PeerReachable` (integration) — bootstrap a CA via `security.CAInit`-equivalent, generate+sign a server cert with SAN `localhost`, start an `httptest.NewUnstartedServer` with TLS + `ClientAuth=RequireAndVerifyClientCert` (mirror `security/integration_test.go` pattern), insert a node row with `Address = ts.Listener.Addr().String()` and `Name = "localhost"`, set `ORCA_HOME`, run `doctor.Network().Run(ctx)`, expect PASS. Run `go test -race ./internal/doctor/...`. | network-engineer (network tests), data-engineer (db test), cli-engineer (All() rewrite test) | `internal/doctor/doctor_test.go` | `go test -race ./internal/doctor/...` passes; all 5 test cases pass; the stale stub assertion is gone. | 02-02-04 |
|
||||
|
||||
### Test strategy (P02)
|
||||
|
||||
- **Store layer:** `internal/store/migrate_test.go` — `MigrationVersion` returns highest applied migration (`0005_node_capacity.sql`) and `""` on empty. (`-race`.)
|
||||
- **Doctor db check:** `TestDBCheck_IntegrityOK` — fresh db → PASS with version in message. (Optional brittle `TestDBCheck_Corrupt` may be added if a reliable corruption method is found; otherwise rely on the integrity-string parsing logic via the PASS/FAIL branch coverage.)
|
||||
- **Doctor network check:** `TestNetworkCheck_NoPeers` (WARN), `TestNetworkCheck_PeerUnreachable` (FAIL, peer name in message), `TestNetworkCheck_PeerReachable` (integration: real mTLS `httptest` server → PASS). The reachable test reuses the proven `TestEndToEndMTLS` pattern from `security/integration_test.go`.
|
||||
- **Doctor `All()` regression:** rewritten `TestRunAllChecksWithNoCA` asserts per-check results (certs FAIL, db PASS, network WARN) — no more global "hasWarn" stub assertion.
|
||||
- **Race:** `go test -race ./...` is the gate.
|
||||
|
||||
### Vertical slice integrity (P02)
|
||||
|
||||
- **Wave 1** produces a runnable, testable artifact: `certpaths.DBPath()` (cycle broken) + `store.MigrationVersion` (tested). `go test -race ./internal/store/... ./internal/certpaths/...` passes. No doctor code depends on the stubs being changed yet.
|
||||
- **Wave 2** builds on Wave 1 to deliver the real `DB()` and `Network()` checks, wires the CLI, and replaces the stub tests. After Wave 2 an operator can demo `orca doctor` showing real PASS/WARN/FAIL for db and network.
|
||||
|
||||
---
|
||||
|
||||
## Phase P03 (final): review + ship + audit
|
||||
|
||||
**Goal:** Review the v0.3 milestone for completeness against REQ-022/030/032, audit the codebase for leftover stubs/dead code, run the full CI gate (`go test -race ./...`, `gosec`, `govulncheck`, `gitleaks`), tag the milestone release, and ship.
|
||||
|
||||
**Requirements:** REQ-022 (verify complete), REQ-030 (verify complete), REQ-032 (verify complete)
|
||||
**Milestone:** v0.3
|
||||
**Phase tag:** v0.3.3 (= milestone release; target milestone tag `v0.4.0` per ROADMAP next-minor rule)
|
||||
|
||||
### Wave 1: Review + audit + ship (single wave)
|
||||
|
||||
| Task ID | Description | Persona | Files | Must-have | Deps |
|
||||
|---------|-------------|---------|-------|-----------|------|
|
||||
| 03-01-01 | Verify REQ coverage: confirm REQ-022 (iter.Seq streaming job lists), REQ-030 (--watch table/JSON modes), REQ-032 (doctor network + db) are fully implemented. Update `REQUIREMENTS.md` status for REQ-022/030/032 from Pending/Partial → **Complete**. Cross-check against the plan's must-have criteria. | lead-developer | `.ciagent/REQUIREMENTS.md` | All three REQs marked Complete with phase references; no remaining "stub" or "Pending" status for v0.3 scope. | P01, P02 complete |
|
||||
| 03-01-02 | Codebase audit: `grep -r "NetworkStub\|DBStub" internal/` returns nothing; `grep -r "TODO\|FIXME" internal/` reviewed (no v0.3 leftovers); confirm no `dbPath` duplication remains in `internal/cli`; confirm `iter` import is used (no unused imports); run `go vet ./...`. | lead-developer | (read-only audit; edits only if cleanup needed) | `go vet ./...` clean; no stub references; no leftover TODOs for v0.3 scope. | 03-01-01 |
|
||||
| 03-01-03 | Full CI gate: `go build ./...`, `go test -race ./...`, `gosec` (vs baseline JSON), `govulncheck ./...` (offline mode per REQ-027), `gitleaks` (vs baseline per REQ-029). Fix any new findings. | lead-developer | (fixes if needed) | All gates green; no new gosec findings beyond baseline; govulncheck exit 0; gitleaks clean vs baseline. | 03-01-02 |
|
||||
| 03-01-04 | Tag + ship: per `.ciagent/RELEASE_POLICY.md`, tag `v0.3.3` (phase tag) and the milestone tag (next-minor per ROADMAP). Produce Gitea release. Update `ROADMAP.md` v0.3 section to mark P01/P02/P03 complete. | lead-developer | `.ciagent/ROADMAP.md` | `v0.3.3` tag exists; Gitea release published; ROADMAP v0.3 checkboxes updated. | 03-01-03 |
|
||||
|
||||
### Test strategy (P03)
|
||||
|
||||
- No new tests; this phase is review + audit + release.
|
||||
- The gate is the existing test suite + security scans all passing under CI.
|
||||
|
||||
---
|
||||
|
||||
## Cross-phase notes
|
||||
|
||||
- **Phase ordering / parallelism (D-042, revised by G-003):** P01 Wave 1 and P02 Wave 1 may run in parallel (file-disjoint: store repos vs certpaths+migrate). **P02 Wave 1 (02-01-01) MUST complete before P01 Wave 2 (01-02-04)** because both modify `internal/cli/node.go` (P01 adds `--watch`, P02 removes old `dbPath`). P01 Wave 2 tasks 01-02-01..01-02-03 (job.go only) are not blocked by P02. Recommended order: P01 W1 + P02 W1 in parallel → P02 W1 02-01-01 completes → P01 W2 (job.go tasks) + P02 W2 in parallel → P01 W2 01-02-04 (node.go) after 02-01-01. P03 is strictly sequential after both phases complete.
|
||||
- **No new dependencies (D-041):** `iter` (P01) and the mTLS health probe (P02) use stdlib + existing internal packages only. The 4 direct `go.mod` deps (cobra, hcl/v2, modernc/sqlite, uuid) are unchanged.
|
||||
- **Decisions logged during planning (new, this plan):**
|
||||
- **D-043** — `watchInterval` test hook: an unexported package var in `internal/store` (default `1 * time.Second`) referenced by both `Watch` methods, overridable from `_test.go`. Avoids a public `WatchWithInterval` constructor that would leak test-only API into production. Confidence 0.90.
|
||||
- **D-044** — P01 Wave 1 / Wave 2 split: Wave 1 = store-layer `Watch` methods + tests (data-engineer only, fully isolated); Wave 2 = CLI `--watch` flag + renders + CLI tests (cli-engineer). This keeps the `iter.Seq` contract verifiable without the CLI and matches persona territories. Confidence 0.93.
|
||||
- **D-045** — P02 Wave 1 / Wave 2 split: Wave 1 = `certpaths.DBPath()` relocation + `store.MigrationVersion` + tests (breaks the import cycle, data-engineer + lead-developer); Wave 2 = real `DB()`/`Network()` checks + CLI wiring + doctor tests. Wave 1 is the unblock for both checks. Confidence 0.91.
|
||||
- **D-046** — `--watch --json` event wrapper shape: `{"event":"update","job":{...}}` / `{"event":"init","job":{...}}` (and `node` analog). `"init"` on first sighting of an ID, `"update"` on subsequent change. Unchanged IDs on a tick emit nothing. Confidence 0.80 (matches D-030's per-event interpretation of REQ-030).
|
||||
|
||||
## Grill amendments (binding ACCEPT verdicts applied)
|
||||
|
||||
Three binding changes from `.ciagent/GRILL_v0.3.md` have been applied to this plan:
|
||||
|
||||
- **G-001 [CRITICAL]** — `Watch` element type changed from `iter.Seq[*model.Job]` (per-row) to `iter.Seq[[]*model.Job]` (full snapshot per tick). Each tick yields the complete snapshot as a single slice. This makes table render mode correct (clear-screen + re-render needs full snapshot) and enables natural `"delete"` events in JSON mode. Applied to tasks 01-01-01, 01-01-02, 01-02-02, 01-02-03, 01-02-04, 01-01-04.
|
||||
- **G-002 [CRITICAL]** — `Watch` must yield immediately on the first iteration, then `select` on the ticker for subsequent ticks. Prevents a 1s blank-screen UX bug in production (tests with 10ms interval missed this). Applied to tasks 01-01-01, 01-01-02; new test `TestWatch_ImmediateFirstYield` added to 01-01-04.
|
||||
- **G-003 [HIGH]** — D-042's "file-disjoint" claim corrected: both P01 (01-02-04) and P02 (02-01-01) modify `internal/cli/node.go`. P02 Wave 1 task 02-01-01 is now a dependency of P01 Wave 2 task 01-02-04. Cross-phase ordering updated.
|
||||
|
||||
DEFER items (G-004, G-006, G-007, G-009, G-012) are noted in the grill report and will be addressed during execution.
|
||||
|
||||
## Summary
|
||||
|
||||
- **Phases:** 3 (P01, P02 execution; P03 final review/ship)
|
||||
- **Waves:** P01 = 2 waves (4 + 5 tasks); P02 = 2 waves (3 + 5 tasks); P03 = 1 wave (4 tasks). Total = 5 waves.
|
||||
- **Tasks:** P01 = 9, P02 = 8, P03 = 4. Total = 21 tasks.
|
||||
- **Grill amendments:** G-001 (snapshot-per-tick), G-002 (immediate first yield), G-003 (serialize P02 W1 → P01 W2 on node.go). 3 ACCEPT verdicts applied.
|
||||
- **New planning decisions:** D-043 (watchInterval test hook), D-044 (P01 wave split), D-045 (P02 wave split), D-046 (JSON event wrapper shape).
|
||||
- **Requirements closed:** REQ-022, REQ-030 (P01); REQ-032 (P02, completion).
|
||||
- **No new go.mod dependencies.** No source code written in this plan — implementation begins at P01 Wave 1.
|
||||
+1
-98
@@ -1,11 +1,6 @@
|
||||
# Project: Orca
|
||||
|
||||
## What This Is
|
||||
|
||||
A minimalist, offline-first, CLI-first orchestration engine inspired by HashiCorp Nomad, prioritizing stability, security, and simplicity over feature richness. Single-binary distribution, no container runtime, no cloud dependencies, no K8s-level complexity.
|
||||
|
||||
## Vision
|
||||
|
||||
A minimalist, offline-first, CLI-first orchestration engine inspired by HashiCorp Nomad, prioritizing stability, security, and simplicity over feature richness.
|
||||
|
||||
## Objective
|
||||
@@ -40,104 +35,12 @@ Build a lightweight system to manage and execute workloads across a set of nodes
|
||||
| D-008 | Container runtime? | **Direct process execution (no container runtime) for v0.1** | Avoids the Docker/container dependency. Pure process management. | 0.85 |
|
||||
| D-009 | Configuration file location? | **`~/.orca/config.hcl` and `/etc/orca/orca.hcl`** | Standard XDG-style paths. | 0.90 |
|
||||
| D-010 | Logging format? | **Structured JSON via `log/slog`** | Native Go 1.21+ slog, no external dependency. | 0.95 |
|
||||
| D-011 | v0.2 mTLS cert authority model? | **Internal CA with CSR join** | One node bootstraps a local CA; peers generate CSRs and submit them to the CA for signing. CA cert is the trust anchor. More secure than self-signed per-node (single trust root) without the operational complexity of an external PKI. | 0.92 |
|
||||
| D-012 | v0.2 CA bootstrap & cert distribution? | **Operator-mediated, fingerprint-verified** | Bootstrap node writes `~/.orca/ca.crt` and `~/.orca/ca.key` (mode 0600). Operator copies `ca.crt` to peers; peers verify by SHA-256 fingerprint at `orca node join --ca-fingerprint <sha256>`. No automated secret distribution. | 0.85 |
|
||||
| D-013 | v0.2 cert validity & rotation? | **Server certs 90 days, CA cert 10 years, rotate 30 days before expiry** | Server certs are short-lived (compromise window small); CA is long-lived (manual rotation is expensive). `orca cert renew` reissues server certs automatically. | 0.90 |
|
||||
| D-014 | v0.2 mTLS handshake timing? | **Eager — at `orca node join` time** | Fail fast on bad certs, misconfigurations, or CA mismatches. Lazy handshake would let stale configs run until first request, complicating debugging. | 0.88 |
|
||||
| D-015 | v0.2 minimum TLS version & cipher suites? | **TLS 1.3 only; AEAD cipher allowlist** | MinVersion=tls.VersionTLS13, CipherSuites limited to TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256, TLS_AES_128_GCM_SHA256. No TLS 1.2 fallback. | 0.92 |
|
||||
| D-016 | v0.2 security-scanning placement? | **`validate` pipeline of `.coreci.yml`, gates merges to main** | `gosec` baseline JSON checked into repo; new findings fail the build. `govulncheck ./...` exit-on-known. Pre-commit hook with `gitleaks` is opt-in (developer machine). | 0.85 |
|
||||
| D-017 | v0.2 `iter.Seq` API surface? | **`orca job list --watch` and `orca node list --watch`** | Pull-based `iter.Seq[Job]` / `iter.Seq[Node]`; cancellation via `context.Context`; `signal.NotifyContext` on ctrl-c. Backpressure is implicit (consumer-driven). | 0.90 |
|
||||
| D-018 | v0.2 multi-node scheduling algorithm? | **Bin-packing by available CPU/memory, FIFO within a node** | Simple, deterministic, matches D-004 minimalism. Cross-node dispatch via ConnectRPC `orca.v1.Dispatch` service. Retry on transient failures with exponential backoff. | 0.85 |
|
||||
|
||||
## Out of Scope
|
||||
- Full-blown Kubernetes-compatible API.
|
||||
- Complex cloud-provider integrations.
|
||||
- GUI-based management consoles.
|
||||
- Multi-node scheduling.
|
||||
- Container runtime integration.
|
||||
- Service mesh / sidecar injection.
|
||||
- Auto-scaling / horizontal pod autoscaler.
|
||||
- External PKI / Let's Encrypt / cert transparency logs.
|
||||
- gRPC framework dependency (ConnectRPC in `config.json` frameworks but
|
||||
not in `go.mod`; v0.2 uses stdlib `net/http` with h2c for
|
||||
`orca.v1.Dispatch` — see ARCHITECTURE.md AD-014).
|
||||
|
||||
## v0.2 Scope Summary
|
||||
|
||||
v0.2 is a focused 4-phase milestone that turns Orca from a single-node
|
||||
process executor into a small cluster engine with strong transport
|
||||
security and richer I/O. The 4 phases are:
|
||||
|
||||
- **P01 — mTLS handshake + internal CA with CSR join.** Internal CA, CSR
|
||||
join, eager handshake at `node join`, TLS 1.3 + AEAD allowlist.
|
||||
See ARCHITECTURE.md Flow 1 + Flow 2.
|
||||
- **P02 — Multi-node scheduling & job dispatch.** Best-fit bin-packing by
|
||||
CPU/memory, FIFO within a node, `orca.v1.Dispatch` over mTLS. See
|
||||
ARCHITECTURE.md Flow 3.
|
||||
- **P03 — `gosec` + `govulncheck` + `gitleaks` in CI.** `gosec` baseline
|
||||
JSON in repo, `govulncheck ./...` in `validate` pipeline, `gitleaks`
|
||||
in pre-commit (opt-in).
|
||||
- **P04 — `iter.Seq` streaming for `--watch` flags.** Go 1.25+ range-over-func
|
||||
semantics, `context.Context` cancellation, `signal.NotifyContext` on
|
||||
ctrl-c. See ARCHITECTURE.md Flow 4.
|
||||
|
||||
The vision ("minimalist, offline-first, CLI-first orchestration engine")
|
||||
is unchanged. v0.2 is a hardening + small-cluster extension, not a
|
||||
direction change.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
The 18 D-series decisions (D-001..D-018) are recorded in the "Clarified
|
||||
Decisions" table above. The 10 v0.1 decisions (D-001..D-010) are stable
|
||||
and unchanged in v0.2. The 8 v0.2 decisions (D-011..D-018) were
|
||||
auto-resolved under full autonomy and are summarized here:
|
||||
|
||||
- **D-011: Internal CA with CSR join** (vs. self-signed per-node or SPIFFE).
|
||||
Single trust root, no external PKI, CSR workflow.
|
||||
- **D-012: Operator-mediated CA cert distribution with fingerprint verify**
|
||||
(no automated secret distribution — matches offline-first principle).
|
||||
- **D-013: 90d server certs, 10y CA cert, 30d pre-expiry rotation.**
|
||||
- **D-014: Eager mTLS handshake at `orca node join` time** (fail fast).
|
||||
- **D-015: TLS 1.3 only, AEAD cipher allowlist** (no TLS 1.2 fallback).
|
||||
- **D-016: `gosec`+`govulncheck` in `validate` pipeline of `.coreci.yml`**
|
||||
(gates merges to main). `gitleaks` in pre-commit (opt-in).
|
||||
- **D-017: `iter.Seq` for `orca job list --watch` and `orca node list --watch`**
|
||||
(pull-based, ctx cancellation, ctrl-c via `signal.NotifyContext`).
|
||||
- **D-018: Bin-packing by CPU/memory with FIFO within node; JSON-over-HTTP
|
||||
orca.v1.Dispatch for cross-node** (no ConnectRPC dep).
|
||||
|
||||
## v0.3 Clarified Decisions (D-series, full autonomy)
|
||||
|
||||
v0.3 is a lean 2-execution-phase milestone completing the streaming and
|
||||
doctor work deferred from v0.2. The 6 v0.3 decisions (D-019..D-024)
|
||||
were auto-resolved under full autonomy:
|
||||
|
||||
| ID | Question | Decision | Rationale | Confidence |
|
||||
|----|----------|----------|-----------|------------|
|
||||
| D-019 | Watch refresh mechanism? | **Poll-based, 1s ticker** | Simpler than event channel; no daemon coupling for CLI; matches offline-first. | 0.90 |
|
||||
| D-020 | Watch output format (REQ-030)? | **Table by default; `--watch --json` streams one-line JSON per event** | Consistent with D-005 `--json` convention; serves humans + AI agents. | 0.92 |
|
||||
| D-021 | Doctor network check scope? | **Probe configured peer addresses via mTLS `/healthz` handshake; PASS/WARN/FAIL per peer** | Reuses existing transport client; read-only. | 0.85 |
|
||||
| D-022 | Doctor db check scope? | **`PRAGMA integrity_check` + migration version query** | Already specced in ARCHITECTURE.md §5; minimal surface. | 0.95 |
|
||||
| D-023 | iter.Seq cancellation? | **`signal.NotifyContext` on SIGINT/SIGTERM** | Per D-017 + ARCHITECTURE Flow 4. | 0.95 |
|
||||
| D-024 | `--watch` applies to job list only, or node list too? | **Both `orca job list --watch` and `orca node list --watch`** | Per ARCHITECTURE.md CLI layer + D-017. | 0.92 |
|
||||
|
||||
## v0.3 Scope Summary
|
||||
|
||||
v0.3 is a focused 2-execution-phase milestone completing the work
|
||||
deferred from v0.2 that was NOT already shipped in P08-P10. A codebase
|
||||
audit during re-init SPECIFY confirmed that REQ-014, REQ-027, REQ-028,
|
||||
REQ-029, REQ-031, REQ-037, REQ-039, REQ-040 all shipped in P08-P10
|
||||
despite stale REQUIREMENTS.md marking them Pending. The remaining work:
|
||||
|
||||
- **P01 — `iter.Seq` streaming for `--watch` flags.** Go 1.25+
|
||||
range-over-func semantics, pull-based `iter.Seq[Job]` /
|
||||
`iter.Seq[Node]`, `context.Context` cancellation,
|
||||
`signal.NotifyContext` on ctrl-c. Applies to both `orca job list
|
||||
--watch` and `orca node list --watch`. Covers REQ-022, REQ-030.
|
||||
- **P02 — `orca doctor` network + db full implementation.** Replaces
|
||||
the P01 stubs (`NetworkStub`, `DBStub`) with real checks: peer
|
||||
reachability via mTLS `/healthz` probe; SQLite `PRAGMA
|
||||
integrity_check` + migration version. Covers REQ-032 (completion).
|
||||
|
||||
The vision ("minimalist, offline-first, CLI-first orchestration
|
||||
engine") is unchanged. v0.3 is a completion milestone, not a direction
|
||||
change.
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
---
|
||||
description: CIAgent release and shipping policy — applies to v0.2+ and all subsequent milestones.
|
||||
---
|
||||
|
||||
# Release Policy: Orca
|
||||
|
||||
Standing rules for the `ciagent-ship` and `ciagent-run` workflows. These apply to **v0.2+ and every future milestone** of Orca.
|
||||
|
||||
## Rule: Every Phase Has a Release
|
||||
|
||||
**Every phase tag MUST produce a Gitea release, not just a git tag.**
|
||||
|
||||
- A `git tag` alone is a pointer, not a release. Releases carry the built artifact (tarball) and notes.
|
||||
- For each `vX.Y.Z` phase tag, `ciagent-ship` must invoke `scripts/release.sh vX.Y.Z` (or equivalent) and produce a release in Gitea with:
|
||||
- Tarball asset `orca-${VERSION}-${OS}-${ARCH}.tar.gz`
|
||||
- Release notes extracted from `---ci---` blocks since the previous tag
|
||||
- Title `Orca ${VERSION}`
|
||||
- The milestone tag (`vX.(Y+1).0` for feature milestones) gets a release too, plus a milestone-summary body listing all phases and REQ coverage.
|
||||
|
||||
## Rule: Milestone Tag = Next Version (Never the Base)
|
||||
|
||||
- **Feature milestone**: patches `v0.5.1`…`v0.5.N` → milestone tag is `v0.(Y+1).0` (NOT `v0.Y.0`).
|
||||
- **Major milestone**: minors `v0.Z.0` → milestone tag is `v1.0.0`.
|
||||
- **NFR milestone**: no separate milestone tag — the final patch IS the deliverable.
|
||||
- Tags must be strictly greater than all existing tags on the same `major.minor` line.
|
||||
|
||||
## Rule: One Tag, One Release, One Push
|
||||
|
||||
For each ship, the sequence is:
|
||||
1. `git tag -a vX.Y.Z -m "..."`
|
||||
2. `scripts/release.sh vX.Y.Z` (builds, packages, creates Gitea release with tarball)
|
||||
3. `git push origin <branch> --tags`
|
||||
|
||||
The release step is NOT optional. Skipping the release is a ship failure.
|
||||
|
||||
## Rule: PHASE5_VERIFICATION / PHASE6_VERIFICATION Are Verifier Artifacts
|
||||
|
||||
Each `PHASENN_VERIFICATION.md` in `.ciagent/` is the verifier's report for that phase. These are committed alongside the verification commit and remain in `.ciagent/` as historical evidence for the milestone. They are referenced by the milestone release notes.
|
||||
|
||||
## Rule: PHASE##_VERIFICATION.md Naming
|
||||
|
||||
Phase verification reports are committed as `.ciagent/PHASE##_VERIFICATION.md` (zero-padded, e.g. `PHASE5_VERIFICATION.md`, `PHASE6_VERIFICATION.md`) and are part of the ship record.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
Tags are cheap. Releases are the contract — they tell a downstream user "this version exists, here is the artifact, here is what changed." Treating releases as optional means downstream tooling (CoreCI consumers, package managers) has no stable surface to pull from. Every ship creates a release. No exceptions.
|
||||
+27
-79
@@ -1,82 +1,30 @@
|
||||
# Requirements: Orca
|
||||
|
||||
The canonical requirements table. Each row carries the REQ-ID, the
|
||||
milestone it belongs to, the requirement summary, priority, the phase
|
||||
that addresses it, and the current status. This single table is the
|
||||
source of truth — superseded any per-milestone status tables in
|
||||
earlier versions of this file.
|
||||
## Milestone v0.1: Foundation
|
||||
|
||||
| ID | Requirement | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-001 | Go 1.25+ toolchain support | High | v0.1 P01 | **Complete** |
|
||||
| REQ-002 | CLI-first interface for all operations (single binary) | High | v0.1 P01 | **Complete** |
|
||||
| REQ-003 | Offline-first operational mode (no cloud deps) | High | v0.1 | **Complete** |
|
||||
| REQ-004 | Basic task deployment (single-node process execution) | Medium | v0.1 P03 | **Complete** (single-node); multi-node dispatch in v0.2 P02 |
|
||||
| REQ-005 | Local state storage via modernc/sqlite (CGO-free) | Medium | v0.1 P02 | **Complete** |
|
||||
| REQ-006 | Security-first audit logging via `log/slog` | High | v0.1 P04 | **Complete** |
|
||||
| REQ-007 | CoreCI full release flow integration via `.coreci.yml` | High | v0.1 P06 | **Complete** (per-phase releases) |
|
||||
| REQ-008 | Structured JSON logging (slog) | High | v0.1 P05 | **Complete** |
|
||||
| REQ-009 | HCL/YAML job spec parsing | Medium | v0.1 P03 | **Complete** |
|
||||
| REQ-010 | `--json` output flag for machine consumption | High | v0.1 P01 | **Complete** |
|
||||
| REQ-011 | mTLS for inter-node communication | Medium | **v0.2 P01** | **Complete** (P01 shipped v0.2.1) |
|
||||
| REQ-012 | `~/.orca/config.hcl` and `/etc/orca/orca.hcl` config locations | Low | v0.1 P01 | **Complete** (CLI uses `~/.orca/` + `ORCA_DB` env) |
|
||||
| REQ-013 | Pre-push git hook triggers CoreCI on every push | High | v0.1 P01 | **Complete** |
|
||||
| REQ-014 | `gosec` + `govulncheck` in CI pipeline | High | v0.2 P03 | **Complete** (P10 shipped v0.2.3) |
|
||||
| REQ-015 | MIT LICENSE | Low | v0.1 P01 | **Complete** |
|
||||
| REQ-016 | README.md with quickstart | Medium | v0.1 P01 | **Complete** |
|
||||
| REQ-017 | `context.Context` propagation in all I/O | High | v0.1 | **Complete** |
|
||||
| REQ-018 | Error wrapping with `fmt.Errorf("...: %w", err)` | High | v0.1 | **Complete** |
|
||||
| REQ-019 | Cobra CLI framework | High | v0.1 P01 | **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-022 | `iter.Seq` for streaming job lists (Go 1.25+) | Low | **v0.3 P01** | **Complete** (v0.3 P01 shipped v0.3.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-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-026 | Trusted-CA fingerprint pinned in config; daemon refuses to start on mismatch | High | **v0.2 P01** | **Complete** (P01 shipped v0.2.1) |
|
||||
| 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-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-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-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-035 | `orca cert show` redacts private key material from default and `--json` output | High | **v0.2 P01** | **Complete** (P01 shipped v0.2.1) |
|
||||
| REQ-036 | Server cert SAN validation: SAN entries (DNS + IP) populated at sign-time; refuses to sign a CSR without them | High | **v0.2 P01** | **Complete** (P01 shipped v0.2.1) |
|
||||
| REQ-037 | `X-Orca-Idempotency-Key` header on cross-node POST; dispatcher retries only when header is present | Medium | v0.2 P02 | **Complete** (P09 shipped v0.2.2; `internal/transport/idempotency.go`) |
|
||||
| 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-040 | `.golangci.yml` unified lint config superseding per-tool invocations | Low | v0.2 P03 | **Complete** (P10 shipped v0.2.3) |
|
||||
|
||||
## v0.1 Milestone Summary
|
||||
|
||||
**Status: Complete** — all 6 phases shipped (P00–P06) plus P07 backfill,
|
||||
4-layer verification passed at every phase, tagged `v0.2.0` per
|
||||
`run.md` versioning logic (next-minor after all feature-patches
|
||||
v0.1.1..v0.1.7 ship).
|
||||
|
||||
**Coverage**: 21/24 v0.1-declared requirements complete by v0.1 ship;
|
||||
the 3 deferred (REQ-011, REQ-014, REQ-022, REQ-023) all moved to v0.2.
|
||||
Plus REQ-025..REQ-040 (16 net-new) added by v0.2 IDEATE stage.
|
||||
|
||||
## v0.2 Milestone Summary
|
||||
|
||||
**Status: Functionally Complete (pending merge to main)** — P08 (mTLS),
|
||||
P09 (scheduling), P10 (security scan) all shipped to the
|
||||
`milestone/v0.2-networking-observability-security` branch as v0.2.1,
|
||||
v0.2.2, v0.2.3. The milestone branch has NOT been merged to main yet.
|
||||
REQ-022/030 (iter.Seq streaming) and REQ-032 (doctor network/db) were
|
||||
deferred to v0.3.
|
||||
|
||||
## v0.3 Milestone Summary
|
||||
|
||||
**Status: Complete** — P01 (iter.Seq streaming, v0.3.1) and P02 (doctor
|
||||
network+db, v0.3.2) both shipped. REQ-022, REQ-030, REQ-032 all complete.
|
||||
Re-init SPECIFY audit confirmed all other v0.2-deferred REQs (014, 027,
|
||||
028, 029, 031, 037, 039, 040) already shipped in P08-P10.
|
||||
|
||||
## Deferred to v0.4
|
||||
|
||||
- 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.
|
||||
| ID | Requirement | Priority | Status |
|
||||
|----|-------------|----------|--------|
|
||||
| REQ-001 | Go 1.25+ toolchain support | High | Pending |
|
||||
| REQ-002 | CLI-first interface for all operations (single binary) | High | Pending |
|
||||
| REQ-003 | Offline-first operational mode (no cloud deps) | High | Pending |
|
||||
| REQ-004 | Basic task deployment (single-node process execution) | Medium | Pending |
|
||||
| REQ-005 | Local state storage via modernc/sqlite (CGO-free) | Medium | Pending |
|
||||
| REQ-006 | Security-first audit logging via `log/slog` | High | Pending |
|
||||
| REQ-007 | CoreCI full release flow integration via `.coreci.yml` | High | Pending |
|
||||
| REQ-008 | Structured JSON logging (slog) | High | Pending |
|
||||
| REQ-009 | HCL/YAML job spec parsing | Medium | Pending |
|
||||
| REQ-010 | `--json` output flag for machine consumption | High | Pending |
|
||||
| REQ-011 | mTLS for inter-node communication | Medium | Deferred (v0.2) |
|
||||
| REQ-012 | `~/.orca/config.hcl` and `/etc/orca/orca.hcl` config locations | Low | Pending |
|
||||
| REQ-013 | Pre-push git hook triggers CoreCI on every push | High | Pending |
|
||||
| REQ-014 | `gosec` + `govulncheck` in CI pipeline | High | Pending |
|
||||
| REQ-015 | MIT LICENSE | Low | Pending |
|
||||
| REQ-016 | README.md with quickstart | Medium | Pending |
|
||||
| REQ-017 | `context.Context` propagation in all I/O | High | Pending |
|
||||
| REQ-018 | Error wrapping with `fmt.Errorf("...: %w", err)` | High | Pending |
|
||||
| REQ-019 | Cobra CLI framework | High | Pending |
|
||||
| REQ-020 | HCL parser integration (`hashicorp/hcl`) | Medium | Pending |
|
||||
| REQ-021 | `os/exec` with `WaitDelay` (Go 1.25+) | Medium | Pending |
|
||||
| REQ-022 | `iter.Seq` for streaming job lists (Go 1.25+) | Low | Pending |
|
||||
| REQ-023 | Self-signed mTLS cert generation | Medium | Pending |
|
||||
| REQ-024 | `Makefile` with standard targets | High | Pending |
|
||||
|
||||
@@ -1,506 +0,0 @@
|
||||
# Research: Orca v0.3 — scheduling-streaming
|
||||
|
||||
Phase: 0 (research) for milestone v0.3 (scheduling-streaming).
|
||||
Branch: `phase/00-pre-execution` (cut from `milestone/v0.3-scheduling-streaming`).
|
||||
Go toolchain: `go1.25.0` (confirmed via `go version`; `go.mod` declares `go 1.25.0`).
|
||||
|
||||
This document provides concrete, file-level implementation guidance for the
|
||||
two v0.3 execution phases:
|
||||
|
||||
- **P01** — `iter.Seq` streaming for `--watch` flags (REQ-022, REQ-030)
|
||||
- **P02** — `orca doctor` network + db full implementation (REQ-032 completion)
|
||||
|
||||
All assumptions are logged as decisions (D-025..D-038) with confidence scores.
|
||||
Full autonomy mode — no items flagged for human validation.
|
||||
|
||||
---
|
||||
|
||||
## Codebase Audit Summary
|
||||
|
||||
### Current state (commit ba5ffd7 + phase docs)
|
||||
|
||||
| Area | File | Key finding |
|
||||
|------|------|-------------|
|
||||
| CLI `job list` | `internal/cli/job.go:112-143` | `jobListCmd.RunE` calls `store.NewJobRepo(db).List(ctx)`, prints a fixed-width table; `--json` via `printJSON(jobs)`. No `--watch` flag exists. |
|
||||
| CLI `node list` | `internal/cli/node.go:155-186` | `nodeListCmd.RunE` calls `registry.List(ctx)` → `repo.List(ctx)`. Table + `--json`. No `--watch` flag. |
|
||||
| CLI root | `internal/cli/root.go` | `jsonOutput` is a package-level `bool` set by `--json` persistent flag. `printJSON` uses `json.NewEncoder` with 2-space indent. |
|
||||
| Job repo | `internal/store/job_task_repo.go` | `JobRepo.List(ctx) ([]*model.Job, error)` — single-shot query, closes rows. `scanJob` helper is reusable. |
|
||||
| Node repo | `internal/store/node_repo.go` | `NodeRepo.List(ctx) ([]*model.Node, error)`. `scanNode` helper is reusable. `scanner` interface defined here (`Scan(dest ...any) error`) — shared by `*sql.Row` and `*sql.Rows`. |
|
||||
| Store open | `internal/store/store.go` | `store.Open(path)` opens with `?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)` and runs `migrate(db)`. |
|
||||
| Migrations | `internal/store/migrate.go` | `migrate` is unexported, runs at `Open` time. `schema_migrations` table tracks applied migrations by filename. Migrations are embedded via `//go:embed migrations/*.sql`. No public API to query migration version. |
|
||||
| Migrations on disk | `internal/store/migrations/` | `0001_nodes.sql`, `0002_jobs_tasks.sql`, `0003_audit_log.sql`, `0004_certs.sql`, `0005_node_capacity.sql`. Highest = 0005. |
|
||||
| Doctor | `internal/doctor/doctor.go` | `NetworkStub()` and `DBStub()` return WARN stubs. `All()` aggregates 6 checks. `Run(ctx)` iterates checks. `Check.Run` signature: `func(ctx context.Context) (Result, string)`. `Result` is `PASS|WARN|FAIL`. No DB or transport imports — cert-only. |
|
||||
| Doctor CLI | `internal/cli/doctor.go` | `doctorNetworkCmd`/`doctorDBCmd` call `doctor.NetworkStub()`/`doctor.DBStub()` directly. |
|
||||
| Doctor tests | `internal/doctor/doctor_test.go` | Two tests: `TestRunAllChecksWithNoCA` (expects FAIL + WARN for stubs), `TestRunWithCAAndServerCert` (cert checks PASS). Uses `t.Setenv("ORCA_HOME", dir)`. **The "expects WARN" assertion will break when stubs become real checks** — must be updated in P02. |
|
||||
| Transport mTLS client | `internal/transport/mtls.go` | `NewMTLSClient(caPath, serverName, certPath, keyPath)` builds an `http.Client` with a TLS-1.3-only config from `security.ClientTLSConfig`. `MTLSClient.Do(req)`. `DialContext` for low-level TLS dial. |
|
||||
| Transport dispatch client | `internal/transport/dispatch.go` | `NewDispatchClient(caPath, serverName, peerAddr)` wraps `MTLSClient`. `PeerAddr` is `http://` or `https://`. `Submit`/`Status` POST to `/orca.v1.Dispatch/*`. No `/healthz` GET helper. |
|
||||
| Daemon health | `internal/daemon/health.go` | `handleHealthz` → 200 `{"status":"alive"}`. `handleReadyz` → 200/503 with db ping. Mounted at `mux.HandleFunc("/healthz", ...)` in `server.go:104`. |
|
||||
| Daemon TLS | `internal/daemon/tls.go` | `StartMTLS(state)` sets `httpServer.TLSConfig` with `ClientAuth = RequireAndVerifyClientCert`. The daemon **requires client certs** in mTLS mode. |
|
||||
| Peer registry | `internal/engine/peer.go` | `PeerRegistry` is **in-memory only** (`map[string]*Peer` under `sync.RWMutex`). `NewPeerRegistry()` returns empty. `Peer` has `NodeID, Address, ServerName, CAPath, LastSeen, Capacity`. **Not persisted to SQLite.** |
|
||||
| Peer registry usage | `internal/cli/job.go:70`, `internal/cli/daemon.go:47` | Both create a **fresh empty** `NewPeerRegistry()` per process. No code ever calls `peers.Add(...)`. The registry is currently a structural placeholder. |
|
||||
| Node registry | `internal/engine/registry.go` | `NodeRegistry` wraps `store.NodeRepo` + `Audit`. `List(ctx)` → `repo.List(ctx)`. Persisted to `nodes` table. |
|
||||
| Node model | `internal/model/node.go` | `Node{ID, Name, Address, State, JoinedAt, LastSeen, Metadata}`. **No `ServerName` or `CAPath` field** — `model.Node` differs from `engine.Peer`. |
|
||||
| Cert paths | `internal/certpaths/certpaths.go` | `Dir()` honors `ORCA_HOME`; `CACertPath()`, `ServerCertPath()`, `ServerKeyPath()`. |
|
||||
| Security client TLS | `internal/security/tls_config.go:106` | `ClientTLSConfig(caPath, serverName, certPath, keyPath)` — TLS 1.3 only, AEAD allowlist, `RootCAs` = single CA. Both-or-neither for cert/key. |
|
||||
| Go version | `go.mod` + `go version` | `go 1.25.0` — `iter` package and range-over-func are stable stdlib. |
|
||||
| Deps | `go.mod` | cobra, hcl/v2, modernc/sqlite, uuid. **No new deps needed for v0.3.** `iter` is stdlib. |
|
||||
|
||||
### Critical gap analysis
|
||||
|
||||
1. **`PeerRegistry` is non-persistent and always empty at CLI time.** The
|
||||
doctor network check cannot rely on it — there is no code path that populates
|
||||
it. The `nodes` table IS persisted and has `Address`, but lacks the
|
||||
`ServerName`/`CAPath` needed for an mTLS probe. **Resolution: doctor network
|
||||
reads the `nodes` table via `NodeRepo.List`, and derives `ServerName` +
|
||||
`CAPath` from local config (`certpaths.CACertPath()` + node name/addr).**
|
||||
See D-029.
|
||||
|
||||
2. **`doctor.Run` / `Check.Run` do not plumb a `*sql.DB` or transport client.**
|
||||
The cert checks are filesystem-only. P02 must extend the check constructors
|
||||
to accept a DB handle and (for network) a transport client factory. The
|
||||
`Check.Run` signature (`func(ctx) (Result, string)`) is preserved by
|
||||
closure-capturing the handles in the constructor. See D-027, D-031.
|
||||
|
||||
3. **No public migration-version query.** `migrate()` is unexported and writes
|
||||
to `schema_migrations(name, applied_at)`. P02 adds a public
|
||||
`store.MigrationVersion(ctx, db)` (or method on a repo) that selects the max
|
||||
applied migration name. See D-033.
|
||||
|
||||
4. **Doctor test `TestRunAllChecksWithNoCA` asserts a WARN from stubs.** This
|
||||
will break when stubs become real (the db check will PASS with a fresh test
|
||||
DB, and the network check will WARN/FAIL on zero peers). Must be updated.
|
||||
See D-036.
|
||||
|
||||
---
|
||||
|
||||
## P01: iter.Seq Streaming for `--watch` Flags
|
||||
|
||||
Covers REQ-022 (`iter.Seq` for streaming job lists), REQ-030 (`--watch` output
|
||||
format: table default vs streaming one-line JSON per event).
|
||||
|
||||
### Decisions
|
||||
|
||||
| ID | Decision | Confidence |
|
||||
|----|----------|------------|
|
||||
| **D-025** | `iter.Seq` lives on the store repos, not the engine registry. `JobRepo.Watch(ctx) iter.Seq[*model.Job]` and `NodeRepo.Watch(ctx) iter.Seq[*model.Node]`. Rationale: repos already own the `*sql.DB` and the `scanJob`/`scanNode` helpers; engine.Registry.List just delegates to repo. Keeping Watch in the store layer matches the data-engineer territory and avoids a new engine→store iter dependency. | 0.90 |
|
||||
| **D-026** | Element type is `*model.Job` / `*model.Node` (pointer), matching the existing `[]*model.Job` return of `List`. This keeps `printJSON` and table rendering identical between one-shot and watch paths. | 0.88 |
|
||||
| **D-027** | The `Check.Run` signature in `doctor` is unchanged; P02 captures DB/transport handles in closure at constructor time (`Network(db)`, `DB(db)`). This is the established pattern (cert checks already closure-capture `certpaths`). | 0.92 |
|
||||
| **D-028** | Watch refresh = poll-based 1s ticker (per D-019). No event channel, no daemon coupling. Each tick re-runs the existing `List` query and yields the **full current snapshot** (one element per row). The CLI dedupes by detecting snapshot equality before re-rendering (see D-030). Rationale: simpler than NOTIFY/LISTEN, no daemon dependency, matches offline-first. | 0.90 |
|
||||
| **D-029** | `--watch` output: default = re-print the table on every changed snapshot (clear screen via ANSI `\033[2J\033[H` then table); `--watch --json` = one compact JSON line **per snapshot** (an array on each line, OR one line per element — see D-030). The CLI tracks the previous snapshot's hash to avoid spamming identical frames. | 0.85 |
|
||||
| **D-030** | `--watch --json` emits **one JSON object per element per tick where the element changed**, i.e. streaming one-line JSON per event (per REQ-030 wording). Implementation: on each tick, for each element, if its JSON bytes differ from the previous snapshot's bytes for that ID, print `{"event":"update","job":{...}}\n`. On first tick, print all as `{"event":"init",...}`. This is the most useful for AI agents tailing the stream. Confidence lower because REQ-030 is ambiguous between "array per tick" and "object per event"; the per-event interpretation matches "streaming one-line JSON per event" literally. | 0.72 |
|
||||
| **D-031** | Cancellation: `signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)` at the CLI command layer, **replacing** the current `context.WithTimeout(cmd.Context(), 5*time.Second)` for the watch path only. The non-watch `list` path keeps its 5s timeout. The `iter.Seq` receives this ctx and stops yielding on `ctx.Done()`. | 0.93 |
|
||||
| **D-032** | The `iter.Seq` implementation **must not leak goroutines**: the polling loop runs **inline in the yield callback's caller goroutine** (the `range` loop), not a separate goroutine. `for range seq { ... }` drives the pull; inside `Watch`, we loop `for { select { <-ticker.C: query+yield each; <-ctx.Done(): return } }` and call `yield(item)` directly. When `yield` returns false (consumer broke the loop), we stop and return. **No goroutine is spawned by Watch.** This is the cleanest Go 1.25 iter pattern and avoids leak surface entirely. | 0.95 |
|
||||
|
||||
### Implementation approach — store layer
|
||||
|
||||
**File: `internal/store/job_task_repo.go`** — add method:
|
||||
|
||||
```go
|
||||
// Watch yields the current snapshot of jobs on a 1-second ticker until
|
||||
// ctx is cancelled or the consumer stops pulling (yield returns false).
|
||||
// It does not spawn a goroutine; the polling loop runs in the caller's
|
||||
// goroutine via the range-over-func pull protocol.
|
||||
//
|
||||
// Each tick re-runs the List query and yields one *model.Job per row.
|
||||
// The caller is responsible for deduping across ticks if desired.
|
||||
func (r *JobRepo) Watch(ctx context.Context) iter.Seq[*model.Job] {
|
||||
return func(yield func(*model.Job) bool) {
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
// Reuse the existing List query + scanJob helper.
|
||||
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 {
|
||||
// Surfacing errors from inside iter.Seq is awkward; the
|
||||
// CLI layer cannot receive a returned error. Log via slog
|
||||
// (the repo doesn't hold a logger today — see D-034) and
|
||||
// continue to next tick rather than terminating the
|
||||
// stream. A transient DB blip should not kill the watch.
|
||||
continue
|
||||
}
|
||||
for rows.Next() {
|
||||
j, err := scanJob(rows)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return
|
||||
}
|
||||
if !yield(j) {
|
||||
rows.Close()
|
||||
return // consumer stopped
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**File: `internal/store/node_repo.go`** — add analogous `Watch`:
|
||||
|
||||
```go
|
||||
func (r *NodeRepo) Watch(ctx context.Context) iter.Seq[*model.Node] {
|
||||
return func(yield func(*model.Node) bool) {
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT id, name, address, state, joined_at, last_seen, metadata
|
||||
FROM nodes ORDER BY joined_at ASC`)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for rows.Next() {
|
||||
n, err := scanNode(rows)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return
|
||||
}
|
||||
if !yield(n) {
|
||||
rows.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Imports: add `"iter"` and `"time"` (time already present in both files). The
|
||||
`iter` package is imported only for the return type; `yield` is the callback.
|
||||
|
||||
**Note on the existing `scanner` interface:** `scanJob`/`scanNode` accept the
|
||||
`scanner` interface (`Scan(dest ...any) error`) satisfied by both `*sql.Row`
|
||||
and `*sql.Rows`, so they are directly reusable in `Watch` — no refactor needed.
|
||||
|
||||
### Implementation approach — CLI layer
|
||||
|
||||
**File: `internal/cli/job.go`** — modify `jobListCmd`:
|
||||
|
||||
1. Add a package var `jobWatch bool` and register `jobListCmd.Flags().BoolVar(&jobWatch, "watch", false, "stream jobs until Ctrl-C")`.
|
||||
2. In `RunE`, branch on `jobWatch`:
|
||||
- If `!jobWatch`: keep the existing 5s-timeout `List` path.
|
||||
- If `jobWatch`:
|
||||
- `ctx, cancel := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM); defer cancel()` (drop the 5s timeout).
|
||||
- `seq := store.NewJobRepo(db).Watch(ctx)`
|
||||
- If `jsonOutput`: stream one-line JSON per event (D-030). Maintain a `map[string][]byte` of last-seen JSON per job ID. On each yielded job, marshal compact JSON; if it differs from the stored bytes (or ID unseen), print `{"event":"update","job":{...}}\n` and update the map.
|
||||
- Else (table): on each tick, after collecting the full snapshot, compare against the previous snapshot (by hashing the rendered table string or by comparing the slice of `[]*model.Job` via reflect/cmp). If changed, emit `"\033[2J\033[H"` (clear) then the table header + rows. This gives a "top-like" refresh.
|
||||
|
||||
Because `iter.Seq` does not return an error, the watch path swallows
|
||||
per-tick query errors inside `Watch` (D-034). The CLI relies on `ctx.Done()`
|
||||
for termination.
|
||||
|
||||
**File: `internal/cli/node.go`** — analogous change to `nodeListCmd`:
|
||||
- Add `nodeWatch bool`, register `--watch` flag.
|
||||
- Branch in `RunE`; `seq := store.NewNodeRepo(db).Watch(ctx)` (open a fresh `openDB()` for the watch path; `registry.List` is not used for watch — go straight to the repo to get the iter).
|
||||
|
||||
**Note:** `nodeListCmd` currently goes through `nodeRegistry()` which wraps
|
||||
`NodeRepo` in `engine.NodeRegistry`. For watch, bypass the registry and use
|
||||
`store.NewNodeRepo(db).Watch(ctx)` directly — the registry adds no value for a
|
||||
read-only stream and would require a `Watch` passthrough method. This keeps the
|
||||
iter boundary clean in the store layer (D-025).
|
||||
|
||||
### Pitfalls & mitigations (P01)
|
||||
|
||||
| Pitfall | Mitigation |
|
||||
|---------|------------|
|
||||
| **Goroutine leak** if Watch spawned a goroutine. | It doesn't — the polling loop is inline in the pull callback (D-032). `defer ticker.Stop()` + `rows.Close()` on every exit path. |
|
||||
| **Rows cursor held open across yield** — if `yield` blocks (e.g. slow consumer), the `*sql.Rows` stays open and holds a SQLite read lock. | Yield is called per-row inside the `rows.Next()` loop; the consumer (`range`) is fast (prints to stdout). For safety, close rows immediately after the loop or on `yield==false`. The 1s tick cadence bounds how long a cursor is held. WAL mode (set in `store.Open`) allows concurrent reads, so this does not block writers. |
|
||||
| **No error channel from iter.Seq** — a transient DB error is invisible to the CLI. | Log inside Watch via a package-level slog default (`slog.Default().Warn(...)`) since the repo has no logger field today (D-034: add an optional `logger *slog.Logger` to JobRepo/NodeRepo, defaulting to `slog.Default()` in the constructors — minimal change). Continue to next tick rather than terminating. |
|
||||
| **ctx cancellation mid-query** — `QueryContext` returns an error; `rows.Next()` returns false. | Handled: the `select` on `ctx.Done()` returns before the next tick; an in-flight query is cancelled by the ctx. |
|
||||
| **Rapid re-render flicker** in table mode. | Clear-screen + full re-render on changed snapshot only (hash compare). Unchanged snapshots produce no output. |
|
||||
| **`--watch` + `--json` interleaving with slog stderr.** | slog writes to stderr; CLI output to stdout — no interleaving on stdout. Safe. |
|
||||
| **Test determinism** — ticker is 1s, tests would be slow/flaky. | Provide a test-only constructor `WatchWithInterval(ctx, d time.Duration)` OR make the interval a field on the repo set via an unexported option. Preferred: an unexported `watchInterval` package var defaulting to 1s, overridable from `internal/store` tests. See D-035. |
|
||||
| **Signal handling clobbers root signal handler.** | `signal.NotifyContext` with `os.Interrupt` returns a fresh ctx; the root `cobra.Command` does not install its own SIGINT handler, so no conflict. `defer cancel()` restores default behavior on exit. |
|
||||
|
||||
### Test strategy (P01)
|
||||
|
||||
**`internal/store/job_task_repo_test.go` (new file or appended):**
|
||||
- `TestJobRepoWatch_YieldsSnapshots`: insert 1 job, call `Watch` with a 10ms interval (via test hook), range over `seq` collecting into a slice, insert a 2nd job from a goroutine after 30ms, cancel ctx after 80ms, assert the 2nd job appeared in the collected slice. Use `context.WithTimeout` for cancellation.
|
||||
- `TestJobRepoWatch_StopsOnConsumerBreak`: range over `seq` and `break` after the first yield; assert the function returns (no hang) within a short deadline. This validates the `yield==false` path.
|
||||
- `TestJobRepoWatch_StopsOnCtxCancel`: cancel ctx; assert the range loop exits within 50ms.
|
||||
- `TestNodeRepoWatch_*`: mirror the above for nodes.
|
||||
|
||||
**`internal/cli/job_test.go` (new) / `node_test.go` (new) — if CLI tests exist; otherwise add:**
|
||||
- `TestJobListWatch_JSONStreaming`: spin a temp DB, insert a job, invoke the `jobListCmd.RunE` with `--watch --json` in a goroutine, insert a 2nd job, capture stdout for ~200ms, assert two JSON lines appear. Cancel via ctx.
|
||||
- `TestJobListWatch_TableRefresh`: assert clear-screen escape + table re-render on change.
|
||||
- These CLI tests are harder to make deterministic; prefer testing the store-layer Watch thoroughly and keep CLI watch tests to a smoke-level "produces output, exits on ctx.Done".
|
||||
|
||||
### Dependency check (P01)
|
||||
|
||||
- `iter` — Go 1.25 stdlib (`go.mod` declares `go 1.25.0`). ✅ no new dep.
|
||||
- `time`, `context`, `os/signal`, `syscall`, `encoding/json` — stdlib. ✅
|
||||
- No new go.mod dependencies required.
|
||||
|
||||
---
|
||||
|
||||
## P02: `orca doctor` network + db full implementation
|
||||
|
||||
Covers REQ-032 completion (network + db checks replacing `NetworkStub`/`DBStub`).
|
||||
|
||||
### Decisions
|
||||
|
||||
| ID | Decision | Confidence |
|
||||
|----|----------|------------|
|
||||
| **D-033** | Add a public `store.MigrationVersion(ctx, db) (string, error)` function (in `migrate.go` or a new `internal/store/migrate_query.go`) that returns the **highest applied migration filename** from `schema_migrations`. SQL: `SELECT name FROM schema_migrations ORDER BY name DESC LIMIT 1`. This is the source of truth for "migration version" — it reflects what `migrate()` actually applied. Returns `("", nil)` if no migrations applied (fresh empty table) and `("", sql.ErrNoRows)` is treated as empty. | 0.90 |
|
||||
| **D-034** | The doctor DB check opens its own `*sql.DB` via `store.Open(dbPath())` (reusing the CLI's `dbPath`) inside the check constructor closure, rather than receiving a shared handle. Rationale: doctor should be runnable whether the daemon is up or down; `store.Open` uses WAL so a concurrent daemon is fine. The check `defer db.Close()`. This avoids threading a `*sql.DB` through `doctor.Run`/`All()` and keeps the `Check.Run` signature stable. | 0.86 |
|
||||
| **D-035** | The doctor DB check runs `PRAGMA integrity_check` via `db.QueryRow("PRAGMA integrity_check")`. SQLite returns a single row with a TEXT value: `"ok"` on success, or a multi-line error description on failure. PASS if the value is `"ok"`; FAIL otherwise (with the first line of the message). Plus query the migration version (D-033); WARN if `schema_migrations` is empty (fresh/never-migrated db) — this is suspicious but not corrupt. | 0.92 |
|
||||
| **D-036** | Doctor network check sources peer addresses from the **`nodes` table** (`NodeRepo.List`), NOT from `engine.PeerRegistry` (which is in-memory and always empty at CLI time — see gap #1). For each node with `state != 'left'`, probe `https://<address>/healthz` over mTLS. | 0.88 |
|
||||
| **D-037** | For each peer, the network check builds an mTLS client via `transport.NewMTLSClient(certpaths.CACertPath(), serverName, certpaths.ServerCertPath(), certpaths.ServerKeyPath())`. `serverName` is derived as the node's `Name` (the SAN on a peer's server cert is its node name, per `security.GenerateCSR(nodeName, sans)` — confirmed in `integration_test.go:41` `GenerateCSR("test-server", ...)`. If the SAN uses a different value the probe will fail handshake, which is itself a useful diagnostic). `CAPath` is the local `ca.crt` (all peers share one CA per D-011). This presents the local node's client cert, satisfying the daemon's `RequireAndVerifyClientCert`. | 0.80 |
|
||||
| **D-038** | Network check result semantics: **zero peers registered** → `WARN` ("no peers registered; network check skipped") — not FAIL, because a single-node install legitimately has no peers. **A peer unreachable / handshake failed** → `FAIL` for that peer, aggregated to a single `network` check result that is FAIL if any peer failed, PASS if all peers probed OK, WARN if zero peers. Each peer's per-line outcome is folded into the message string (e.g. `PASS — 2/2 peers reachable; FAIL — peer node-b (host:port): tls handshake error`). | 0.85 |
|
||||
|
||||
### Implementation approach — db check
|
||||
|
||||
**File: `internal/store/migrate.go`** — add:
|
||||
|
||||
```go
|
||||
// MigrationVersion returns the filename of the most recently applied
|
||||
// migration, or "" if no migrations have been applied (empty db or
|
||||
// schema_migrations table missing). Used by `orca doctor db`.
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
(Add `"context"` import — already imported in migrate.go.)
|
||||
|
||||
**File: `internal/doctor/doctor.go`** — replace `DBStub()` with `DB()`:
|
||||
|
||||
```go
|
||||
// DB checks SQLite integrity and migration version (REQ-032).
|
||||
// It opens its own *sql.DB so it can run whether or not the daemon is up.
|
||||
func DB() Check {
|
||||
return Check{
|
||||
Name: "db",
|
||||
Description: "SQLite PRAGMA integrity_check + migration version",
|
||||
Run: func(ctx context.Context) (Result, string) {
|
||||
path := dbPath() // dbPath currently lives in internal/cli; see D-039
|
||||
db, err := store.Open(path)
|
||||
if err != nil {
|
||||
return ResultFail, fmt.Sprintf("open %s: %v", path, err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// 1. integrity_check
|
||||
var integrity string
|
||||
if err := db.QueryRowContext(ctx, "PRAGMA integrity_check").Scan(&integrity); err != nil {
|
||||
return ResultFail, fmt.Sprintf("integrity_check query: %v", err)
|
||||
}
|
||||
if integrity != "ok" {
|
||||
first := strings.SplitN(integrity, "\n", 2)[0]
|
||||
return ResultFail, fmt.Sprintf("integrity_check: %s", first)
|
||||
}
|
||||
|
||||
// 2. migration version
|
||||
ver, err := store.MigrationVersion(ctx, db)
|
||||
if err != nil {
|
||||
return ResultFail, fmt.Sprintf("migration version: %v", err)
|
||||
}
|
||||
if ver == "" {
|
||||
return ResultWarn, "integrity ok; no migrations applied (fresh db?)"
|
||||
}
|
||||
return ResultPass, fmt.Sprintf("integrity ok; migrations up to %s", ver)
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**D-039 (assumption, confidence 0.78):** `dbPath()` currently lives in
|
||||
`internal/cli/node.go` and is unexported. The `doctor` package cannot import
|
||||
`internal/cli` (would create a cycle: `cli` imports `doctor`). **Resolution:**
|
||||
move `dbPath()` (and the `ORCA_DB` env logic) into `certpaths` (rename the
|
||||
package conceptually, or add a sibling `internal/paths` package) OR duplicate
|
||||
the ~5-line `dbPath` function inside `internal/doctor`. The cleanest is to add
|
||||
`func DBPath() string` to `internal/certpaths/certpaths.go` (it already owns
|
||||
`Dir()` honoring `ORCA_HOME`) and have both `cli` and `doctor` call it.
|
||||
`cli.dbPath` becomes a thin wrapper or is replaced. This is a small refactor
|
||||
within P02's scope. Logged as D-039, confidence 0.78 (territory overlap between
|
||||
cli-engineer and the doctor package; lead-developer adjudicates).
|
||||
|
||||
### Implementation approach — network check
|
||||
|
||||
**File: `internal/doctor/doctor.go`** — replace `NetworkStub()` with `Network()`:
|
||||
|
||||
```go
|
||||
// Network probes each registered peer's /healthz over mTLS (REQ-032).
|
||||
// Peers are sourced from the nodes table. Zero peers => WARN (single-node
|
||||
// install is legitimate). Any peer unreachable => FAIL.
|
||||
func Network() Check {
|
||||
return Check{
|
||||
Name: "network",
|
||||
Description: "peer reachability via mTLS /healthz probe",
|
||||
Run: func(ctx context.Context) (Result, string) {
|
||||
// 1. Load registered nodes (skip 'left').
|
||||
path := certpaths.DBPath() // same resolution as D-039
|
||||
db, err := store.Open(path)
|
||||
if err != nil {
|
||||
return ResultFail, fmt.Sprintf("open db for node list: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
nodes, err := store.NewNodeRepo(db).List(ctx)
|
||||
if err != nil {
|
||||
return ResultFail, fmt.Sprintf("list nodes: %v", err)
|
||||
}
|
||||
// filter out left nodes
|
||||
var live []*model.Node
|
||||
for _, n := range nodes {
|
||||
if n.State != model.NodeStateLeft {
|
||||
live = append(live, n)
|
||||
}
|
||||
}
|
||||
if len(live) == 0 {
|
||||
return ResultWarn, "no peers registered; network check skipped (single-node?)"
|
||||
}
|
||||
|
||||
caPath := certpaths.CACertPath()
|
||||
certPath := certpaths.ServerCertPath()
|
||||
keyPath := certpaths.ServerKeyPath()
|
||||
// Short per-probe timeout so one slow peer doesn't stall doctor.
|
||||
var lines []string
|
||||
overall := ResultPass
|
||||
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 {
|
||||
overall = ResultFail
|
||||
lines = append(lines, fmt.Sprintf("FAIL %s (%s): %v", n.Name, n.Address, err))
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf("PASS %s (%s)", n.Name, n.Address))
|
||||
}
|
||||
}
|
||||
if overall == ResultPass {
|
||||
return ResultPass, fmt.Sprintf("%d/%d peers reachable: %s", len(live), len(live), strings.Join(lines, "; "))
|
||||
}
|
||||
return ResultFail, strings.Join(lines, "; ")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// probeHealthz does a GET https://addr/healthz over mTLS.
|
||||
func probeHealthz(ctx context.Context, caPath, certPath, keyPath, serverName, addr string) error {
|
||||
client, err := transport.NewMTLSClient(caPath, serverName, certPath, keyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build mTLS client: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://"+addr+"/healthz", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build 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 status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
New imports in `doctor.go`: `net/http`, `strings`, `time`, `git.cloudinit.dev/coreci/orca/internal/store`, `git.cloudinit.dev/coreci/orca/internal/transport`, `git.cloudinit.dev/coreci/orca/internal/model`.
|
||||
|
||||
**File: `internal/doctor/doctor.go`** — update `All()`:
|
||||
```go
|
||||
func All() []Check {
|
||||
return []Check{
|
||||
CertCA(), CertServer(), CertExpiry(), CertFingerprint(),
|
||||
Network(), // was NetworkStub()
|
||||
DB(), // was DBStub()
|
||||
}
|
||||
}
|
||||
```
|
||||
Keep `NetworkStub`/`DBStub` exported functions for one release as thin
|
||||
wrappers that call the new ones? **No** — delete them; the CLI doctor.go
|
||||
references them and must be updated in lockstep (they are internal). See D-040.
|
||||
|
||||
**File: `internal/cli/doctor.go`** — update `doctorNetworkCmd` and `doctorDBCmd`:
|
||||
```go
|
||||
doctorNetworkCmd.RunE: c := doctor.Network() // was doctor.NetworkStub()
|
||||
doctorDBCmd.RunE: c := doctor.DB() // was doctor.DBStub()
|
||||
```
|
||||
Also: the per-subcommand render should honor `jsonOutput` (currently it only
|
||||
prints text). Minor enhancement, in scope.
|
||||
|
||||
### Pitfalls & mitigations (P02)
|
||||
|
||||
| Pitfall | Mitigation |
|
||||
|---------|------------|
|
||||
| **`model.Node` has no `ServerName`/`CAPath`** — mTLS needs `ServerName` to match the cert SAN. | Derive `ServerName = node.Name` (D-037). This assumes peer server certs are issued with SAN = node name, which matches `GenerateCSR(nodeName, sans)`. If a deployment uses DNS SANs instead, the probe fails — which is itself a diagnostic. Document this assumption in the check message. |
|
||||
| **No local client cert/key** — doctor can't present a client cert if `server.crt`/`server.key` are missing. | The check should FAIL with a clear message if `certpaths.ServerCertPath()` doesn't exist, BEFORE attempting probes. Reuse `os.Stat`. This also covers the single-node-never-joined case. |
|
||||
| **Daemon down** — peer's `/healthz` unreachable. | Per-probe 3s timeout (D-038). Surfaces as FAIL per peer with the dial/handshake error in the message. Doctor is designed to run with daemon up or down, so this is expected behavior, not a crash. |
|
||||
| **Self-probe** — the local node is likely in the `nodes` table too. Doctor will probe itself over mTLS. This is fine (validates the local daemon's mTLS stack) but requires the local daemon to be running. If the daemon is down, the self-probe fails → FAIL, which is the correct signal. | Document; no special-casing. |
|
||||
| **DB file doesn't exist** — `store.Open` creates the dir + file + runs migrations (so a missing db becomes a fresh empty db). The db check would then PASS with "no migrations applied" WARN. | This is acceptable: `store.Open` is idempotent. If the operator expected an existing db, the WARN surfaces the surprise. Could additionally `os.Stat` the path before `Open` and WARN if it didn't exist pre-open — optional refinement. |
|
||||
| **`PRAGMA integrity_check` can return multiple rows** in rare cases (when there are multiple errors). `QueryRow` only reads the first. | For `integrity_check`, a single row containing `"ok"` or the first error is the documented SQLite behavior for the common case. Use `QueryRow` + `Scan`; if it's not `"ok"`, that's already a FAIL. Acceptable. |
|
||||
| **Doctor test `TestRunAllChecksWithNoCA`** asserts WARN from stubs. | Update the test: with real checks, a no-CA scenario yields FAIL on cert.ca (unchanged) AND FAIL on db (open succeeds, integrity ok, but no migrations if fresh — actually WARN) AND WARN on network (no peers). Rewrite assertions to check each check by name rather than "hasWarn globally". See test strategy. |
|
||||
| **Import cycle:** `doctor` → `cli` (for `dbPath`). | Resolved by D-039: move `dbPath` to `certpaths` (or a new `internal/paths`); both `cli` and `doctor` import it. No cycle. |
|
||||
| **`store.Open` runs migrations on every open** — doctor opening the db to run integrity_check would also (re)migrate. | `migrate()` is idempotent (checks `schema_migrations` per name). Re-opening is safe; no-op if already migrated. Acceptable. |
|
||||
|
||||
### Test strategy (P02)
|
||||
|
||||
**`internal/store/migrate_test.go` (new or appended):**
|
||||
- `TestMigrationVersion`: open a fresh test db (which runs migrate), call `MigrationVersion`, assert it returns `0005_node_capacity.sql` (the highest current migration). Then manually delete all rows from `schema_migrations`, assert returns `""` with nil error.
|
||||
|
||||
**`internal/doctor/doctor_test.go` (update):**
|
||||
- Update `TestRunAllChecksWithNoCA`: set `ORCA_HOME` to temp dir (no CA). Expect: cert.ca FAIL, cert.server FAIL, cert.expiry FAIL, cert.fingerprint FAIL, **db WARN** (fresh db, no migrations — actually `store.Open` runs migrations, so db will PASS with version 0005; adjust: db PASS), **network WARN** (no peers). Rewrite to assert per-check rather than "hasWarn/hasFail globally". Remove the stale "expected WARN (stubs)" comment.
|
||||
- New `TestDBCheck_IntegrityOK`: open a fresh db via `store.Open` in temp, run `doctor.DB().Run(ctx)`, expect PASS and message contains "0005".
|
||||
- New `TestDBCheck_Corrupt`: open db, manually `db.Exec("DROP TABLE jobs")` to introduce inconsistency, run integrity_check — but `integrity_check` mostly detects corruption, not missing tables. More reliable: write garbage to the db file via raw file write, then open — `store.Open` may fail at Ping. Assert FAIL. (This test is brittle; prefer a unit test on the integrity string-parsing logic with a stub.)
|
||||
- New `TestNetworkCheck_NoPeers`: fresh db, no nodes, run `doctor.Network().Run(ctx)`, expect WARN.
|
||||
- New `TestNetworkCheck_PeerReachable`: this is an integration test — bootstrap a CA (`security.CAInit`), generate+sign a server cert with SAN `localhost`, start an `httptest.NewUnstartedServer` with `ts.TLS = serverTLS` and `ClientAuth = RequireAndVerifyClientCert` (mirror `security/integration_test.go:88-108`), generate+sign a client cert, insert a node row with `Address = ts.Listener.Addr().String()` and `Name = "localhost"`, set `ORCA_HOME` to the temp dir holding the CA + client cert, run `doctor.Network().Run(ctx)`, expect PASS. This reuses the proven pattern from `TestEndToEndMTLS`.
|
||||
- New `TestNetworkCheck_PeerUnreachable`: insert a node with `Address = "127.0.0.1:1"` (nothing listening), run, expect FAIL with the peer name in the message.
|
||||
|
||||
### Dependency check (P02)
|
||||
|
||||
- `net/http`, `crypto/tls` (via transport), `strings`, `time`, `context` — stdlib. ✅
|
||||
- `internal/transport`, `internal/store`, `internal/model`, `internal/certpaths` — existing internal packages. ✅
|
||||
- No new go.mod dependencies required.
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting decisions
|
||||
|
||||
| ID | Decision | Confidence |
|
||||
|----|----------|------------|
|
||||
| **D-039** | Move `dbPath()` (the `ORCA_DB`-honoring path resolver) from `internal/cli` to `internal/certpaths` as `DBPath()`, to break the would-be `doctor→cli` import cycle. Both `cli` and `doctor` then import `certpaths`. `certpaths` already owns the `ORCA_HOME`-honoring `Dir()`. Territory: this is a shared infra concern; `lead-developer` adjudicates. | 0.78 |
|
||||
| **D-040** | Delete `doctor.NetworkStub` and `doctor.DBStub` (no backward-compat shims). They are internal, referenced only by `internal/cli/doctor.go` which is updated in the same phase. Keeping dead stub code violates `no-redundant-implementations`. | 0.95 |
|
||||
| **D-041** | No new go.mod dependencies for v0.3. `iter` (P01) and mTLS health probe (P02) use stdlib + existing internal packages only. The 4 existing direct deps (cobra, hcl, modernc/sqlite, uuid) are unchanged. | 0.97 |
|
||||
| **D-042** | Phase ordering: P01 (iter.Seq) and P02 (doctor) are **independent** — no file is modified by both (P01 touches cli/job.go, cli/node.go, store repos; P02 touches doctor.go, cli/doctor.go, store/migrate.go, certpaths). They can be developed in either order or in parallel. Recommend P01 first only because it's the lower-risk change. | 0.85 |
|
||||
|
||||
---
|
||||
|
||||
## Summary of assumptions logged
|
||||
|
||||
All assumptions below are logged as decisions with confidence scores; none are
|
||||
flagged for human validation (full autonomy). Low-confidence (<0.80) items that
|
||||
warrant normal decision-flow attention:
|
||||
|
||||
- **D-030** (0.72): `--watch --json` emits one JSON object per changed element
|
||||
per tick (vs. one array per tick). REQ-030 wording is ambiguous; this
|
||||
interpretation matches "streaming one-line JSON per event" literally.
|
||||
- **D-037** (0.80): peer `ServerName` = node `Name` (SAN convention).
|
||||
- **D-039** (0.78): `dbPath` relocation to `certpaths` — territory overlap.
|
||||
|
||||
These three are escalated through the normal decision flow (DecisionEngine) per
|
||||
the researcher protocol, NOT flagged for human validation.
|
||||
+8
-69
@@ -1,71 +1,10 @@
|
||||
# Roadmap: Orca
|
||||
|
||||
## Milestone v0.1: Foundation — **COMPLETE**
|
||||
|
||||
- [x] Phase 0: Project Initialization & Specification
|
||||
- [x] Phase 1: Core CLI Skeleton & Command Parsing
|
||||
- [x] Phase 2: Basic Node Management (Join/Leave)
|
||||
- [x] Phase 3: Simple Task Execution Engine
|
||||
- [x] Phase 4: Local State Persistence
|
||||
- [x] Phase 5: Basic Health Checking
|
||||
- [x] Phase 6: CoreCI Full Release Flow
|
||||
|
||||
**Tagged `v0.2.0`** (next-minor per feature-milestone promotion rule).
|
||||
|
||||
## Deferred to v0.2 (out of scope for v0.1)
|
||||
|
||||
- Multi-node scheduling (D-004 decision: single-node only in v0.1)
|
||||
- mTLS for inter-node communication (REQ-011, REQ-023)
|
||||
- `gosec` + `govulncheck` in CI pipeline (REQ-014)
|
||||
- `iter.Seq` streaming job lists (REQ-022)
|
||||
- Frontend / devops personas (no web UI; CoreCI handles release)
|
||||
|
||||
## Milestone v0.2: Networking, Observability, Security Hardening — **COMPLETE (merged to main via v0.3)**
|
||||
|
||||
Scope: extend v0.1 with secure cross-node transport, multi-node scheduling,
|
||||
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 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 11: `iter.Seq` streaming job/node lists (Wave 2) — **completed in v0.3 P01** (shipped v0.3.1)
|
||||
|
||||
**Milestone tag**: `v0.4.0` (shipped — v0.2 work merged to main via v0.3 milestone).
|
||||
|
||||
Per-phase tags: `v0.2.1` (P01), `v0.2.2` (P02), `v0.2.3` (P03) — all shipped.
|
||||
|
||||
## Milestone v0.3: Scheduling & Streaming Completion — **COMPLETE**
|
||||
|
||||
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
|
||||
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:
|
||||
|
||||
- [x] Phase 0: Pre-execution (specify → clarify → research → plan → grill) — shipped v0.3.0
|
||||
- [x] Phase 1: `iter.Seq` streaming for `--watch` flags (REQ-022, REQ-030) — shipped v0.3.1
|
||||
- [x] Phase 2: `orca doctor` network + db full implementation (REQ-032 completion) — shipped v0.3.2
|
||||
- [x] Phase 3: Final review + ship + audit (milestone release) — shipped v0.3.3
|
||||
|
||||
**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 `.ciagent/RELEASE_POLICY.md`, every phase tag produces a Gitea release.
|
||||
|
||||
### Per-phase REQ coverage
|
||||
|
||||
- **P01 — `iter.Seq` streaming for `--watch` flags**
|
||||
- REQ-022 (`iter.Seq` for streaming job lists, Go 1.25+)
|
||||
- REQ-030 (`--watch` output format mode: table default vs streaming JSON per event)
|
||||
- Applies to both `orca job list --watch` and `orca node list --watch`
|
||||
(D-024, per ARCHITECTURE.md CLI layer + D-017)
|
||||
|
||||
- **P02 — `orca doctor` network + db full implementation**
|
||||
- REQ-032 (completion: network reachability via mTLS `/healthz` probe,
|
||||
db integrity via `PRAGMA integrity_check` + migration version)
|
||||
- Replaces `NetworkStub` and `DBStub` from v0.2 P01
|
||||
|
||||
### v0.3 is a completion milestone, not a direction change
|
||||
|
||||
The vision ("minimalist, offline-first, CLI-first orchestration
|
||||
engine") is unchanged. v0.3 closes out the v0.2 deferrals and merges
|
||||
the accumulated v0.2 work to main.
|
||||
## Milestone v0.1: Foundation
|
||||
- [ ] Phase 0: Project Initialization & Specification
|
||||
- [ ] Phase 1: Core CLI Skeleton & Command Parsing
|
||||
- [ ] Phase 2: Basic Node Management (Join/Leave)
|
||||
- [ ] Phase 3: Simple Task Execution Engine
|
||||
- [ ] Phase 4: Local State Persistence
|
||||
- [ ] Phase 5: Basic Health Checking
|
||||
- [ ] Phase 6: CoreCI Full Release Flow
|
||||
|
||||
+2
-37
@@ -5,7 +5,7 @@
|
||||
"slug": "orca",
|
||||
"name": "Orca",
|
||||
"description": "Offline/CLI-first orchestration engine (Orca) — Nomad-inspired, far simpler than Kubernetes",
|
||||
"milestone": "v0.3",
|
||||
"milestone": "v0.1",
|
||||
"phase": 0,
|
||||
"milestone_type": "feature",
|
||||
"default_branch": "main",
|
||||
@@ -29,24 +29,7 @@
|
||||
"decision_confidence_threshold": 0.60,
|
||||
"max_revision_iterations": 3,
|
||||
"max_verification_retries": 2,
|
||||
"clarify_budget": 10,
|
||||
"escalation_hooks": ["deploy", "delete_data", "merge_to_main"]
|
||||
},
|
||||
"workflow": {
|
||||
"no_hitl": true,
|
||||
"release_flow_per_phase": true,
|
||||
"merge_strategy": {
|
||||
"allowed": ["fast-forward", "rebase-then-fast-forward"],
|
||||
"forbidden": ["merge-commit-no-ff", "squash"],
|
||||
"phase_to_milestone": "fast-forward",
|
||||
"milestone_to_main": "rebase-then-fast-forward"
|
||||
},
|
||||
"branching": {
|
||||
"hierarchy": "main < milestone/<slug> < phase/<NN>-<slug>",
|
||||
"phase_branches": "phase/NN-<slug> merges into milestone/<slug> via fast-forward",
|
||||
"milestone_branches": "milestone/<slug> rebases onto main, then fast-forwards main",
|
||||
"default_branch": "main"
|
||||
}
|
||||
"escalation_hooks": ["delete", "drop", "force", "reset --hard"]
|
||||
},
|
||||
"personas": {
|
||||
"enabled": true,
|
||||
@@ -115,24 +98,6 @@
|
||||
"url": "https://git.cloudinit.dev/coreci/orca.git",
|
||||
"main_branch": "main"
|
||||
},
|
||||
"release": {
|
||||
"forge": "gitea",
|
||||
"gitea": {
|
||||
"base_url": "https://git.cloudinit.dev",
|
||||
"owner": "coreci",
|
||||
"repo": "orca",
|
||||
"token_env": "GITEA_TOKEN"
|
||||
}
|
||||
},
|
||||
"secrets": {
|
||||
"scopes": [
|
||||
{
|
||||
"name": "gitea",
|
||||
"vars": ["GITEA_TOKEN", "GITEA_USER"],
|
||||
"env_file": ".env"
|
||||
}
|
||||
]
|
||||
},
|
||||
"commands": {
|
||||
"test": "make test",
|
||||
"build": "make build",
|
||||
|
||||
+11
-79
@@ -2,23 +2,9 @@ version: "1"
|
||||
name: orca-ci
|
||||
description: Orca — offline/CLI-first orchestration engine. Full release flow via CoreCI.
|
||||
|
||||
# CoreCI configuration for orca.
|
||||
#
|
||||
# Each pipeline runs in an isolated container with the golang:1.25 toolchain.
|
||||
# All four pipelines (validate, build, test, release) must pass before a tag
|
||||
# can be published. The release pipeline is gated on the existence of a
|
||||
# semver tag (vX.Y.Z) and is the only pipeline that touches the Gitea API.
|
||||
#
|
||||
# P03 (v0.2) added three security-scanning stages to the `validate` pipeline:
|
||||
# - gosec (REQ-014, REQ-040) Static analysis for Go security smells
|
||||
# - govulncheck (REQ-014, REQ-027) Offline vuln scan of dependencies
|
||||
# - gitleaks (REQ-039) Pre-commit-style secret scan
|
||||
# The `test` pipeline runs with -race (REQ-031).
|
||||
# See docs/security-scanning.md for operator-facing details.
|
||||
|
||||
pipelines:
|
||||
validate:
|
||||
description: Validate Go toolchain, formatting, and security scans
|
||||
description: Validate Go toolchain and code formatting
|
||||
steps:
|
||||
- name: go-version
|
||||
image: golang:1.25
|
||||
@@ -27,88 +13,34 @@ pipelines:
|
||||
- gofmt -l .
|
||||
- go vet ./...
|
||||
|
||||
- name: gosec
|
||||
image: golang:1.25
|
||||
commands:
|
||||
- go install github.com/securego/gosec/v2/cmd/gosec@v2.18.2
|
||||
- gosec -fmt text -quiet ./...
|
||||
|
||||
- name: govulncheck
|
||||
image: golang:1.25
|
||||
env:
|
||||
# REQ-027: offline mode. GOFLAGS=-mod=mod ensures module mode;
|
||||
# GOVULNCHECK_DB (when present) overrides the bundled DB.
|
||||
GOFLAGS: -mod=mod
|
||||
commands:
|
||||
- go install golang.org/x/vuln/cmd/govulncheck@v1.1.3
|
||||
- govulncheck -mode binary ./...
|
||||
|
||||
- name: gitleaks
|
||||
image: golang:1.25
|
||||
commands:
|
||||
- apk add --no-cache curl
|
||||
- sh -c "$(curl -fsSL https://github.com/gitleaks/gitleaks/releases/latest/download/install.sh)"
|
||||
- gitleaks detect --source . --config .gitleaks.toml --baseline-path .gitleaks-baseline.json --no-banner
|
||||
|
||||
build:
|
||||
description: Build the orca binary with version injection
|
||||
description: Build the orca binary
|
||||
steps:
|
||||
- name: build
|
||||
image: golang:1.25
|
||||
env:
|
||||
VERSION: ${CI_COMMIT_TAG:-dev}
|
||||
GIT_COMMIT: ${CI_COMMIT_SHA}
|
||||
BUILD_TIME: ${CI_BUILD_TIME}
|
||||
commands:
|
||||
- |
|
||||
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}"
|
||||
go build -trimpath -ldflags="${LDFLAGS}" -o bin/orca ./cmd/orca
|
||||
- file bin/orca
|
||||
- ./bin/orca version
|
||||
- go build -o bin/orca ./cmd/orca
|
||||
|
||||
test:
|
||||
description: Run all tests with race detection and coverage (REQ-031)
|
||||
description: Run all tests with race detection
|
||||
steps:
|
||||
- name: test
|
||||
image: golang:1.25
|
||||
commands:
|
||||
- go test -race -coverprofile=coverage.out ./...
|
||||
- go tool cover -func=coverage.out | tail -1
|
||||
|
||||
release:
|
||||
description: Full release flow — versioned build, tarball, changelog, Gitea release
|
||||
when:
|
||||
ref: "refs/tags/v*"
|
||||
description: Full release flow — build, package, and publish to Gitea
|
||||
steps:
|
||||
- name: build-artifact
|
||||
image: golang:1.25
|
||||
env:
|
||||
VERSION: ${CI_COMMIT_TAG}
|
||||
GIT_COMMIT: ${CI_COMMIT_SHA}
|
||||
BUILD_TIME: ${CI_BUILD_TIME}
|
||||
commands:
|
||||
- |
|
||||
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}"
|
||||
go build -trimpath -ldflags="${LDFLAGS}" -o bin/orca ./cmd/orca
|
||||
- make changelog
|
||||
- tar -czf orca-${VERSION}-linux-amd64.tar.gz -C bin orca
|
||||
- ls -lh orca-${VERSION}-linux-amd64.tar.gz
|
||||
- go build -ldflags="-s -w" -o bin/orca ./cmd/orca
|
||||
- tar -czf orca-${CI_COMMIT_TAG}-linux-amd64.tar.gz -C bin orca
|
||||
- name: gitea-release
|
||||
image: golang:1.25
|
||||
env:
|
||||
GITEA_TOKEN: ${GITEA_TOKEN}
|
||||
VERSION: ${CI_COMMIT_TAG}
|
||||
commands:
|
||||
- apk add --no-cache curl tar
|
||||
- sh -c "$(curl -fsSL https://gitea.com/gitea/tea/releases/latest/download/install.sh)"
|
||||
- tea releases create ${VERSION}
|
||||
--repo coreci/orca
|
||||
--title "Orca ${VERSION}"
|
||||
--note-file CHANGELOG.md
|
||||
--asset orca-${VERSION}-linux-amd64.tar.gz
|
||||
- tea releases create ${CI_COMMIT_TAG}
|
||||
--title "Orca ${CI_COMMIT_TAG}"
|
||||
--note "Full release of Orca. See CHANGELOG for details."
|
||||
--asset orca-${CI_COMMIT_TAG}-linux-amd64.tar.gz
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
GITEA_TOKEN=795e2f875dcd23dff830fab8301ec52e4c9d67aa
|
||||
GITEA_USER=cloudinit-bot
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/bin/bash
|
||||
# .githooks/pre-commit — gitleaks pre-commit gate (P03, REQ-039).
|
||||
#
|
||||
# Runs `gitleaks protect --staged` on every commit. If gitleaks is
|
||||
# not installed, the hook is a no-op (the commit proceeds). CI
|
||||
# catches the same findings via `.coreci.yml` `validate` pipeline.
|
||||
#
|
||||
# Install: `git config core.hooksPath .githooks`
|
||||
|
||||
set -e
|
||||
|
||||
if ! command -v gitleaks >/dev/null 2>&1; then
|
||||
echo " (gitleaks not installed; skipping pre-commit secret scan; CI will catch it)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Find the repo root (this hook lives in .githooks/).
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# Run gitleaks on staged content. The --baseline-path suppresses
|
||||
# pre-existing findings (REQ-029 — the v0.1 .env leak).
|
||||
gitleaks protect --staged --config .gitleaks.toml --baseline-path .gitleaks-baseline.json
|
||||
@@ -8,8 +8,4 @@ orca
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
.env
|
||||
.env.local
|
||||
.env.secrets
|
||||
.env.*
|
||||
*.tar.gz
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
[
|
||||
{
|
||||
"Op": "skip",
|
||||
"RuleID": "orca-pre-existing-env-leak",
|
||||
"Commit": "0cba1aa5feef9564f8b9a2a97ae735dc859a8a84",
|
||||
"Entropy": 0,
|
||||
"Secret": "REDACTED-AT-BASELINE-CREATION-TIME",
|
||||
"File": ".env",
|
||||
"SymlinkFile": "",
|
||||
"CheckEntropy": false,
|
||||
"Match": "GITEA_TOKEN=<redacted — pre-existing v0.1 leak; rotated in 00127ce>"
|
||||
}
|
||||
]
|
||||
@@ -1,41 +0,0 @@
|
||||
# gitleaks config for orca (v0.2 P03, REQ-039)
|
||||
#
|
||||
# Allowlist CA cert PEM blocks (-----BEGIN CERTIFICATE-----) and test
|
||||
# data paths under internal/security/testdata/. Stopwords for both
|
||||
# the v0.1 historical `.env` leak (mitigated forward; baseline file
|
||||
# .gitleaks-baseline.json handles the historical case) and the
|
||||
# `.gitleaks-baseline.json` file itself.
|
||||
|
||||
title = "orca gitleaks config"
|
||||
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
[allowlist]
|
||||
description = "Global allowlist for orca repo"
|
||||
paths = [
|
||||
'''\.gitleaks-baseline\.json$''',
|
||||
'''\.gitleaks\.toml$''',
|
||||
'''\.golangci\.yml$''',
|
||||
'''\.coreci\.yml$''',
|
||||
'''\.ciagent/.*\.md$''',
|
||||
'''CHANGELOG\.md$''',
|
||||
'''internal/security/testdata/.*''',
|
||||
'''docs/security-scanning\.md$''',
|
||||
]
|
||||
|
||||
# Stopwords for cert PEM blocks (REQ-039): allow the cert headers,
|
||||
# but not the private-key headers. We rely on gitleaks' built-in
|
||||
# private-key detector for the latter; the allowlist here suppresses
|
||||
# the cert-PEM false-positive on `-----BEGIN CERTIFICATE-----`.
|
||||
stopwords = [
|
||||
'''-----BEGIN CERTIFICATE-----''',
|
||||
'''-----END CERTIFICATE-----''',
|
||||
]
|
||||
|
||||
[[rules]]
|
||||
id = "orca-cert-pem"
|
||||
description = "CA and leaf cert PEM blocks (allowlisted, not flagged)"
|
||||
regex = '''-----BEGIN (?:RSA |EC |DSA |)CERTIFICATE-----'''
|
||||
keywords = ["-----BEGIN CERTIFICATE-----"]
|
||||
allowlist = true
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
# golangci-lint unified config for orca (v0.2 P03, REQ-040).
|
||||
# Supersedes per-tool invocations. The linters here are picked for
|
||||
# the minimalist pillar: only what's needed to catch real bugs and
|
||||
# security issues, nothing cosmetic.
|
||||
|
||||
linters:
|
||||
disable-all: true
|
||||
enable:
|
||||
- gosec # security; integrated with .coreci.yml validate
|
||||
- govet # standard go vet
|
||||
- ineffassign # unreachable error returns
|
||||
- misspell # common typos
|
||||
- gocritic # opinionated style/lint checks (subset below)
|
||||
|
||||
linters-settings:
|
||||
gosec:
|
||||
# Severity filter: don't fail on LOW; HIGH is a blocker.
|
||||
# The P03 plan asks for hardcoded-credential (G101) to be a
|
||||
# build-breaking finding; the gosec default severity is HIGH
|
||||
# for G101, so the default config satisfies that.
|
||||
severity: high
|
||||
confidence: medium
|
||||
|
||||
issues:
|
||||
# Exclude generated or vendored paths.
|
||||
exclude-rules:
|
||||
- path: "_test\\.go"
|
||||
linters: [gosec]
|
||||
text: "G404" # Insecure random number source (math/rand) is fine in tests
|
||||
- path: "internal/security/testdata/"
|
||||
linters: [gosec, misspell]
|
||||
|
||||
run:
|
||||
# golangci-lint uses .golangci.yml by default; we keep the
|
||||
# timeout short because the codebase is small. CI overrides
|
||||
# this in .coreci.yml.
|
||||
timeout: 5m
|
||||
tests: true
|
||||
@@ -1,35 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to orca are documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
- `e1b538575c57158c7a6661d5919b103f6c7932fc` — feat(P06): CoreCI release flow with .coreci.yml and tea integration
|
||||
- `07b8ad2ceaba7ca303dfe91876930d33b76c633e` — ship(P05): health checks merged into milestone
|
||||
- `b06458d31370750417a3b239dac61c6e2fdf5329` — docs(P05): verification - 4 layers pass
|
||||
- `708d9834296271094667700e88e80bfa27db7bdd` — feat(P05): health check daemon with /healthz, /readyz, /v1/* handlers
|
||||
- `30c523c0c7a8e75a2e97b42f1c8a39802febcbdc` — ship(P04): state persistence merged into milestone
|
||||
- `759b1b519d7fadf3d91d3070952d9ad2051a0eba` — docs(P04): verification - 4 layers pass
|
||||
- `b25e074e1d3518f175478184ff8d002ec0d8412c` — feat(P04): audit log + persistence hardening
|
||||
- `bb6b5b3e8342c16601a8503223c7186ecdbb00df` — ship(P03): task exec merged into milestone
|
||||
- `857f7563190e7703f97c607a50d6b0a897d250e9` — docs(P03): verification - 4 layers pass
|
||||
- `f9a98733411cfa8657e82636e0c55671086ebe46` — feat(P03): task execution engine with HCL specs, jobs, tasks, WaitDelay
|
||||
- `78334f1f74f0c185c6d38014c796aac4903b8141` — ship(P02): node mgmt merged into milestone
|
||||
- `c7dbcef9587596786a541a7566479d9fb93fcf0a` — docs(P02): verification - 4 layers pass
|
||||
- `9580f347c68e395dccfbe83b27a857d52bf21075` — feat(P02): node management with SQLite-backed registry
|
||||
- `46e929e4c6539bd604539ba27d5ed0c606e87bb9` — chore(P01): source .env in trigger_coreci.sh for GITEA_TOKEN
|
||||
- `503923bf1ee2c60f8375acc7eb9608d346368e1c` — ship(P01): cli skeleton merged into milestone
|
||||
- `e3f6e1df825d39f73933c9996bd2cc4717ff1061` — docs(P01): verification - 4 layers pass
|
||||
- `aa3cccead503a37dfec75873d06d2d396a2876f2` — feat(P01): CLI skeleton with Cobra, subcommand stubs, pre-push hook
|
||||
- `c2038952c74f7c242ba3be65d2f4269b23685f5a` — docs(P00): create 6 phase plans with wave ordering
|
||||
- `65eb2e601b741b36388598b9f8adddd7bd8dd3a8` — docs(P00): research findings - architecture + personas
|
||||
- `6f34f1794b9f526c06a1dc139d4a74371599502e` — docs(P00): ideation - 30 ideas accepted (3 tiers)
|
||||
- `bc7ce1caf672e87774455a6cd6cc0db986cd09b3` — docs(P00): clarify ambiguities (full autonomy, 10 decisions)
|
||||
- `55aae5347ec09bce9ef7697ea0c9c9ee158bc040` — chore(P00): rename orch-engine to orca, configure gitea + coreci (v0.1)
|
||||
- `0cba1aa5feef9564f8b9a2a97ae735dc859a8a84` — chore(P00): set autonomy level to full
|
||||
- `e2e77e79b9cbfb462044662543845476f843161b` — chore(P00): quick task - populate config.json with backlog reference
|
||||
- `8c086def698bf0af31e8e820b6b7a2783af06f43` — chore(config): populate ciagent config with standard settings
|
||||
- `8774008c3e47e4ca4711f4fef164531006d16216` — docs(init): validate specification
|
||||
|
||||
Generated by make changelog. Do not edit by hand.
|
||||
@@ -1,47 +1,26 @@
|
||||
.PHONY: build test test-race lint fmt clean run release version changelog help security-scan
|
||||
.PHONY: build test lint fmt clean run release help
|
||||
|
||||
BINARY := bin/orca
|
||||
GOFLAGS := -trimpath
|
||||
PKG := ./cmd/orca
|
||||
|
||||
# Version is read from the latest git tag, with a `dev` fallback.
|
||||
# Override with `make build VERSION=v0.1.5` if needed.
|
||||
VERSION ?= $(shell git describe --tags --abbrev=0 2>/dev/null || echo "dev")
|
||||
GIT_COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
BUILD_TIME ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
# -ldflags injects version metadata into the binary. The variables live in
|
||||
# internal/cli/root.go, so we target git.cloudinit.dev/coreci/orca/internal/cli.
|
||||
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)
|
||||
LDFLAGS := -s -w -X main.version=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") \
|
||||
-X main.gitCommit=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") \
|
||||
-X main.buildTime=$(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
help:
|
||||
@echo "orca — make targets"
|
||||
@echo " build Build binary to $(BINARY) (injects version via -ldflags)"
|
||||
@echo " test Run tests"
|
||||
@echo " test-race Run tests with race detection (REQ-031)"
|
||||
@echo " lint Run gofmt + go vet"
|
||||
@echo " fmt Format code"
|
||||
@echo " clean Remove build artifacts"
|
||||
@echo " run Build and run with args (use: make run ARGS='version')"
|
||||
@echo " version Print the version string that would be injected"
|
||||
@echo " changelog Generate CHANGELOG.md from ---ci--- commit blocks"
|
||||
@echo " release Run scripts/release.sh [VERSION] — build, tar, publish"
|
||||
@echo " security-scan Run gosec+govulncheck+gitleaks (P03, REQ-014/027/039)"
|
||||
@echo " build Build binary to $(BINARY)"
|
||||
@echo " test Run tests with race detection"
|
||||
@echo " lint Run gofmt + go vet"
|
||||
@echo " fmt Format code"
|
||||
@echo " clean Remove build artifacts"
|
||||
@echo " run Build and run with args (use: make run ARGS='version')"
|
||||
@echo " release Build release artifact with version injection"
|
||||
|
||||
build:
|
||||
@mkdir -p bin
|
||||
@echo " → building $(VERSION) ($(GIT_COMMIT))"
|
||||
go build $(GOFLAGS) -ldflags="$(LDFLAGS)" -o $(BINARY) $(PKG)
|
||||
go build $(GOFLAGS) -o $(BINARY) ./cmd/orca
|
||||
|
||||
test:
|
||||
go test -coverprofile=coverage.out ./...
|
||||
|
||||
# test-race runs the full test suite under the race detector (REQ-031).
|
||||
# Wired into the .coreci.yml `test` pipeline as well.
|
||||
test-race:
|
||||
go test -race -coverprofile=coverage.out ./...
|
||||
|
||||
lint:
|
||||
@@ -52,49 +31,12 @@ fmt:
|
||||
gofmt -w .
|
||||
|
||||
clean:
|
||||
rm -rf bin coverage.out *.tar.gz
|
||||
rm -rf bin coverage.out
|
||||
|
||||
run: build
|
||||
./$(BINARY) $(ARGS)
|
||||
|
||||
version:
|
||||
@echo "$(VERSION) (commit $(GIT_COMMIT), built $(BUILD_TIME))"
|
||||
|
||||
# changelog aggregates the most recent ---ci--- tagged commit messages
|
||||
# into CHANGELOG.md. Idempotent; safe to run after every milestone.
|
||||
changelog:
|
||||
@echo "# Changelog" > CHANGELOG.md
|
||||
@echo "" >> CHANGELOG.md
|
||||
@echo "All notable changes to orca are documented in this file." >> CHANGELOG.md
|
||||
@echo "" >> CHANGELOG.md
|
||||
@echo "The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)," >> CHANGELOG.md
|
||||
@echo "and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html)." >> CHANGELOG.md
|
||||
@echo "" >> CHANGELOG.md
|
||||
@git log --pretty=format:'%H' --grep='^feat\|^fix\|^docs\|^ship\|^chore' 2>/dev/null | head -50 | while read sha; do \
|
||||
msg=$$(git log -1 --pretty=format:'%s' "$$sha"); \
|
||||
if echo "$$msg" | grep -qE -- '---ci---|phase:'; then \
|
||||
phase=$$(echo "$$msg" | grep -oE 'phase: [0-9]+' | head -1 | awk '{print $$2}'); \
|
||||
status=$$(echo "$$msg" | grep -oE 'status: [a-z]+' | head -1 | awk '{print $$2}'); \
|
||||
echo "- \`$$sha\` (phase $$phase, $$status) — $$msg" >> CHANGELOG.md; \
|
||||
else \
|
||||
echo "- \`$$sha\` — $$msg" >> CHANGELOG.md; \
|
||||
fi; \
|
||||
done
|
||||
@echo "" >> CHANGELOG.md
|
||||
@echo "Generated by make changelog. Do not edit by hand." >> CHANGELOG.md
|
||||
@echo "✓ CHANGELOG.md updated"
|
||||
|
||||
release:
|
||||
@if [ -z "$(VERSION)" ] || [ "$(VERSION)" = "dev" ]; then \
|
||||
echo "release: no version tag found. Tag first: git tag v0.1.6"; \
|
||||
exit 1; \
|
||||
fi
|
||||
./scripts/release.sh $(VERSION)
|
||||
|
||||
# security-scan runs the three tools integrated in P03 (REQ-014,
|
||||
# REQ-027, REQ-039). Local equivalent of the .coreci.yml `validate`
|
||||
# security stages. Exits non-zero on any unsuppressed finding.
|
||||
# The script handles tool detection (silently skips tools not on PATH
|
||||
# in a developer's local environment; CI requires all three).
|
||||
security-scan:
|
||||
./scripts/security_scan.sh
|
||||
@mkdir -p bin
|
||||
go build $(GOFLAGS) -ldflags="$(LDFLAGS)" -o $(BINARY) ./cmd/orca
|
||||
@echo "Release build complete: $(BINARY)"
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := cli.Execute(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
# Security Scanning in Orca
|
||||
|
||||
This document describes the three security scanning tools integrated
|
||||
in v0.2 P03 (Phases 10): `gosec`, `govulncheck`, and `gitleaks`. All
|
||||
three run in the `.coreci.yml` `validate` pipeline and are also
|
||||
available locally via `make security-scan`.
|
||||
|
||||
## TL;DR
|
||||
|
||||
```bash
|
||||
# Run all three tools locally (silently skips tools not on PATH).
|
||||
make security-scan
|
||||
|
||||
# Strict mode: require all three to be installed.
|
||||
./scripts/security_scan.sh --strict
|
||||
```
|
||||
|
||||
The `.coreci.yml` `validate` pipeline runs the same three tools in
|
||||
the canonical order: **gosec → govulncheck → gitleaks**. A failure
|
||||
at any stage blocks merges to `main`.
|
||||
|
||||
## Tools
|
||||
|
||||
### gosec
|
||||
|
||||
[gosec](https://github.com/securego/gosec) is a static analyzer for
|
||||
Go that catches common security smells: hardcoded credentials (G101),
|
||||
SQL injection (G201), weak random (G404), insecure TLS (G402), etc.
|
||||
|
||||
**Configuration**: `gosec -fmt text -quiet ./...` — text output, quiet
|
||||
mode (only summary + findings). The plan calls for an empty
|
||||
`gosec.json` baseline at the start; new G101 findings fail the build.
|
||||
|
||||
**What gets caught**:
|
||||
- G101: hardcoded credentials (e.g., `apiKey := "abc123"`)
|
||||
- G102: bind to all interfaces (`0.0.0.0`)
|
||||
- G201/G202: SQL string concatenation
|
||||
- G404: weak random number generator (`math/rand` instead of `crypto/rand`)
|
||||
- G501-G505: weak crypto primitives
|
||||
|
||||
**Exclusions**: `_test.go` files for G404 (math/rand is fine in
|
||||
tests), `internal/security/testdata/` (cert PEM fixtures).
|
||||
|
||||
### govulncheck (offline mode, REQ-027)
|
||||
|
||||
[govulncheck](https://golang.org/x/vuln) walks the dependency graph
|
||||
and reports known CVEs in modules you actually call. REQ-027 requires
|
||||
**offline mode** — the default invocation calls `vuln.go.dev` to
|
||||
fetch the latest vulnerability database. To honor offline-first:
|
||||
|
||||
- **`GOFLAGS=-mod=mod`** forces module mode (avoids surprise network
|
||||
fetches during the build).
|
||||
- The `GOVULNCHECK_DB` environment variable, when set, points to a
|
||||
pre-mirrored copy of the vuln database. The CI image bundles a
|
||||
daily-mirrored DB at `/var/lib/orca/vulndb/`. Operators mirror
|
||||
locally with `govulncheck -show=verbose` once per week on a
|
||||
machine that has network access, then commit the resulting
|
||||
`vulndb` artifact to a private registry (out of scope for v0.2
|
||||
OSS; documented as a follow-up).
|
||||
- Until the mirror is in place, `govulncheck -mode binary ./...`
|
||||
uses its bundled DB. The bundled DB is updated on every
|
||||
`govulncheck` release; in CI we pin to `v1.1.3` for reproducibility.
|
||||
|
||||
**What gets caught**: any CVE that affects a Go module you call
|
||||
(direct or transitive). Output is the govulncall symbol + CVE ID.
|
||||
|
||||
### gitleaks (REQ-039)
|
||||
|
||||
[gitleaks](https://github.com/gitleaks/gitleaks) scans the working
|
||||
tree (and git history, if asked) for hardcoded secrets: API keys,
|
||||
private keys, tokens, passwords. REQ-039 specifies a project-local
|
||||
`.gitleaks.toml` to allowlist `-----BEGIN CERTIFICATE-----` PEM
|
||||
blocks (which are not secrets) while still flagging
|
||||
`-----BEGIN RSA PRIVATE KEY-----` and similar.
|
||||
|
||||
**Configuration**:
|
||||
- `.gitleaks.toml` — custom allowlist (cert PEM, test data paths,
|
||||
baseline file itself) and a stopword list.
|
||||
- `.gitleaks-baseline.json` — REQ-029. Suppresses the pre-existing
|
||||
`.env` SHA-1 leak from v0.1 history (rotated forward; the
|
||||
baseline gates future re-leaks of the same SHA).
|
||||
- **Pre-commit hook** (`.githooks/pre-commit`) — runs
|
||||
`gitleaks protect --staged` on every commit. Commits are still
|
||||
allowed when gitleaks is not installed (the `if command -v` gate
|
||||
is in the hook).
|
||||
|
||||
## Pipeline Integration
|
||||
|
||||
`.coreci.yml` `validate` pipeline:
|
||||
|
||||
```yaml
|
||||
- name: gosec
|
||||
image: golang:1.25
|
||||
commands:
|
||||
- go install github.com/securego/gosec/v2/cmd/gosec@v2.18.2
|
||||
- gosec -fmt text -quiet ./...
|
||||
|
||||
- name: govulncheck
|
||||
image: golang:1.25
|
||||
env:
|
||||
GOFLAGS: -mod=mod
|
||||
commands:
|
||||
- go install golang.org/x/vuln/cmd/govulncheck@v1.1.3
|
||||
- govulncheck -mode binary ./...
|
||||
|
||||
- name: gitleaks
|
||||
image: golang:1.25
|
||||
commands:
|
||||
- apk add --no-cache curl
|
||||
- sh -c "$(curl -fsSL https://github.com/gitleaks/gitleaks/releases/latest/download/install.sh)"
|
||||
- gitleaks detect --source . --config .gitleaks.toml --baseline-path .gitleaks-baseline.json --no-banner
|
||||
```
|
||||
|
||||
The `test` pipeline runs with `-race` (REQ-031):
|
||||
|
||||
```yaml
|
||||
- name: test
|
||||
image: golang:1.25
|
||||
commands:
|
||||
- go test -race -coverprofile=coverage.out ./...
|
||||
- go tool cover -func=coverage.out | tail -1
|
||||
```
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
# Install the three tools (one-time).
|
||||
go install github.com/securego/gosec/v2/cmd/gosec@v2.18.2
|
||||
go install golang.org/x/vuln/cmd/govulncheck@v1.1.3
|
||||
# gitleaks: see https://github.com/gitleaks/gitleaks#installation
|
||||
|
||||
# Run all three.
|
||||
make security-scan
|
||||
|
||||
# Run with strict mode (all three required).
|
||||
./scripts/security_scan.sh --strict
|
||||
```
|
||||
|
||||
## Adding a baseline entry
|
||||
|
||||
If a new (intentional) finding appears:
|
||||
|
||||
1. **gosec**: regenerate the baseline with
|
||||
`gosec -fmt json -no-fail ./... > gosec.json`. Inspect for
|
||||
false positives; document the suppression in the JSON's
|
||||
`suppressions` field.
|
||||
2. **govulncheck**: wait for the upstream fix; if you must pin
|
||||
a vulnerable dep, document the pin in a `//nolint:govulncheck`
|
||||
comment and create a tracking issue.
|
||||
3. **gitleaks**: add a fingerprint to `.gitleaks-baseline.json`
|
||||
with `gitleaks detect --baseline-path .gitleaks-baseline.json
|
||||
--report-path new-findings.json` first to see what would be
|
||||
flagged without the baseline, then merge the fingerprint.
|
||||
|
||||
## Why offline mode matters
|
||||
|
||||
Default `govulncheck` calls `vuln.go.dev` on every run. That violates
|
||||
REQ-003 (offline-first). The fix in P03 is:
|
||||
|
||||
1. `GOFLAGS=-mod=mod` ensures module mode (no surprise module
|
||||
downloads).
|
||||
2. The pre-mirrored DB mechanism is a follow-up; the bundled DB
|
||||
in the pinned `govulncheck` binary is the immediate fallback.
|
||||
3. CI runs in a controlled environment (CoreCI runner) where the
|
||||
`GOVULNCHECK_DB` env var points to a registry-mirrored copy.
|
||||
|
||||
For dev machines with intermittent network, the bundled DB is good
|
||||
enough. For air-gapped CI runners, set `GOVULNCHECK_DB` to a
|
||||
known-good DB file.
|
||||
@@ -2,18 +2,14 @@ module git.cloudinit.dev/coreci/orca
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/hashicorp/hcl/v2 v2.24.0
|
||||
github.com/spf13/cobra v1.8.1
|
||||
modernc.org/sqlite v1.51.0
|
||||
)
|
||||
require github.com/spf13/cobra v1.8.1
|
||||
|
||||
require (
|
||||
github.com/agext/levenshtein v1.2.1 // indirect
|
||||
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/hcl/v2 v2.24.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
|
||||
@@ -29,4 +25,5 @@ require (
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.51.0 // indirect
|
||||
)
|
||||
|
||||
@@ -3,20 +3,10 @@ github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki
|
||||
github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
|
||||
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68=
|
||||
github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE=
|
||||
github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
@@ -36,8 +26,6 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
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-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo=
|
||||
github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
@@ -51,31 +39,11 @@ golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
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=
|
||||
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U=
|
||||
modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
// Package audit provides a thin convenience wrapper around
|
||||
// engine.Audit tailored to mTLS / cert lifecycle events. It exists so
|
||||
// that cert, transport, and daemon code can call a small, semantically
|
||||
// clear API (Emit with explicit action + result) without depending on
|
||||
// the more general-purpose engine.Audit.
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/engine"
|
||||
)
|
||||
|
||||
// Result enumerates the result strings persisted to audit_log. Keeping
|
||||
// these as constants (rather than free-form strings) prevents typos at
|
||||
// call sites and makes log analytics trivial.
|
||||
type Result string
|
||||
|
||||
const (
|
||||
ResultSuccess Result = "success"
|
||||
ResultFailure Result = "failure"
|
||||
ResultDenied Result = "denied"
|
||||
)
|
||||
|
||||
// Action enumerates the cert / handshake event names used across the
|
||||
// security surface. Matches REQ-038 / P01 must-haves:
|
||||
//
|
||||
// cert.issued — a CSR was signed, server cert persisted
|
||||
// cert.renewed — a server cert was re-issued (rotation)
|
||||
// cert.joined — a node joined the trust domain (CA pinned)
|
||||
// node.handshake_ok — mTLS handshake succeeded
|
||||
// node.handshake_failed — mTLS handshake failed
|
||||
type Action string
|
||||
|
||||
const (
|
||||
ActionCertIssued Action = "cert.issued"
|
||||
ActionCertRenewed Action = "cert.renewed"
|
||||
ActionCertJoined Action = "cert.joined"
|
||||
ActionNodeHandshakeOK Action = "node.handshake_ok"
|
||||
ActionNodeHandshakeFail Action = "node.handshake_failed"
|
||||
)
|
||||
|
||||
// Audit wraps engine.Audit with a cert/handshake-focused API.
|
||||
type Audit struct {
|
||||
engine *engine.Audit
|
||||
}
|
||||
|
||||
// New constructs an Audit backed by the given engine.Audit. The engine
|
||||
// instance persists to the audit_log table; the wrapper just shapes
|
||||
// the call signature.
|
||||
func New(e *engine.Audit) *Audit {
|
||||
return &Audit{engine: e}
|
||||
}
|
||||
|
||||
// Emit persists an audit entry. The `event` is a free-form description
|
||||
// that ends up in the resource field, paired with action + result. Use
|
||||
// the Action* constants for `action`; free-form strings for `event` are
|
||||
// allowed for extensibility but should be stable for analytics.
|
||||
func (a *Audit) Emit(ctx context.Context, action Action, event string, result Result, metadata map[string]any) {
|
||||
if a == nil || a.engine == nil {
|
||||
return
|
||||
}
|
||||
// Resource field is conventionally <event>:<id>; we just use event
|
||||
// as-is here. Callers can stuff the relevant id into metadata.
|
||||
a.engine.Record(ctx, "security", string(action), event, string(result), nil, metadata)
|
||||
}
|
||||
|
||||
// EmitWithErr persists a failure entry whose err is also recorded in the
|
||||
// audit_log.error column. Use this for handshake failures and similar
|
||||
// error paths where the underlying error is useful for postmortem.
|
||||
func (a *Audit) EmitWithErr(ctx context.Context, action Action, event string, err error, metadata map[string]any) {
|
||||
if a == nil || a.engine == nil {
|
||||
return
|
||||
}
|
||||
a.engine.Record(ctx, "security", string(action), event, string(ResultFailure), err, metadata)
|
||||
}
|
||||
|
||||
// LogHandshakeOK emits a structured slog record for a successful mTLS
|
||||
// handshake. This is a SEPARATE log line from the audit_log entry —
|
||||
// structured slog is for operators; audit_log is for compliance.
|
||||
func LogHandshakeOK(log *slog.Logger, peer, certFP string) {
|
||||
if log == nil {
|
||||
return
|
||||
}
|
||||
log.Info("mtls.handshake",
|
||||
slog.String("event", "mtls.handshake"),
|
||||
slog.String("result", "ok"),
|
||||
slog.String("peer", peer),
|
||||
slog.String("cert_fp", certFP),
|
||||
)
|
||||
}
|
||||
|
||||
// LogHandshakeFailed emits a structured slog record for a failed mTLS
|
||||
// handshake. Per REQ-038, the fields are: event=mtls.handshake, peer,
|
||||
// cert_fp (may be empty if no cert was presented), err.
|
||||
func LogHandshakeFailed(log *slog.Logger, peer, certFP string, err error) {
|
||||
if log == nil {
|
||||
return
|
||||
}
|
||||
attrs := []any{
|
||||
slog.String("event", "mtls.handshake"),
|
||||
slog.String("result", "failed"),
|
||||
slog.String("peer", peer),
|
||||
slog.String("cert_fp", certFP),
|
||||
}
|
||||
if err != nil {
|
||||
attrs = append(attrs, slog.String("err", err.Error()))
|
||||
}
|
||||
log.Warn("mtls.handshake", attrs...)
|
||||
}
|
||||
|
||||
// String converts an Action to its canonical string form. Useful in
|
||||
// tests and CLI surface.
|
||||
func (a Action) String() string { return string(a) }
|
||||
|
||||
// String converts a Result to its canonical string form.
|
||||
func (r Result) String() string { return string(r) }
|
||||
|
||||
// FormatAction formats an action+result pair as "action=... result=...",
|
||||
// used by callers building structured log lines.
|
||||
func FormatAction(action Action, result Result) string {
|
||||
return fmt.Sprintf("action=%s result=%s", action, result)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
// Package certpaths centralizes the on-disk locations of the CA and
|
||||
// server cert/key files. The CLI layer, the security layer, and the
|
||||
// doctor layer all need to agree on these paths, so they're factored
|
||||
// into their own package to avoid import cycles (cli <-> doctor).
|
||||
package certpaths
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultCADir = ".orca"
|
||||
caCertFilename = "ca.crt"
|
||||
caKeyFilename = "ca.key"
|
||||
)
|
||||
|
||||
// Dir returns the directory the local CA lives in. Honors $ORCA_HOME
|
||||
// for testability; otherwise defaults to ~/.orca.
|
||||
func Dir() string {
|
||||
if p := os.Getenv("ORCA_HOME"); p != "" {
|
||||
return p
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, defaultCADir)
|
||||
}
|
||||
|
||||
// CACertPath returns the path to ca.crt.
|
||||
func CACertPath() string { return filepath.Join(Dir(), caCertFilename) }
|
||||
|
||||
// CAKeyPath returns the path to ca.key.
|
||||
func CAKeyPath() string { return filepath.Join(Dir(), caKeyFilename) }
|
||||
|
||||
// ServerCertPath returns the path to server.crt.
|
||||
func ServerCertPath() string { return filepath.Join(Dir(), "server.crt") }
|
||||
|
||||
// ServerKeyPath returns the path to 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")
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
var (
|
||||
auditLimit int
|
||||
)
|
||||
|
||||
var auditCmd = &cobra.Command{
|
||||
Use: "audit",
|
||||
Short: "View orca audit log",
|
||||
Long: "Display the most recent audit log entries (security-first observability).",
|
||||
}
|
||||
|
||||
var auditListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List recent audit log entries",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
db, closer, err := openDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
|
||||
entries, err := store.NewAuditRepo(db).List(ctx, auditLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if jsonOutput {
|
||||
return printJSON(entries)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "No audit entries.")
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%-22s %-12s %-20s %-30s %-10s\n", "TIMESTAMP", "ACTOR", "ACTION", "RESOURCE", "RESULT")
|
||||
for _, e := range entries {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%-22s %-12s %-20s %-30s %-10s\n",
|
||||
e.Timestamp.Format("2006-01-02T15:04:05Z"), e.Actor, e.Action, e.Resource, e.Result)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
auditListCmd.Flags().IntVar(&auditLimit, "limit", 50, "max entries to show")
|
||||
auditCmd.AddCommand(auditListCmd)
|
||||
rootCmd.AddCommand(auditCmd)
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
// cert.go implements the `orca cert` subcommand family.
|
||||
//
|
||||
// Subcommands:
|
||||
//
|
||||
// orca cert ca-init — bootstrap a local CA in ~/.orca/
|
||||
// orca cert gen — generate a server CSR + sign it with the local CA
|
||||
// orca cert show — print the active server cert (redacted; REQ-035)
|
||||
// orca cert renew — re-issue and rotate the server cert
|
||||
// orca cert fingerprint — print the SHA-256 of ca.crt or server.crt
|
||||
//
|
||||
// All subcommands refuse to operate if the on-disk CA / cert file modes
|
||||
// do not match REQ-033 (0600 for keys, 0644 for certs).
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
||||
"git.cloudinit.dev/coreci/orca/internal/security"
|
||||
)
|
||||
|
||||
// CADir returns the directory the local CA lives in. Re-exported for
|
||||
// backward compatibility with callers that imported this from the cli
|
||||
// package directly.
|
||||
func CADir() string { return certpaths.Dir() }
|
||||
|
||||
// CACertPath returns the path to ca.crt.
|
||||
func CACertPath() string { return certpaths.CACertPath() }
|
||||
|
||||
// CAKeyPath returns the path to ca.key.
|
||||
func CAKeyPath() string { return certpaths.CAKeyPath() }
|
||||
|
||||
// ServerCertPath returns the path to server.crt.
|
||||
func ServerCertPath() string { return certpaths.ServerCertPath() }
|
||||
|
||||
// ServerKeyPath returns the path to server.key.
|
||||
func ServerKeyPath() string { return certpaths.ServerKeyPath() }
|
||||
|
||||
// NewCommand builds the `orca cert` command tree.
|
||||
func NewCommand(log *slog.Logger) *cobra.Command {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
certCmd := &cobra.Command{
|
||||
Use: "cert",
|
||||
Short: "Manage orca certificates (CA, server, rotation)",
|
||||
Long: "Bootstrap a local CA, generate server certs, and rotate them.",
|
||||
}
|
||||
|
||||
certCmd.AddCommand(newCAInitCmd(log))
|
||||
certCmd.AddCommand(newGenCmd(log))
|
||||
certCmd.AddCommand(newShowCmd(log))
|
||||
certCmd.AddCommand(newRenewCmd(log))
|
||||
certCmd.AddCommand(newFingerprintCmd(log))
|
||||
return certCmd
|
||||
}
|
||||
|
||||
func newCAInitCmd(log *slog.Logger) *cobra.Command {
|
||||
var cn string
|
||||
cmd := &cobra.Command{
|
||||
Use: "ca-init",
|
||||
Short: "Initialize a local orca CA (ca.crt + ca.key) under ~/.orca",
|
||||
Long: "Generates a new RSA CA cert and writes it to ~/.orca/ca.crt (0644) and ~/.orca/ca.key (0600) per REQ-033.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
dir := CADir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir %s: %w", dir, err)
|
||||
}
|
||||
ca, err := security.CAInit(dir, cn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ca-init: %w", err)
|
||||
}
|
||||
fp := ca.Fingerprint()
|
||||
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "✓ CA initialized at %s\n fingerprint (sha256): %s\n not_after: %s\n",
|
||||
dir, fp, ca.NotAfter.UTC().Format("2006-01-02")); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Info("cert.ca_init",
|
||||
slog.String("event", "cert.ca_init"),
|
||||
slog.String("dir", dir),
|
||||
slog.String("cert_fp", fp),
|
||||
)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&cn, "cn", "orca-local-ca", "CA common name")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newGenCmd(log *slog.Logger) *cobra.Command {
|
||||
var cn string
|
||||
var sans []string
|
||||
cmd := &cobra.Command{
|
||||
Use: "gen",
|
||||
Short: "Generate a server cert (CSR + sign) under ~/.orca",
|
||||
Long: "Builds a CSR with the requested SANs, signs it with the local CA, and writes server.crt + server.key.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
dir := CADir()
|
||||
if cn == "" {
|
||||
cn = "orca-server"
|
||||
}
|
||||
ca, err := security.LoadCA(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load CA (run `orca cert ca-init` first): %w", err)
|
||||
}
|
||||
keyPEM, csrPEM, err := security.GenerateCSR(cn, sans)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate CSR: %w", err)
|
||||
}
|
||||
certPEM, err := ca.SignCSR(csrPEM)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sign CSR: %w", err)
|
||||
}
|
||||
certPath := ServerCertPath()
|
||||
keyPath := ServerKeyPath()
|
||||
if err := security.WriteCert(certPath, certPEM); err != nil {
|
||||
return fmt.Errorf("write cert: %w", err)
|
||||
}
|
||||
if err := security.WriteKey(keyPath, keyPEM); err != nil {
|
||||
return fmt.Errorf("write key: %w", err)
|
||||
}
|
||||
fp := security.FingerprintOf(parseFirstCertDER(certPEM))
|
||||
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "✓ Server cert generated\n cert: %s\n key: %s\n fingerprint (sha256): %s\n",
|
||||
certPath, keyPath, fp); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Info("cert.issued",
|
||||
slog.String("event", "cert.issued"),
|
||||
slog.String("cn", cn),
|
||||
slog.String("cert_fp", fp),
|
||||
)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&cn, "cn", "orca-server", "server cert common name")
|
||||
cmd.Flags().StringSliceVar(&sans, "san", []string{"localhost", "127.0.0.1"}, "SAN entries (DNS or IP) — at least one required (REQ-036)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newShowCmd(log *slog.Logger) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "show",
|
||||
Short: "Print the server cert (private keys redacted; REQ-035)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
pem, err := os.ReadFile(ServerCertPath())
|
||||
if err != nil {
|
||||
return fmt.Errorf("read server cert: %w", err)
|
||||
}
|
||||
// Per REQ-035, strip private key material before display.
|
||||
out := security.Redact(pem)
|
||||
if _, err := cmd.OutOrStdout().Write(out); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Debug("cert.show", slog.String("event", "cert.show"))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newRenewCmd(log *slog.Logger) *cobra.Command {
|
||||
var cn string
|
||||
var sans []string
|
||||
cmd := &cobra.Command{
|
||||
Use: "renew",
|
||||
Short: "Rotate the server cert (hot-swapped by the daemon; REQ-034)",
|
||||
Long: "Re-runs `cert gen` and overwrites server.crt / server.key in place. The daemon's GetCertificate callback picks up the new cert on the next handshake — no restart required.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
dir := CADir()
|
||||
if cn == "" {
|
||||
cn = "orca-server"
|
||||
}
|
||||
ca, err := security.LoadCA(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load CA: %w", err)
|
||||
}
|
||||
keyPEM, csrPEM, err := security.GenerateCSR(cn, sans)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate CSR: %w", err)
|
||||
}
|
||||
certPEM, err := ca.SignCSR(csrPEM)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sign CSR: %w", err)
|
||||
}
|
||||
if err := security.WriteCert(ServerCertPath(), certPEM); err != nil {
|
||||
return fmt.Errorf("write cert: %w", err)
|
||||
}
|
||||
if err := security.WriteKey(ServerKeyPath(), keyPEM); err != nil {
|
||||
return fmt.Errorf("write key: %w", err)
|
||||
}
|
||||
fp := security.FingerprintOf(parseFirstCertDER(certPEM))
|
||||
if _, err := fmt.Fprintln(cmd.OutOrStdout(), "✓ Server cert rotated"); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Info("cert.renewed",
|
||||
slog.String("event", "cert.renewed"),
|
||||
slog.String("cert_fp", fp),
|
||||
)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&cn, "cn", "orca-server", "server cert common name")
|
||||
cmd.Flags().StringSliceVar(&sans, "san", []string{"localhost", "127.0.0.1"}, "SAN entries (DNS or IP)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newFingerprintCmd(log *slog.Logger) *cobra.Command {
|
||||
var which string
|
||||
cmd := &cobra.Command{
|
||||
Use: "fingerprint",
|
||||
Short: "Print the SHA-256 fingerprint of ca.crt or server.crt",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var path string
|
||||
switch which {
|
||||
case "ca", "":
|
||||
path = CACertPath()
|
||||
case "server":
|
||||
path = ServerCertPath()
|
||||
default:
|
||||
return fmt.Errorf("--which must be 'ca' or 'server'")
|
||||
}
|
||||
fp, err := security.Fingerprint(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(cmd.OutOrStdout(), fp); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Debug("cert.fingerprint",
|
||||
slog.String("event", "cert.fingerprint"),
|
||||
slog.String("path", path),
|
||||
slog.String("cert_fp", fp),
|
||||
)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&which, "which", "ca", "which cert: 'ca' or 'server'")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// parseFirstCertDER decodes the first CERTIFICATE PEM block in pemBytes
|
||||
// and returns the DER bytes. Used by the cert cli for fingerprint calc
|
||||
// after a fresh issuance.
|
||||
func parseFirstCertDER(pemBytes []byte) []byte {
|
||||
block, _ := pem.Decode(pemBytes)
|
||||
if block == nil {
|
||||
return nil
|
||||
}
|
||||
return block.Bytes
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/daemon"
|
||||
"git.cloudinit.dev/coreci/orca/internal/engine"
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
var (
|
||||
daemonAddr string
|
||||
)
|
||||
|
||||
var daemonCmd = &cobra.Command{
|
||||
Use: "daemon",
|
||||
Short: "Run the orca daemon (HTTP API + health checks)",
|
||||
Long: "Start the orca daemon. Listens on the configured address for health, API, and dispatch requests.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
db, closer, err := openDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
|
||||
log := newLogger()
|
||||
srv := daemon.NewServer(daemon.Options{
|
||||
DB: db,
|
||||
Log: log,
|
||||
Addr: daemonAddr,
|
||||
Actor: "daemon",
|
||||
})
|
||||
|
||||
// Wire the orca.v1.Dispatch service (v0.2 P02). The executor
|
||||
// runs jobs locally; the dispatcher decides local vs peer.
|
||||
executor := engine.NewExecutor(store.NewJobRepo(db), store.NewTaskRepo(db), log)
|
||||
peers := engine.NewPeerRegistry()
|
||||
dispatcher := engine.NewDispatcher(log, store.NewCapacityRepo(db), peers, executor)
|
||||
srv.RegisterDispatch(daemon.NewDispatchHandlers(dispatcher, dispatcher.Dedupe()))
|
||||
|
||||
srv.MarkReady()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
err := srv.Start()
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ orca daemon listening on %s\n", daemonAddr)
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " /healthz - liveness")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " /readyz - readiness (db + ready flag)")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " /v1/status - status JSON")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " /v1/jobs - list jobs")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " /v1/nodes - list nodes")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " /v1/tasks - list tasks")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " /orca.v1.Dispatch/Submit - cross-node job submit (P02)")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " /orca.v1.Dispatch/Status - cross-node job status (P02)")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " press Ctrl+C to stop")
|
||||
|
||||
ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "\nshutting down...")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return srv.Shutdown(shutdownCtx)
|
||||
case err := <-errCh:
|
||||
return err
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
daemonCmd.Flags().StringVar(&daemonAddr, "addr", ":8080", "listen address")
|
||||
rootCmd.AddCommand(daemonCmd)
|
||||
_ = slog.Default // keep import if unused above
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/doctor"
|
||||
)
|
||||
|
||||
var doctorCmd = &cobra.Command{
|
||||
Use: "doctor",
|
||||
Short: "Run self-checks on the orca installation",
|
||||
Long: "Verify CA, server cert, expiry, fingerprint, network, and DB. Reports PASS/WARN/FAIL per check.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
report := doctor.Run(cmd.Context())
|
||||
if jsonOutput {
|
||||
return printJSON(report.Checks)
|
||||
}
|
||||
fmt.Fprint(cmd.OutOrStdout(), report.Print())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var doctorCertCmd = &cobra.Command{
|
||||
Use: "cert",
|
||||
Short: "Run only the cert self-checks",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
checks := []doctor.Check{
|
||||
doctor.CertCA(),
|
||||
doctor.CertServer(),
|
||||
doctor.CertExpiry(),
|
||||
doctor.CertFingerprint(),
|
||||
}
|
||||
results := make([]doctor.CheckResult, 0, len(checks))
|
||||
for _, c := range checks {
|
||||
r, msg := c.Run(cmd.Context())
|
||||
results = append(results, doctor.CheckResult{Name: c.Name, Result: r, Message: msg})
|
||||
}
|
||||
if jsonOutput {
|
||||
return printJSON(results)
|
||||
}
|
||||
for _, r := range results {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-5s %s\n", r.Name, r.Result, r.Message)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var doctorNetworkCmd = &cobra.Command{
|
||||
Use: "network",
|
||||
Short: "Run the network self-check (P02 impl)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c := doctor.Network()
|
||||
r, msg := c.Run(cmd.Context())
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-5s %s\n", c.Name, r, msg)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var doctorDBCmd = &cobra.Command{
|
||||
Use: "db",
|
||||
Short: "Run the database self-check (P02 impl)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c := doctor.DB()
|
||||
r, msg := c.Run(cmd.Context())
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-5s %s\n", c.Name, r, msg)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
doctorCmd.AddCommand(doctorCertCmd, doctorNetworkCmd, doctorDBCmd)
|
||||
rootCmd.AddCommand(doctorCmd)
|
||||
}
|
||||
+24
-282
@@ -1,22 +1,9 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/engine"
|
||||
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
var jobCmd = &cobra.Command{
|
||||
@@ -25,292 +12,46 @@ var jobCmd = &cobra.Command{
|
||||
Long: "Run, list, stop, and inspect orca jobs.",
|
||||
}
|
||||
|
||||
func jobExecutor() (*engine.Executor, func() error, error) {
|
||||
db, closer, err := openDB()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
jobs := store.NewJobRepo(db)
|
||||
tasks := store.NewTaskRepo(db)
|
||||
return engine.NewExecutor(jobs, tasks, newLogger()), closer, nil
|
||||
}
|
||||
|
||||
var (
|
||||
stopID string
|
||||
runTarget string
|
||||
runIDKey string
|
||||
jobWatch bool
|
||||
)
|
||||
|
||||
var jobRunCmd = &cobra.Command{
|
||||
Use: "run <spec.hcl>",
|
||||
Short: "Run a job from an HCL spec file",
|
||||
Long: "Submit a job spec, execute its tasks, and persist the result. Use --target to pin to a specific node (overrides bin-packing); --idempotency-key for cross-node dispatch dedupe.",
|
||||
Long: "Submit a job spec and execute it. Implemented in Phase 3.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
spec, err := jobspec.ParseFile(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
exec, closer, err := jobExecutor()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
|
||||
// If --target or --idempotency-key is set, route through the
|
||||
// dispatcher (which may land the job locally or on a peer
|
||||
// based on capacity).
|
||||
if runTarget != "" || runIDKey != "" {
|
||||
db, dbCloser, err := openDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dbCloser()
|
||||
peers := engine.NewPeerRegistry()
|
||||
dispatcher := engine.NewDispatcher(newLogger(), store.NewCapacityRepo(db), peers, exec)
|
||||
specBytes, _ := json.Marshal(map[string]any{
|
||||
"name": spec.Job.Name,
|
||||
"command": "/bin/true", // placeholder; full HCL dispatch lands in a later phase
|
||||
})
|
||||
jobID, nodeID, err := dispatcher.Submit(ctx, runTarget, specBytes, runIDKey)
|
||||
if err != nil {
|
||||
if jsonOutput {
|
||||
_ = printJSON(map[string]any{"status": "failed", "error": err.Error()})
|
||||
}
|
||||
return err
|
||||
}
|
||||
if jsonOutput {
|
||||
return printJSON(map[string]any{"id": jobID, "node_id": nodeID, "status": "dispatched"})
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ Job dispatched: %s to %s\n", jobID, nodeID)
|
||||
return nil
|
||||
}
|
||||
|
||||
job := &model.Job{
|
||||
ID: uuid.NewString(),
|
||||
Name: spec.Job.Name,
|
||||
Spec: args[0],
|
||||
Status: model.JobStatusPending,
|
||||
}
|
||||
if err := exec.Run(ctx, job, toTaskSpecs(spec.Tasks)); err != nil {
|
||||
if jsonOutput {
|
||||
_ = printJSON(map[string]any{"id": job.ID, "status": "failed", "error": err.Error()})
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "✗ Job %s failed: %v\n", job.ID, err)
|
||||
return err
|
||||
}
|
||||
if jsonOutput {
|
||||
return printJSON(map[string]any{"id": job.ID, "name": job.Name, "status": "complete"})
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ Job complete: %s (%s)\n", job.ID, job.Name)
|
||||
return nil
|
||||
return notImplemented("orca job run " + args[0])
|
||||
},
|
||||
}
|
||||
|
||||
var jobListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
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. Implemented in Phase 3.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if jobWatch {
|
||||
return watchJobs(cmd)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
db, closer, err := openDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
|
||||
jobs, err := store.NewJobRepo(db).List(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if jsonOutput {
|
||||
return printJSON(jobs)
|
||||
}
|
||||
if len(jobs) == 0 {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "No jobs. Use 'orca job run <spec.hcl>' to submit one.")
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%-36s %-20s %-12s %-8s\n", "ID", "NAME", "STATUS", "EXIT")
|
||||
for _, j := range jobs {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%-36s %-20s %-12s %-8d\n", j.ID, j.Name, j.Status, j.ExitCode)
|
||||
}
|
||||
return nil
|
||||
return notImplemented("orca job list")
|
||||
},
|
||||
}
|
||||
|
||||
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{
|
||||
Use: "stop [job-id]",
|
||||
Use: "stop <job-id>",
|
||||
Short: "Stop a running job",
|
||||
Long: "Mark a job as stopped. Note: this is a soft stop (cancel context for the daemon).",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Long: "Stop a job by ID. Implemented in Phase 3.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id := stopID
|
||||
if id == "" && len(args) > 0 {
|
||||
id = args[0]
|
||||
}
|
||||
if id == "" {
|
||||
return fmt.Errorf("job id required (--id or argument)")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
db, closer, err := openDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
|
||||
repo := store.NewJobRepo(db)
|
||||
job, err := repo.Get(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return fmt.Errorf("job not found: %s", id)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := repo.UpdateStatus(ctx, id, model.JobStatusStopped, 130); err != nil {
|
||||
return err
|
||||
}
|
||||
if jsonOutput {
|
||||
return printJSON(map[string]any{"id": id, "status": "stopped", "previous_status": job.Status})
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ Job stopped: %s\n", id)
|
||||
return nil
|
||||
return notImplemented("orca job stop " + args[0])
|
||||
},
|
||||
}
|
||||
|
||||
var jobLogsCmd = &cobra.Command{
|
||||
Use: "logs [job-id]",
|
||||
Short: "Show task output for a job",
|
||||
Long: "Display captured stdout/stderr for all tasks in a job.",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Use: "logs <job-id>",
|
||||
Short: "Show logs for a job",
|
||||
Long: "Display the logs for a job by ID. Implemented in Phase 3.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id := stopID
|
||||
if id == "" && len(args) > 0 {
|
||||
id = args[0]
|
||||
}
|
||||
if id == "" {
|
||||
return fmt.Errorf("job id required (--id or argument)")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
db, closer, err := openDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
|
||||
taskRepo := store.NewTaskRepo(db)
|
||||
tasks, err := taskRepo.ListByJob(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if jsonOutput {
|
||||
return printJSON(tasks)
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "No tasks for this job.")
|
||||
return nil
|
||||
}
|
||||
for i, t := range tasks {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "--- task[%d] %s (%s) exit=%d ---\n", i, t.Command, t.Status, t.ExitCode)
|
||||
if t.Stdout != "" {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), t.Stdout)
|
||||
}
|
||||
if t.Stderr != "" {
|
||||
fmt.Fprintln(cmd.OutOrStderr(), t.Stderr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return notImplemented("orca job logs " + args[0])
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
jobStopCmd.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(&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(jobListCmd)
|
||||
jobCmd.AddCommand(jobStopCmd)
|
||||
@@ -318,15 +59,16 @@ func init() {
|
||||
rootCmd.AddCommand(jobCmd)
|
||||
}
|
||||
|
||||
func toTaskSpecs(in []jobspec.TaskSpec) []engine.TaskSpec {
|
||||
out := make([]engine.TaskSpec, len(in))
|
||||
for i, t := range in {
|
||||
out[i] = engine.TaskSpec{
|
||||
Name: t.Name,
|
||||
Command: t.Command,
|
||||
Args: t.Args,
|
||||
Env: t.Env,
|
||||
}
|
||||
func notImplemented(cmd string) error {
|
||||
if jsonOutput {
|
||||
return printJSON(map[string]any{
|
||||
"command": cmd,
|
||||
"status": "not_implemented",
|
||||
"phase": "1-cli-skeleton",
|
||||
"next": "Phase 2-6 will implement this",
|
||||
})
|
||||
}
|
||||
return out
|
||||
fmt.Fprintf(rootCmd.ErrOrStderr(), "✗ %s: not yet implemented (Phase 1: CLI skeleton only)\n", cmd)
|
||||
fmt.Fprintf(rootCmd.ErrOrStderr(), " see .ciagent/ROADMAP.md for the full 6-phase plan\n")
|
||||
return fmt.Errorf("not implemented: %s", cmd)
|
||||
}
|
||||
|
||||
+15
-104
@@ -3,26 +3,30 @@ package cli
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
||||
"git.cloudinit.dev/coreci/orca/internal/engine"
|
||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||
"git.cloudinit.dev/coreci/orca/internal/security"
|
||||
"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) {
|
||||
db, err := store.Open(certpaths.DBPath())
|
||||
db, err := store.Open(dbPath())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -39,16 +43,13 @@ func nodeRegistry() (*engine.NodeRegistry, func() error, error) {
|
||||
return nil, nil, err
|
||||
}
|
||||
repo := store.NewNodeRepo(db)
|
||||
audit := engine.NewAudit(store.NewAuditRepo(db), newLogger())
|
||||
return engine.NewNodeRegistry(repo, audit, newLogger()), closer, nil
|
||||
return engine.NewNodeRegistry(repo, newLogger()), closer, nil
|
||||
}
|
||||
|
||||
var (
|
||||
joinName string
|
||||
joinAddr string
|
||||
joinCAFinger string
|
||||
leaveID string
|
||||
nodeWatch bool
|
||||
joinName string
|
||||
joinAddr string
|
||||
leaveID string
|
||||
)
|
||||
|
||||
var nodeCmd = &cobra.Command{
|
||||
@@ -68,24 +69,6 @@ var nodeJoinCmd = &cobra.Command{
|
||||
if joinAddr == "" {
|
||||
joinAddr = "localhost:8443"
|
||||
}
|
||||
|
||||
// REQ-026: if --ca-fingerprint is set, verify the on-disk CA
|
||||
// matches the pinned value before we touch the registry. This
|
||||
// prevents typos in the operator-supplied fingerprint from
|
||||
// silently degrading to "no pin" and accepting any cert.
|
||||
if joinCAFinger != "" {
|
||||
fp, err := security.Fingerprint(certpaths.CACertPath())
|
||||
if err != nil {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -150,11 +133,8 @@ var nodeLeaveCmd = &cobra.Command{
|
||||
var nodeListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
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 {
|
||||
if nodeWatch {
|
||||
return watchNodes(cmd)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -183,79 +163,10 @@ 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() {
|
||||
nodeJoinCmd.Flags().StringVar(&joinName, "name", "", "node name (required)")
|
||||
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")
|
||||
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(nodeLeaveCmd)
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
// node_capacity.go implements `orca node capacity` for v0.2 P02.
|
||||
// The capacity declaration is per-node (cpu_millicores, memory_mib,
|
||||
// disk_mib) and feeds the bin-packing scheduler.
|
||||
//
|
||||
// REQ-028: HCL/YAML schema for NodeCapacity — the CLI accepts the
|
||||
// three numeric flags and writes a row to the `node_capacity` table.
|
||||
// A future enhancement can read `~/.orca/node.hcl` at join time
|
||||
// (out of scope for P02).
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
var (
|
||||
capSetCPU int64
|
||||
capSetMem int64
|
||||
capSetDisk int64
|
||||
capNodeID string
|
||||
)
|
||||
|
||||
var nodeCapacityCmd = &cobra.Command{
|
||||
Use: "capacity",
|
||||
Short: "Manage node capacity declarations (P02 bin-packing input)",
|
||||
Long: "Read or write the per-node capacity used by the multi-node scheduler.",
|
||||
}
|
||||
|
||||
var nodeCapacityShowCmd = &cobra.Command{
|
||||
Use: "show [node-id]",
|
||||
Short: "Show capacity for a node (defaults to 'self')",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id := capNodeID
|
||||
if id == "" && len(args) > 0 {
|
||||
id = args[0]
|
||||
}
|
||||
if id == "" {
|
||||
id = "self"
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
db, closer, err := openDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
repo := store.NewCapacityRepo(db)
|
||||
c, err := repo.Get(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("node %s: %w (use `orca node capacity --set` to declare)", id, err)
|
||||
}
|
||||
if jsonOutput {
|
||||
return printJSON(c)
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Node: %s\n", c.NodeID)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "CPU: %d millicores\n", c.CPUMillicores)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Memory: %d MiB\n", c.MemoryMiB)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Disk: %d MiB\n", c.DiskMiB)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Updated: %s\n", c.UpdatedAt.UTC().Format(time.RFC3339))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var nodeCapacitySetCmd = &cobra.Command{
|
||||
Use: "set",
|
||||
Short: "Declare capacity for a node (used by bin-packing)",
|
||||
Long: "Write cpu_millicores, memory_mib, and disk_mib for the named node. Idempotent: subsequent calls overwrite.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if capSetCPU <= 0 || capSetMem <= 0 || capSetDisk <= 0 {
|
||||
return fmt.Errorf("--cpu, --memory, and --disk must all be positive")
|
||||
}
|
||||
id := capNodeID
|
||||
if id == "" {
|
||||
id = "self"
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
db, closer, err := openDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
repo := store.NewCapacityRepo(db)
|
||||
c := &store.NodeCapacity{
|
||||
NodeID: id,
|
||||
CPUMillicores: capSetCPU,
|
||||
MemoryMiB: capSetMem,
|
||||
DiskMiB: capSetDisk,
|
||||
}
|
||||
if err := repo.Upsert(ctx, c); err != nil {
|
||||
return err
|
||||
}
|
||||
if jsonOutput {
|
||||
return printJSON(c)
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ Capacity set for %s: cpu=%d mem=%d disk=%d\n",
|
||||
c.NodeID, c.CPUMillicores, c.MemoryMiB, c.DiskMiB)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var nodeCapacityListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all node capacity declarations",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
db, closer, err := openDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
repo := store.NewCapacityRepo(db)
|
||||
rows, err := repo.List(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if jsonOutput {
|
||||
return printJSON(rows)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "No capacity declarations. Use `orca node capacity --set` to add one.")
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %12s %12s %12s %s\n", "NODE", "CPU(mc)", "MEM(MiB)", "DISK(MiB)", "UPDATED")
|
||||
for _, c := range rows {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %12d %12d %12d %s\n",
|
||||
c.NodeID, c.CPUMillicores, c.MemoryMiB, c.DiskMiB, c.UpdatedAt.UTC().Format(time.RFC3339))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
nodeCapacitySetCmd.Flags().Int64Var(&capSetCPU, "cpu", 0, "CPU capacity in millicores (1000 = 1 vCPU)")
|
||||
nodeCapacitySetCmd.Flags().Int64Var(&capSetMem, "memory", 0, "Memory capacity in MiB")
|
||||
nodeCapacitySetCmd.Flags().Int64Var(&capSetDisk, "disk", 0, "Disk capacity in MiB")
|
||||
nodeCapacitySetCmd.Flags().StringVar(&capNodeID, "node", "", "node id (defaults to 'self')")
|
||||
nodeCapacityShowCmd.Flags().StringVar(&capNodeID, "node", "", "node id (defaults to 'self')")
|
||||
|
||||
nodeCapacityCmd.AddCommand(nodeCapacityShowCmd, nodeCapacitySetCmd, nodeCapacityListCmd)
|
||||
nodeCmd.AddCommand(nodeCapacityCmd)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
// Package daemon — dispatch_handler.go mounts the orca.v1.Dispatch
|
||||
// service on the daemon's HTTP server. The service is registered as
|
||||
// two handlers (POST /orca.v1.Dispatch/Submit and /Status) and is
|
||||
// gated on the mTLS state — if the server is in plaintext mode
|
||||
// (v0.1 compat), the handlers refuse to serve.
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/transport"
|
||||
)
|
||||
|
||||
// DispatchHandlers groups the Submit and Status handlers so they
|
||||
// can be registered as a unit on the daemon mux.
|
||||
type DispatchHandlers struct {
|
||||
Submit *transport.SubmitHandler
|
||||
Status *transport.StatusHandler
|
||||
}
|
||||
|
||||
// NewDispatchHandlers builds the dispatch handler pair from a
|
||||
// transport.Dispatcher (the engine layer satisfies this).
|
||||
func NewDispatchHandlers(d transport.Dispatcher, dedupe *transport.IdempotencyStore) *DispatchHandlers {
|
||||
if dedupe == nil {
|
||||
dedupe = transport.NewIdempotencyStore()
|
||||
}
|
||||
return &DispatchHandlers{
|
||||
Submit: transport.NewSubmitHandler(d, dedupe),
|
||||
Status: transport.NewStatusHandler(d),
|
||||
}
|
||||
}
|
||||
|
||||
// Mount registers Submit and Status on the given mux. Called by the
|
||||
// daemon's mux builder.
|
||||
func (h *DispatchHandlers) Mount(mux *http.ServeMux) {
|
||||
mux.Handle("/orca.v1.Dispatch/Submit", h.Submit)
|
||||
mux.Handle("/orca.v1.Dispatch/Status", h.Status)
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
// Package daemon — dispatch_test.go exercises the orca.v1.Dispatch
|
||||
// round-trip end-to-end: a SubmitHandler is mounted on a test server
|
||||
// and a DispatchClient dials it. The test asserts the spec flows
|
||||
// through, the job ID is returned, and dedupe (X-Orca-Idempotency-Key)
|
||||
// works.
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/transport"
|
||||
)
|
||||
|
||||
// stubDispatcher is a transport.Dispatcher for tests. It records
|
||||
// every Submit and Status call and returns deterministic responses.
|
||||
type stubDispatcher struct {
|
||||
mu sync.Mutex
|
||||
submits [][]byte
|
||||
statuses []string
|
||||
nextJobID int
|
||||
failSubmit bool
|
||||
}
|
||||
|
||||
func (s *stubDispatcher) LocalSubmit(_ context.Context, spec []byte) (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.failSubmit {
|
||||
return "", fmt.Errorf("submit failed (test)")
|
||||
}
|
||||
cp := make([]byte, len(spec))
|
||||
copy(cp, spec)
|
||||
s.submits = append(s.submits, cp)
|
||||
s.nextJobID++
|
||||
return fmt.Sprintf("job-%d", s.nextJobID), nil
|
||||
}
|
||||
|
||||
func (s *stubDispatcher) LocalStatus(_ context.Context, jobID string) (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.statuses = append(s.statuses, jobID)
|
||||
return "running", nil
|
||||
}
|
||||
|
||||
func TestDispatchRoundTrip(t *testing.T) {
|
||||
stub := &stubDispatcher{}
|
||||
dedupe := transport.NewIdempotencyStore()
|
||||
handlers := NewDispatchHandlers(stub, dedupe)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
handlers.Mount(mux)
|
||||
ts := httptest.NewServer(mux)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
// Submit a spec wrapped in the SubmitRequest envelope.
|
||||
// The wire format is {"spec": <json.RawMessage>}; the inner
|
||||
// spec is opaque to the dispatch service and is parsed by the
|
||||
// local executor downstream.
|
||||
inner := []byte(`{"name":"hello","command":"/bin/echo","args":["hi"],"env":[]}`)
|
||||
wire, _ := json.Marshal(transport.SubmitRequest{Spec: inner})
|
||||
resp, err := http.Post(ts.URL+"/orca.v1.Dispatch/Submit", "application/json", bytes.NewReader(wire))
|
||||
if err != nil {
|
||||
t.Fatalf("Submit: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("Submit status: got %d, want 200", resp.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var sr transport.SubmitResponse
|
||||
if err := json.Unmarshal(body, &sr); err != nil {
|
||||
t.Fatalf("decode Submit response: %v", err)
|
||||
}
|
||||
if sr.JobID == "" {
|
||||
t.Fatal("Submit response missing job_id")
|
||||
}
|
||||
if len(stub.submits) != 1 {
|
||||
t.Errorf("LocalSubmit calls: got %d, want 1", len(stub.submits))
|
||||
}
|
||||
|
||||
// Status query.
|
||||
statusReq := transport.StatusRequest{JobID: sr.JobID}
|
||||
body2, _ := json.Marshal(statusReq)
|
||||
resp2, err := http.Post(ts.URL+"/orca.v1.Dispatch/Status", "application/json", bytes.NewReader(body2))
|
||||
if err != nil {
|
||||
t.Fatalf("Status: %v", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
t.Fatalf("Status code: got %d, want 200", resp2.StatusCode)
|
||||
}
|
||||
var stResp transport.StatusResponse
|
||||
if err := json.NewDecoder(resp2.Body).Decode(&stResp); err != nil {
|
||||
t.Fatalf("decode Status: %v", err)
|
||||
}
|
||||
if stResp.State != "running" {
|
||||
t.Errorf("Status.State: got %q, want running", stResp.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchIdempotencyDedupe(t *testing.T) {
|
||||
stub := &stubDispatcher{}
|
||||
dedupe := transport.NewIdempotencyStore()
|
||||
handlers := NewDispatchHandlers(stub, dedupe)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
handlers.Mount(mux)
|
||||
ts := httptest.NewServer(mux)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
inner := []byte(`{"name":"hello","command":"/bin/echo","args":["hi"]}`)
|
||||
wire, _ := json.Marshal(transport.SubmitRequest{Spec: inner})
|
||||
post := func() string {
|
||||
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/orca.v1.Dispatch/Submit", bytes.NewReader(wire))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set(transport.IdempotencyHeader, "key-42")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Submit: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// First call: real submit, LocalSubmit invoked.
|
||||
first := post()
|
||||
var sr1 transport.SubmitResponse
|
||||
if err := json.Unmarshal([]byte(first), &sr1); err != nil {
|
||||
t.Fatalf("decode 1: %v", err)
|
||||
}
|
||||
if len(stub.submits) != 1 {
|
||||
t.Errorf("after first call: submits=%d, want 1", len(stub.submits))
|
||||
}
|
||||
|
||||
// Second call: same key, dedupe replay.
|
||||
second := post()
|
||||
var sr2 transport.SubmitResponse
|
||||
if err := json.Unmarshal([]byte(second), &sr2); err != nil {
|
||||
t.Fatalf("decode 2: %v", err)
|
||||
}
|
||||
if sr1.JobID != sr2.JobID {
|
||||
t.Errorf("dedupe: first=%s, second=%s (should match)", sr1.JobID, sr2.JobID)
|
||||
}
|
||||
if len(stub.submits) != 1 {
|
||||
t.Errorf("after second call: submits=%d, want 1 (dedupe)", len(stub.submits))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchSubmitValidation(t *testing.T) {
|
||||
stub := &stubDispatcher{}
|
||||
handlers := NewDispatchHandlers(stub, transport.NewIdempotencyStore())
|
||||
mux := http.NewServeMux()
|
||||
handlers.Mount(mux)
|
||||
ts := httptest.NewServer(mux)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
// Empty spec: 400.
|
||||
resp, _ := http.Post(ts.URL+"/orca.v1.Dispatch/Submit", "application/json", bytes.NewReader([]byte(`{}`)))
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("empty spec: status=%d, want 400", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// GET instead of POST: 405.
|
||||
resp2, _ := http.Get(ts.URL + "/orca.v1.Dispatch/Submit")
|
||||
if resp2.StatusCode != http.StatusMethodNotAllowed {
|
||||
t.Errorf("GET: status=%d, want 405", resp2.StatusCode)
|
||||
}
|
||||
resp2.Body.Close()
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// handleHealthz reports liveness. It does NOT check dependencies — by design,
|
||||
// a process that can answer this is "alive" even if its DB is wedged. Use
|
||||
// /readyz for dependency health.
|
||||
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "alive",
|
||||
"time": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// handleReadyz reports readiness. Returns 503 if either:
|
||||
// - MarkReady has not been called, OR
|
||||
// - the SQLite database cannot be pinged within 2s.
|
||||
//
|
||||
// Distinguishing these cases in the response body helps operators triage.
|
||||
func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !s.ready.Load() {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]any{
|
||||
"status": "not_ready",
|
||||
"reason": "daemon not marked ready",
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := s.db.PingContext(ctx); err != nil {
|
||||
s.log.Warn("readyz db ping failed",
|
||||
slog.String("component", "daemon"),
|
||||
slog.String("error", err.Error()))
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]any{
|
||||
"status": "not_ready",
|
||||
"reason": "db ping failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ready",
|
||||
"db": "ok",
|
||||
})
|
||||
}
|
||||
|
||||
// handleStatus returns a small diagnostic JSON blob. Cheap to call; does
|
||||
// NOT touch the database unless we want a DB status check, in which case
|
||||
// the ping is bounded by 2s.
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
dbStatus := "ok"
|
||||
if err := s.db.PingContext(ctx); err != nil {
|
||||
dbStatus = "error"
|
||||
s.log.Warn("status db ping failed",
|
||||
slog.String("component", "daemon"),
|
||||
slog.String("error", err.Error()))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"version": Version,
|
||||
"phase": "5-health-checks",
|
||||
"milestone": "v0.1",
|
||||
"db": dbStatus,
|
||||
"ready": s.ready.Load(),
|
||||
"time": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// writeJSON encodes body as JSON with the given status code.
|
||||
// Errors during encoding are logged but not surfaced — we cannot write
|
||||
// another header after the response has started.
|
||||
func writeJSON(w http.ResponseWriter, code int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
// writeError emits a uniform error envelope: {"error": "<message>"}.
|
||||
func writeError(w http.ResponseWriter, code int, msg string) {
|
||||
writeJSON(w, code, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// loggingMiddleware wraps the mux with a structured access log. It does
|
||||
// NOT log request/response bodies (could contain secrets); just method,
|
||||
// path, status, and duration.
|
||||
func loggingMiddleware(log *slog.Logger, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
ww := &statusRecorder{ResponseWriter: w, status: 200}
|
||||
next.ServeHTTP(ww, r)
|
||||
log.Info("http",
|
||||
slog.String("method", r.Method),
|
||||
slog.String("path", r.URL.Path),
|
||||
slog.Int("status", ww.status),
|
||||
slog.Duration("dur", time.Since(start)),
|
||||
slog.String("remote", r.RemoteAddr),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (s *statusRecorder) WriteHeader(code int) {
|
||||
s.status = code
|
||||
s.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
func newTestServer(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
s := NewServer(Options{DB: db, Log: nil, Addr: "127.0.0.1:0"})
|
||||
s.MarkReady()
|
||||
return s
|
||||
}
|
||||
|
||||
func TestHealthzReturns200(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/healthz", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
s.mux().ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != 200 {
|
||||
t.Errorf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(rr.Body).Decode(&body)
|
||||
if body["status"] != "alive" {
|
||||
t.Errorf("expected status alive, got %v", body["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadyzReturns200WhenReady(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s.MarkReady()
|
||||
req := httptest.NewRequest("GET", "/readyz", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
s.mux().ServeHTTP(rr, req)
|
||||
if rr.Code != 200 {
|
||||
t.Errorf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadyzReturns503WhenNotReady(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s.MarkNotReady()
|
||||
req := httptest.NewRequest("GET", "/readyz", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
s.mux().ServeHTTP(rr, req)
|
||||
if rr.Code != 503 {
|
||||
t.Errorf("expected 503, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusReturns200(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s.MarkReady()
|
||||
req := httptest.NewRequest("GET", "/v1/status", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
s.mux().ServeHTTP(rr, req)
|
||||
if rr.Code != 200 {
|
||||
t.Errorf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(rr.Body).Decode(&body)
|
||||
if body["db"] != "ok" {
|
||||
t.Errorf("expected db ok, got %v", body["db"])
|
||||
}
|
||||
if body["milestone"] != "v0.1" {
|
||||
t.Errorf("expected milestone v0.1, got %v", body["milestone"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobsCollectionEmpty(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/v1/jobs", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
s.mux().ServeHTTP(rr, req)
|
||||
if rr.Code != 200 {
|
||||
t.Errorf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(rr.Body).Decode(&body)
|
||||
if body["count"].(float64) != 0 {
|
||||
t.Errorf("expected count 0, got %v", body["count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobsCollectionMethodNotAllowed(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
req := httptest.NewRequest("PUT", "/v1/jobs", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
s.mux().ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected 405, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobsItemNotFound(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/v1/jobs/nonexistent", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
s.mux().ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobsItemInvalidID(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/v1/jobs/has%20space", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
s.mux().ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodesCollectionEmpty(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/v1/nodes", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
s.mux().ServeHTTP(rr, req)
|
||||
if rr.Code != 200 {
|
||||
t.Errorf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(rr.Body).Decode(&body)
|
||||
if body["count"].(float64) != 0 {
|
||||
t.Errorf("expected count 0, got %v", body["count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTasksCollectionEmpty(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/v1/tasks", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
s.mux().ServeHTTP(rr, req)
|
||||
if rr.Code != 200 {
|
||||
t.Errorf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTasksCollectionInvalidLimit(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/v1/tasks?limit=abc", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
s.mux().ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateID(t *testing.T) {
|
||||
cases := []struct {
|
||||
id string
|
||||
valid bool
|
||||
}{
|
||||
{"abc-123", true},
|
||||
{"550e8400-e29b-41d4-a716-446655440000", true},
|
||||
{"a", true},
|
||||
{"", false},
|
||||
{"has space", false},
|
||||
{"with/slash", false},
|
||||
{"../etc/passwd", false},
|
||||
{string([]byte{0x00, 'a'}), false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
err := validateID(c.id)
|
||||
if (err == nil) != c.valid {
|
||||
t.Errorf("validateID(%q): valid=%v, err=%v", c.id, c.valid, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
// handleJobsCollection handles /v1/jobs.
|
||||
// - GET → list all jobs
|
||||
// - POST → not yet supported (job submission is CLI-only in v0.1)
|
||||
func (s *Server) handleJobsCollection(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
jobs, err := store.NewJobRepo(s.db).List(ctx)
|
||||
if err != nil {
|
||||
s.log.Error("list jobs",
|
||||
slog.String("component", "daemon"),
|
||||
slog.String("error", err.Error()))
|
||||
writeError(w, http.StatusInternalServerError, "failed to list jobs")
|
||||
return
|
||||
}
|
||||
if jobs == nil {
|
||||
jobs = []*model.Job{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"jobs": jobs, "count": len(jobs)})
|
||||
|
||||
case http.MethodPost:
|
||||
// Job submission via HTTP is intentionally not exposed in v0.1.
|
||||
// The CLI submits jobs to the local store directly; the daemon
|
||||
// exists for observability and lifecycle control.
|
||||
writeError(w, http.StatusNotImplemented, "job submission via API is not supported in v0.1; use 'orca job run'")
|
||||
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
// handleJobsItem handles /v1/jobs/{id} and /v1/jobs/{id}/tasks.
|
||||
// - GET /v1/jobs/{id} → job details
|
||||
// - GET /v1/jobs/{id}/tasks → tasks for a job
|
||||
func (s *Server) handleJobsItem(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Path is /v1/jobs/{id} or /v1/jobs/{id}/tasks
|
||||
path := strings.TrimPrefix(r.URL.Path, "/v1/jobs/")
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) == 0 || parts[0] == "" {
|
||||
writeError(w, http.StatusBadRequest, "job id required")
|
||||
return
|
||||
}
|
||||
id := parts[0]
|
||||
if err := validateID(id); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// /v1/jobs/{id}/tasks
|
||||
if len(parts) == 2 && parts[1] == "tasks" {
|
||||
tasks, err := store.NewTaskRepo(s.db).ListByJob(ctx, id)
|
||||
if err != nil {
|
||||
s.log.Error("list tasks for job",
|
||||
slog.String("component", "daemon"),
|
||||
slog.String("job_id", id),
|
||||
slog.String("error", err.Error()))
|
||||
writeError(w, http.StatusInternalServerError, "failed to list tasks")
|
||||
return
|
||||
}
|
||||
if tasks == nil {
|
||||
tasks = []*model.Task{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks, "count": len(tasks), "job_id": id})
|
||||
return
|
||||
}
|
||||
|
||||
// /v1/jobs/{id} (with no further path)
|
||||
if len(parts) != 1 {
|
||||
writeError(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
job, err := store.NewJobRepo(s.db).Get(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "job not found")
|
||||
return
|
||||
}
|
||||
s.log.Error("get job",
|
||||
slog.String("component", "daemon"),
|
||||
slog.String("job_id", id),
|
||||
slog.String("error", err.Error()))
|
||||
writeError(w, http.StatusInternalServerError, "failed to get job")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, job)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
// handleNodesCollection handles /v1/nodes (GET only in v0.1).
|
||||
// Node registration is CLI-only; the API is read-only for observability.
|
||||
func (s *Server) handleNodesCollection(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
nodes, err := store.NewNodeRepo(s.db).List(ctx)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list nodes")
|
||||
return
|
||||
}
|
||||
if nodes == nil {
|
||||
nodes = []*model.Node{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"nodes": nodes, "count": len(nodes)})
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
// Package daemon implements the orca HTTP daemon.
|
||||
//
|
||||
// The daemon exposes health endpoints (/healthz, /readyz), a status endpoint
|
||||
// (/v1/status), and a v1 resource API for jobs, nodes, and tasks. All handlers
|
||||
// follow the project conventions:
|
||||
//
|
||||
// - context.Context propagated to all I/O
|
||||
// - errors wrapped with %w
|
||||
// - structured JSON via writeJSON
|
||||
// - no secrets in logs
|
||||
// - input validation on path/query/body
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Server is the orca HTTP daemon. It holds shared dependencies and lifecycle
|
||||
// state. Construct it with NewServer, then call Start/Shutdown.
|
||||
type Server struct {
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
addr string
|
||||
ready atomic.Bool
|
||||
|
||||
httpServer *http.Server
|
||||
|
||||
// mtls is non-nil after StartMTLS has been called; nil otherwise.
|
||||
// Plaintext HTTP and mTLS are mutually exclusive — a Server is
|
||||
// either in plaintext mode (default, v0.1 compat) or mTLS mode
|
||||
// (v0.2 P01 forward).
|
||||
mtls *MTLSState
|
||||
|
||||
// dispatch is the orca.v1.Dispatch service mounted on
|
||||
// /orca.v1.Dispatch/* (P02). Optional — nil if no Dispatcher
|
||||
// was registered. P02 wires this via RegisterDispatch.
|
||||
dispatch *DispatchHandlers
|
||||
}
|
||||
|
||||
// Options configures a new Server.
|
||||
type Options struct {
|
||||
DB *sql.DB
|
||||
Log *slog.Logger
|
||||
Addr string
|
||||
Actor string // used for audit logging from API requests
|
||||
}
|
||||
|
||||
// NewServer constructs a Server with the default mux and route table.
|
||||
func NewServer(opts Options) *Server {
|
||||
if opts.Log == nil {
|
||||
opts.Log = slog.Default()
|
||||
}
|
||||
if opts.Addr == "" {
|
||||
opts.Addr = ":8080"
|
||||
}
|
||||
if opts.Actor == "" {
|
||||
opts.Actor = "api"
|
||||
}
|
||||
s := &Server{
|
||||
db: opts.DB,
|
||||
log: opts.Log,
|
||||
addr: opts.Addr,
|
||||
}
|
||||
s.httpServer = &http.Server{
|
||||
Addr: opts.Addr,
|
||||
Handler: s.mux(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Addr returns the configured listen address.
|
||||
func (s *Server) Addr() string { return s.addr }
|
||||
|
||||
// MarkReady flips the readiness flag to true. The /readyz endpoint returns
|
||||
// 200 only when this flag is set AND the database is reachable.
|
||||
func (s *Server) MarkReady() { s.ready.Store(true) }
|
||||
|
||||
// MarkNotReady flips the readiness flag to false. Called at shutdown start
|
||||
// so load balancers stop routing traffic.
|
||||
func (s *Server) MarkNotReady() { s.ready.Store(false) }
|
||||
|
||||
// Ready reports the current readiness flag.
|
||||
func (s *Server) Ready() bool { return s.ready.Load() }
|
||||
|
||||
// mux builds the route table. Handlers are split across files:
|
||||
// - health.go /healthz, /readyz, /v1/status
|
||||
// - jobs_handler.go /v1/jobs/*
|
||||
// - nodes_handler.go /v1/nodes/*
|
||||
// - tasks_handler.go /v1/tasks/*
|
||||
// - dispatch_handler.go /orca.v1.Dispatch/* (P02; mounted only if
|
||||
// RegisterDispatch was called)
|
||||
func (s *Server) mux() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", s.handleHealthz)
|
||||
mux.HandleFunc("/readyz", s.handleReadyz)
|
||||
mux.HandleFunc("/v1/status", s.handleStatus)
|
||||
mux.HandleFunc("/v1/jobs", s.handleJobsCollection)
|
||||
mux.HandleFunc("/v1/jobs/", s.handleJobsItem)
|
||||
mux.HandleFunc("/v1/nodes", s.handleNodesCollection)
|
||||
mux.HandleFunc("/v1/tasks", s.handleTasksCollection)
|
||||
if s.dispatch != nil {
|
||||
s.dispatch.Mount(mux)
|
||||
}
|
||||
return loggingMiddleware(s.log, mux)
|
||||
}
|
||||
|
||||
// RegisterDispatch attaches the orca.v1.Dispatch service to the
|
||||
// daemon. Call before Start(). The dispatch routes are mounted at
|
||||
// /orca.v1.Dispatch/Submit and /orca.v1.Dispatch/Status.
|
||||
func (s *Server) RegisterDispatch(h *DispatchHandlers) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
s.dispatch = h
|
||||
s.log.Info("dispatch handlers registered",
|
||||
slog.String("component", "daemon"),
|
||||
slog.String("submit", "/orca.v1.Dispatch/Submit"),
|
||||
slog.String("status", "/orca.v1.Dispatch/Status"),
|
||||
)
|
||||
}
|
||||
|
||||
// Start runs the HTTP server. Returns http.ErrServerClosed on clean shutdown.
|
||||
func (s *Server) Start() error {
|
||||
s.log.Info("daemon starting",
|
||||
slog.String("addr", s.addr),
|
||||
slog.String("component", "daemon"))
|
||||
return s.httpServer.ListenAndServe()
|
||||
}
|
||||
|
||||
// Shutdown gracefully stops the server, bounded by ctx. It also flips the
|
||||
// readiness flag to false so /readyz returns 503 immediately.
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
s.MarkNotReady()
|
||||
s.log.Info("daemon shutting down", slog.String("component", "daemon"))
|
||||
return s.httpServer.Shutdown(ctx)
|
||||
}
|
||||
|
||||
// IsShutdownErr reports whether err is the expected error from a stopped server.
|
||||
func IsShutdownErr(err error) bool {
|
||||
return errors.Is(err, http.ErrServerClosed)
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
func TestServerLifecycle(t *testing.T) {
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "lifecycle.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
s := NewServer(Options{DB: db, Addr: "127.0.0.1:0"})
|
||||
s.MarkReady()
|
||||
|
||||
if !s.Ready() {
|
||||
t.Error("expected server ready after MarkReady")
|
||||
}
|
||||
s.MarkNotReady()
|
||||
if s.Ready() {
|
||||
t.Error("expected server not ready after MarkNotReady")
|
||||
}
|
||||
s.MarkReady()
|
||||
|
||||
// Bind an ephemeral listener and serve on it directly.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
addr := ln.Addr().String()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
err := s.httpServer.Serve(ln)
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errCh <- err
|
||||
}
|
||||
close(errCh)
|
||||
}()
|
||||
|
||||
// Verify healthz responds.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
c := http.Client{Timeout: 200 * time.Millisecond}
|
||||
r, err := c.Get("http://" + addr + "/healthz")
|
||||
if err == nil {
|
||||
_ = r.Body.Close()
|
||||
if r.StatusCode == 200 {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
|
||||
resp, err := http.Get("http://" + addr + "/healthz")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /healthz: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if !strings.Contains(string(body), `"alive"`) {
|
||||
t.Errorf("expected alive status in body, got %s", string(body))
|
||||
}
|
||||
|
||||
// readyz returns 200 when ready.
|
||||
resp, err = http.Get("http://" + addr + "/readyz")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /readyz: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// /v1/jobs returns JSON
|
||||
resp, err = http.Get("http://" + addr + "/v1/jobs")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /v1/jobs: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
||||
t.Errorf("expected JSON content-type, got %s", ct)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// /v1/nodes returns JSON
|
||||
resp, err = http.Get("http://" + addr + "/v1/nodes")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /v1/nodes: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// /v1/tasks returns JSON
|
||||
resp, err = http.Get("http://" + addr + "/v1/tasks")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /v1/tasks: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// Shutdown cleanly.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := s.Shutdown(ctx); err != nil {
|
||||
t.Errorf("shutdown: %v", err)
|
||||
}
|
||||
if s.Ready() {
|
||||
t.Error("expected not-ready after shutdown")
|
||||
}
|
||||
|
||||
// Server should report ErrServerClosed or nil.
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
t.Errorf("expected nil or ErrServerClosed, got %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("server did not exit after Shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsShutdownErr(t *testing.T) {
|
||||
if !IsShutdownErr(http.ErrServerClosed) {
|
||||
t.Error("expected IsShutdownErr(http.ErrServerClosed) to be true")
|
||||
}
|
||||
if IsShutdownErr(errors.New("other")) {
|
||||
t.Error("expected false for other errors")
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
// handleTasksCollection handles /v1/tasks (GET only).
|
||||
// Optional query param: ?job_id=<id> to filter by job.
|
||||
// Optional: ?limit=<n> (default 100, max 1000).
|
||||
func (s *Server) handleTasksCollection(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
jobID := r.URL.Query().Get("job_id")
|
||||
if jobID != "" {
|
||||
if err := validateID(jobID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
limit := 100
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid limit")
|
||||
return
|
||||
}
|
||||
if n > 1000 {
|
||||
n = 1000
|
||||
}
|
||||
limit = n
|
||||
}
|
||||
|
||||
repo := store.NewTaskRepo(s.db)
|
||||
var tasks []*model.Task
|
||||
var err error
|
||||
if jobID != "" {
|
||||
tasks, err = repo.ListByJob(ctx, jobID)
|
||||
} else {
|
||||
tasks, err = repo.ListRecent(ctx, limit)
|
||||
}
|
||||
if err != nil {
|
||||
s.log.Error("list tasks",
|
||||
slog.String("component", "daemon"),
|
||||
slog.String("job_id", jobID),
|
||||
slog.String("error", err.Error()))
|
||||
writeError(w, http.StatusInternalServerError, "failed to list tasks")
|
||||
return
|
||||
}
|
||||
if tasks == nil {
|
||||
tasks = []*model.Task{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks, "count": len(tasks)})
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/security"
|
||||
)
|
||||
|
||||
// MTLSState holds the runtime state for the mTLS server. The hot-swap
|
||||
// mechanism works by reading cert/key from disk + (optionally) the cert
|
||||
// repo on every TLS handshake, so `orca cert renew` can write a new
|
||||
// server.crt / server.key and the daemon picks it up without a restart.
|
||||
//
|
||||
// The actual handshake callback (`GetCertificate`) is set on the tls.Config
|
||||
// by StartMTLS.
|
||||
type MTLSState struct {
|
||||
CertPath string
|
||||
KeyPath string
|
||||
CAPath string
|
||||
|
||||
Log *slog.Logger
|
||||
|
||||
// mu guards the timestamp / counter so concurrent reads of the
|
||||
// on-disk cert are well-defined and we can log rotation events.
|
||||
mu sync.Mutex
|
||||
lastModTime time.Time
|
||||
}
|
||||
|
||||
// NewMTLSState validates the on-disk cert/key/CA paths and returns a
|
||||
// state struct. Fails fast if the CA cert is missing or unreadable — the
|
||||
// daemon must not start in mTLS mode without a CA.
|
||||
func NewMTLSState(certPath, keyPath, caPath string, log *slog.Logger) (*MTLSState, error) {
|
||||
if certPath == "" || keyPath == "" || caPath == "" {
|
||||
return nil, errors.New("NewMTLSState: certPath, keyPath, and caPath are all required")
|
||||
}
|
||||
for _, p := range []string{certPath, keyPath, caPath} {
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
return nil, fmt.Errorf("NewMTLSState: stat %s: %w", p, err)
|
||||
}
|
||||
}
|
||||
// Enforce CA file modes (REQ-033) at daemon start so we fail fast.
|
||||
caDir := caPath[:max(0, lastSep(caPath))]
|
||||
if err := security.EnforceFileModes(caDir); err != nil {
|
||||
return nil, fmt.Errorf("NewMTLSState: %w", err)
|
||||
}
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &MTLSState{
|
||||
CertPath: certPath,
|
||||
KeyPath: keyPath,
|
||||
CAPath: caPath,
|
||||
Log: log,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetCertificate returns the tls.Certificate to present for a given
|
||||
// ClientHelloInfo. It reloads the cert from disk on every call so that
|
||||
// `orca cert renew` (which writes a new server.crt / server.key) takes
|
||||
// effect without a daemon restart. REQ-034's hot-swap requirement.
|
||||
//
|
||||
// The reload is cheap — PEM decode is microseconds for typical cert
|
||||
// sizes. The callback runs once per handshake; concurrency is fine.
|
||||
func (m *MTLSState) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
cert, err := tls.LoadX509KeyPair(m.CertPath, m.KeyPath)
|
||||
if err != nil {
|
||||
m.Log.Warn("mtls cert load failed (will fail handshake)",
|
||||
slog.String("cert", m.CertPath),
|
||||
slog.String("key", m.KeyPath),
|
||||
slog.String("err", err.Error()))
|
||||
return nil, err
|
||||
}
|
||||
cert.Leaf, err = x509.ParseCertificate(cert.Certificate[0])
|
||||
if err != nil {
|
||||
// Not fatal — stdlib falls back to the raw cert. Log a warning.
|
||||
m.Log.Warn("mtls leaf parse failed (non-fatal)",
|
||||
slog.String("err", err.Error()))
|
||||
}
|
||||
m.touch()
|
||||
return &cert, nil
|
||||
}
|
||||
|
||||
// touch updates the last-modified timestamp; primarily for tests.
|
||||
func (m *MTLSState) touch() {
|
||||
m.mu.Lock()
|
||||
m.lastModTime = time.Now()
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// LastReload returns the timestamp of the most recent successful reload
|
||||
// from disk. Exposed for tests / health endpoints.
|
||||
func (m *MTLSState) LastReload() time.Time {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.lastModTime
|
||||
}
|
||||
|
||||
// StartMTLS reconfigures the existing http.Server to serve over TLS using
|
||||
// the given state. The Server's httpServer field is mutated in place;
|
||||
// callers that already have a goroutine running s.httpServer.Serve should
|
||||
// shut it down first and then call StartMTLS, then re-serve.
|
||||
//
|
||||
// We also flip a flag so health endpoints can introspect mTLS state.
|
||||
func (s *Server) StartMTLS(state *MTLSState) error {
|
||||
if state == nil {
|
||||
return errors.New("StartMTLS: state is nil")
|
||||
}
|
||||
tlsCfg, err := security.ServerTLSConfig(state.CertPath, state.KeyPath, state.CAPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("StartMTLS: %w", err)
|
||||
}
|
||||
tlsCfg.GetCertificate = state.GetCertificate
|
||||
// We REQUIRE client certs, so the handshake will fail (and log a
|
||||
// structured mtls.handshake_failed record) for plaintext-only clients.
|
||||
tlsCfg.ClientAuth = tls.RequireAndVerifyClientCert
|
||||
s.httpServer.TLSConfig = tlsCfg
|
||||
s.mtls = state
|
||||
s.log.Info("mTLS enabled",
|
||||
slog.String("cert", state.CertPath),
|
||||
slog.String("ca", state.CAPath),
|
||||
slog.String("component", "daemon"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// MTLSActive reports whether the server is configured to require mTLS.
|
||||
func (s *Server) MTLSActive() bool { return s.mtls != nil }
|
||||
|
||||
// lastSep returns the index of the final separator in path. Used to
|
||||
// extract the dir from a file path. Returns -1 if no separator is found.
|
||||
func lastSep(path string) int {
|
||||
for i := len(path) - 1; i >= 0; i-- {
|
||||
if path[i] == '/' || path[i] == '\\' {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// idPattern constrains path IDs to a safe subset: alphanumerics, hyphens,
|
||||
// and underscores. UUIDs and our internal IDs both fit. We reject anything
|
||||
// that smells like a path-traversal, control character, or shell metachar.
|
||||
var idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`)
|
||||
|
||||
// validateID checks that an ID is well-formed and within length limits.
|
||||
// It exists primarily as a defense-in-depth measure against path traversal
|
||||
// and accidental log-injection when the ID is echoed back in error messages.
|
||||
func validateID(id string) error {
|
||||
if id == "" {
|
||||
return fmt.Errorf("id required")
|
||||
}
|
||||
if strings.ContainsAny(id, "\r\n\t\x00") {
|
||||
return fmt.Errorf("invalid id")
|
||||
}
|
||||
if !idPattern.MatchString(id) {
|
||||
return fmt.Errorf("invalid id format")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package daemon
|
||||
|
||||
// Version is the daemon version. It is set at build time via -ldflags by the
|
||||
// release pipeline, but defaults to a dev marker for local development.
|
||||
var Version = "0.1.0-dev"
|
||||
@@ -1,318 +0,0 @@
|
||||
// Package doctor implements `orca doctor`, a small battery of self-checks
|
||||
// for the orca installation. The cert, network, and db checks surface
|
||||
// common configuration errors before they become runtime failures.
|
||||
//
|
||||
// REQ-032: `orca doctor` is a first-class subcommand in v0.2 P01.
|
||||
// Per-phase subcommands:
|
||||
//
|
||||
// orca doctor — runs all checks, prints a summary
|
||||
// orca doctor cert — CA, server cert, expiry, fingerprint pin
|
||||
// orca doctor network — TCP reachability + mTLS handshake (stub in P01)
|
||||
// orca doctor db — SQLite open + migration apply (stub in P01)
|
||||
//
|
||||
// Each check returns a Result of PASS, WARN, or FAIL with a free-form
|
||||
// message. The aggregator prints one line per check.
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
"git.cloudinit.dev/coreci/orca/internal/transport"
|
||||
)
|
||||
|
||||
// Result is the outcome of a single check.
|
||||
type Result string
|
||||
|
||||
const (
|
||||
ResultPass Result = "PASS"
|
||||
ResultWarn Result = "WARN"
|
||||
ResultFail Result = "FAIL"
|
||||
)
|
||||
|
||||
// Check is a single self-check.
|
||||
type Check struct {
|
||||
Name string
|
||||
Description string
|
||||
Run func(ctx context.Context) (Result, string)
|
||||
}
|
||||
|
||||
// Report is the aggregated result of running all checks.
|
||||
type Report struct {
|
||||
Time time.Time
|
||||
Checks []CheckResult
|
||||
}
|
||||
|
||||
// CheckResult is the outcome of one Check.
|
||||
type CheckResult struct {
|
||||
Name string
|
||||
Result Result
|
||||
Message string
|
||||
}
|
||||
|
||||
// All returns the full battery of checks.
|
||||
func All() []Check {
|
||||
return []Check{
|
||||
CertCA(),
|
||||
CertServer(),
|
||||
CertExpiry(),
|
||||
CertFingerprint(),
|
||||
Network(),
|
||||
DB(),
|
||||
}
|
||||
}
|
||||
|
||||
// Run executes every check and returns a Report.
|
||||
func Run(ctx context.Context) *Report {
|
||||
checks := All()
|
||||
results := make([]CheckResult, 0, len(checks))
|
||||
for _, c := range checks {
|
||||
r, msg := c.Run(ctx)
|
||||
results = append(results, CheckResult{
|
||||
Name: c.Name,
|
||||
Result: r,
|
||||
Message: msg,
|
||||
})
|
||||
}
|
||||
return &Report{Time: time.Now(), Checks: results}
|
||||
}
|
||||
|
||||
// Print renders the Report.
|
||||
func (r *Report) Print() string {
|
||||
out := fmt.Sprintf("orca doctor — %s\n\n", r.Time.UTC().Format(time.RFC3339))
|
||||
pass, warn, fail := 0, 0, 0
|
||||
sort.Slice(r.Checks, func(i, j int) bool { return r.Checks[i].Name < r.Checks[j].Name })
|
||||
for _, c := range r.Checks {
|
||||
out += fmt.Sprintf("%-20s %-5s %s\n", c.Name, c.Result, c.Message)
|
||||
switch c.Result {
|
||||
case ResultPass:
|
||||
pass++
|
||||
case ResultWarn:
|
||||
warn++
|
||||
case ResultFail:
|
||||
fail++
|
||||
}
|
||||
}
|
||||
out += fmt.Sprintf("\n%d PASS, %d WARN, %d FAIL\n", pass, warn, fail)
|
||||
return out
|
||||
}
|
||||
|
||||
// CertCA checks the on-disk CA exists with the right file modes (REQ-033).
|
||||
func CertCA() Check {
|
||||
return Check{
|
||||
Name: "cert.ca",
|
||||
Description: "CA at ~/.orca with mode 0600/0644 (REQ-033)",
|
||||
Run: func(_ context.Context) (Result, string) {
|
||||
dir := certpaths.Dir()
|
||||
if err := security.EnforceFileModes(dir); err != nil {
|
||||
return ResultFail, err.Error()
|
||||
}
|
||||
return ResultPass, fmt.Sprintf("CA at %s with mode 0644/0600", dir)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CertServer checks the server cert is present and parseable.
|
||||
func CertServer() Check {
|
||||
return Check{
|
||||
Name: "cert.server",
|
||||
Description: "server.crt exists, signed by local CA",
|
||||
Run: func(_ context.Context) (Result, string) {
|
||||
certPath := certpaths.ServerCertPath()
|
||||
if _, err := os.Stat(certPath); err != nil {
|
||||
return ResultFail, fmt.Sprintf("server cert missing: %v", err)
|
||||
}
|
||||
fp, err := security.Fingerprint(certPath)
|
||||
if err != nil {
|
||||
return ResultFail, err.Error()
|
||||
}
|
||||
return ResultPass, fmt.Sprintf("server cert at %s, fp=%s", certPath, fp[:16]+"...")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CertExpiry returns WARN if the server cert is within 30 days of expiry
|
||||
// (REQ-034). Otherwise PASS.
|
||||
func CertExpiry() Check {
|
||||
return Check{
|
||||
Name: "cert.expiry",
|
||||
Description: "server cert validity window (> 30d = PASS, ≤ 30d = WARN)",
|
||||
Run: func(_ context.Context) (Result, string) {
|
||||
cert, err := loadCert(certpaths.ServerCertPath())
|
||||
if err != nil {
|
||||
return ResultFail, err.Error()
|
||||
}
|
||||
remaining := time.Until(cert.NotAfter)
|
||||
days := int(remaining.Hours() / 24)
|
||||
if days < 0 {
|
||||
return ResultFail, fmt.Sprintf("server cert EXPIRED %dd ago", -days)
|
||||
}
|
||||
if days <= 30 {
|
||||
return ResultWarn, fmt.Sprintf("server cert expires in %dd — run `orca cert renew`", days)
|
||||
}
|
||||
return ResultPass, fmt.Sprintf("server cert valid for %dd more", days)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CertFingerprint prints the CA fingerprint so the operator can copy
|
||||
// it to peers. Always PASS (or FAIL if the cert is missing).
|
||||
func CertFingerprint() Check {
|
||||
return Check{
|
||||
Name: "cert.fingerprint",
|
||||
Description: "CA fingerprint (for cross-node pinning)",
|
||||
Run: func(_ context.Context) (Result, string) {
|
||||
fp, err := security.Fingerprint(certpaths.CACertPath())
|
||||
if err != nil {
|
||||
return ResultFail, err.Error()
|
||||
}
|
||||
return ResultPass, fmt.Sprintf("CA fp=%s (use at `orca node join --ca-fingerprint`)", fp)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// DB checks SQLite integrity and migration version (REQ-032 completion).
|
||||
func DB() 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{
|
||||
Name: "network",
|
||||
Description: "peer reachability via mTLS /healthz probe",
|
||||
Run: func(ctx context.Context) (Result, string) {
|
||||
caPath := certpaths.CACertPath()
|
||||
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.
|
||||
func probeHealthz(ctx context.Context, caPath, certPath, keyPath, serverName, addr string) error {
|
||||
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
|
||||
}
|
||||
|
||||
// loadCert reads a PEM cert from path and parses the first CERTIFICATE
|
||||
// block.
|
||||
func loadCert(path string) (*x509.Certificate, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("no PEM block in %s", path)
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
return nil, fmt.Errorf("PEM type %q in %s, want CERTIFICATE", block.Type, path)
|
||||
}
|
||||
return x509.ParseCertificate(block.Bytes)
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||
"git.cloudinit.dev/coreci/orca/internal/security"
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
// TestRunAllChecksWithNoCA runs the full battery in a clean temp dir.
|
||||
// With the P02 real checks (no stubs): cert checks FAIL (no CA),
|
||||
// db check PASS (store.Open runs migrations), network check WARN
|
||||
// (no peers).
|
||||
func TestRunAllChecksWithNoCA(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", dir)
|
||||
t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))
|
||||
|
||||
rep := Run(context.Background())
|
||||
if len(rep.Checks) == 0 {
|
||||
t.Fatal("expected checks, got 0")
|
||||
}
|
||||
|
||||
byName := make(map[string]CheckResult, len(rep.Checks))
|
||||
for _, c := range rep.Checks {
|
||||
byName[c.Name] = c
|
||||
}
|
||||
|
||||
// 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 {
|
||||
t.Errorf("%s: got %s, want FAIL — %s", name, c.Result, c.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// DB check: store.Open runs migrations → PASS.
|
||||
if c, ok := byName["db"]; ok {
|
||||
if c.Result != ResultPass {
|
||||
t.Errorf("db: got %s, want PASS — %s", c.Result, c.Message)
|
||||
}
|
||||
} else {
|
||||
t.Error("missing check db")
|
||||
}
|
||||
|
||||
// Network check: no CA → FAIL (can't build mTLS client without CA).
|
||||
if c, ok := byName["network"]; ok {
|
||||
if c.Result != ResultFail {
|
||||
t.Errorf("network: got %s, want FAIL (no CA cert) — %s", c.Result, c.Message)
|
||||
}
|
||||
} else {
|
||||
t.Error("missing check network")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunWithCAAndServerCert covers the happy path: CA + server cert
|
||||
// installed → all cert checks PASS, db PASS, network WARN (no peers).
|
||||
func TestRunWithCAAndServerCert(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", dir)
|
||||
t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))
|
||||
|
||||
if _, err := security.CAInit(dir, "test-ca"); err != nil {
|
||||
t.Fatalf("CAInit: %v", err)
|
||||
}
|
||||
ca, err := security.LoadCA(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadCA: %v", err)
|
||||
}
|
||||
keyPEM, csrPEM, err := security.GenerateCSR("test-server", []string{"localhost", "127.0.0.1"})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCSR: %v", err)
|
||||
}
|
||||
certPEM, err := ca.SignCSR(csrPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("SignCSR: %v", err)
|
||||
}
|
||||
if err := security.WriteCert(dir+"/server.crt", certPEM); err != nil {
|
||||
t.Fatalf("WriteCert: %v", err)
|
||||
}
|
||||
if err := security.WriteKey(dir+"/server.key", keyPEM); err != nil {
|
||||
t.Fatalf("WriteKey: %v", err)
|
||||
}
|
||||
|
||||
rep := Run(context.Background())
|
||||
byName := make(map[string]CheckResult, len(rep.Checks))
|
||||
for _, c := range rep.Checks {
|
||||
byName[c.Name] = c
|
||||
}
|
||||
|
||||
for _, name := range []string{"cert.ca", "cert.server", "cert.expiry", "cert.fingerprint", "db"} {
|
||||
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, "0005") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Suppress slog noise during tests.
|
||||
_ = os.Setenv("ORCA_LOG_LEVEL", "error")
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
// Audit wraps a slog.Logger and persists structured audit entries to SQLite.
|
||||
type Audit struct {
|
||||
repo *store.AuditRepo
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func NewAudit(repo *store.AuditRepo, log *slog.Logger) *Audit {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Audit{repo: repo, log: log}
|
||||
}
|
||||
|
||||
func (a *Audit) Record(ctx context.Context, actor, action, resource, result string, err error, meta map[string]any) {
|
||||
entry := &store.AuditEntry{
|
||||
Actor: actor,
|
||||
Action: action,
|
||||
Resource: resource,
|
||||
Result: result,
|
||||
Metadata: meta,
|
||||
}
|
||||
if err != nil {
|
||||
entry.Error = err.Error()
|
||||
}
|
||||
if persistErr := a.repo.Append(ctx, entry); persistErr != nil {
|
||||
a.log.Error("audit persist failed",
|
||||
slog.String("action", action),
|
||||
slog.String("resource", resource),
|
||||
slog.String("error", persistErr.Error()))
|
||||
}
|
||||
attrs := []any{
|
||||
slog.String("actor", actor),
|
||||
slog.String("action", action),
|
||||
slog.String("resource", resource),
|
||||
slog.String("result", result),
|
||||
}
|
||||
if err != nil {
|
||||
attrs = append(attrs, slog.String("error", err.Error()))
|
||||
a.log.Warn("audit", attrs...)
|
||||
} else {
|
||||
a.log.Info("audit", attrs...)
|
||||
}
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
// Package engine — dispatcher.go implements the cross-node job
|
||||
// dispatch logic (v0.2 P02). The dispatcher is the bridge between
|
||||
// the local "should I run this?" decision (scheduler.PickNode) and
|
||||
// the remote "please run this" call (transport.DispatchClient).
|
||||
//
|
||||
// Flow:
|
||||
//
|
||||
// 1. Receive a job spec (HCL bytes from the CLI).
|
||||
// 2. Parse the spec into a JobSpec (cpu/mem/disk).
|
||||
// 3. Check local capacity. If it fits, run locally via the local
|
||||
// executor. If not, pick a peer and dispatch.
|
||||
// 4. Return the job ID and the node that actually accepted it.
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
"git.cloudinit.dev/coreci/orca/internal/transport"
|
||||
)
|
||||
|
||||
// Dispatcher is the public surface; constructed via NewDispatcher.
|
||||
type Dispatcher struct {
|
||||
log *slog.Logger
|
||||
capacity *store.CapacityRepo
|
||||
peers *PeerRegistry
|
||||
executor LocalExecutor
|
||||
dedupe *transport.IdempotencyStore
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// LocalExecutor is the contract the dispatcher uses to run jobs on
|
||||
// the local node. The engine.Executor satisfies this.
|
||||
type LocalExecutor interface {
|
||||
Submit(ctx context.Context, specBytes []byte) (jobID string, err error)
|
||||
Status(ctx context.Context, jobID string) (state string, err error)
|
||||
}
|
||||
|
||||
// NewDispatcher builds a Dispatcher.
|
||||
func NewDispatcher(log *slog.Logger, capacity *store.CapacityRepo, peers *PeerRegistry, exec LocalExecutor) *Dispatcher {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Dispatcher{
|
||||
log: log,
|
||||
capacity: capacity,
|
||||
peers: peers,
|
||||
executor: exec,
|
||||
dedupe: transport.NewIdempotencyStore(),
|
||||
}
|
||||
}
|
||||
|
||||
// Dedupe exposes the in-memory dedupe store for testing.
|
||||
func (d *Dispatcher) Dedupe() *transport.IdempotencyStore { return d.dedupe }
|
||||
|
||||
// Submit runs the spec locally if it fits, otherwise dispatches to a
|
||||
// peer. Returns the (jobID, chosenNodeID) pair. If `target` is
|
||||
// non-empty, it overrides bin-packing.
|
||||
func (d *Dispatcher) Submit(ctx context.Context, target string, specBytes []byte, idempotencyKey string) (jobID, nodeID string, err error) {
|
||||
if len(specBytes) == 0 {
|
||||
return "", "", errors.New("Dispatcher.Submit: empty spec")
|
||||
}
|
||||
if idempotencyKey != "" {
|
||||
if jid, ok := d.dedupe.Get(idempotencyKey); ok {
|
||||
return jid, "self", nil
|
||||
}
|
||||
}
|
||||
|
||||
parsed, err := parseInlineSpec(specBytes)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("Dispatcher.Submit: parse spec: %w", err)
|
||||
}
|
||||
|
||||
// 1. Explicit target: dispatch there.
|
||||
if target != "" {
|
||||
return d.dispatchTo(ctx, target, specBytes, idempotencyKey)
|
||||
}
|
||||
|
||||
// 2. Check local capacity.
|
||||
if d.capacity != nil {
|
||||
local, err := d.capacity.Get(ctx, "self")
|
||||
if err == nil && parsed.Fits(local) {
|
||||
jid, lerr := d.executor.Submit(ctx, specBytes)
|
||||
if lerr != nil {
|
||||
return "", "", fmt.Errorf("Dispatcher.Submit: local: %w", lerr)
|
||||
}
|
||||
if idempotencyKey != "" {
|
||||
d.dedupe.Put(idempotencyKey, jid)
|
||||
}
|
||||
d.log.Info("dispatch.local",
|
||||
slog.String("event", "dispatch.local"),
|
||||
slog.String("job_id", jid),
|
||||
slog.String("node_id", "self"),
|
||||
)
|
||||
return jid, "self", nil
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Pick a peer.
|
||||
if d.peers == nil {
|
||||
return "", "", errors.New("Dispatcher.Submit: no local capacity and no peer registry")
|
||||
}
|
||||
peers, err := d.peers.All(ctx)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("Dispatcher.Submit: list peers: %w", err)
|
||||
}
|
||||
if len(peers) == 0 {
|
||||
return "", "", errors.New("Dispatcher.Submit: no peers registered")
|
||||
}
|
||||
var caps []*store.NodeCapacity
|
||||
for _, p := range peers {
|
||||
caps = append(caps, p.Capacity)
|
||||
}
|
||||
best, _, err := PickNode(parsed, caps)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("Dispatcher.Submit: %w", err)
|
||||
}
|
||||
var chosen *Peer
|
||||
for _, p := range peers {
|
||||
if p.NodeID == best.NodeID {
|
||||
chosen = p
|
||||
break
|
||||
}
|
||||
}
|
||||
if chosen == nil {
|
||||
return "", "", fmt.Errorf("Dispatcher.Submit: chosen node %s has no peer record", best.NodeID)
|
||||
}
|
||||
return d.dispatchToPeer(ctx, chosen, specBytes, idempotencyKey)
|
||||
}
|
||||
|
||||
// dispatchTo sends a Submit to a specific node id (looked up in the peer registry).
|
||||
func (d *Dispatcher) dispatchTo(ctx context.Context, targetNode string, specBytes []byte, idempotencyKey string) (string, string, error) {
|
||||
if d.peers == nil {
|
||||
return "", "", errors.New("dispatchTo: no peer registry")
|
||||
}
|
||||
peers, err := d.peers.All(ctx)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("dispatchTo: list peers: %w", err)
|
||||
}
|
||||
for _, p := range peers {
|
||||
if p.NodeID == targetNode {
|
||||
return d.dispatchToPeer(ctx, p, specBytes, idempotencyKey)
|
||||
}
|
||||
}
|
||||
return "", "", fmt.Errorf("dispatchTo: target node %q not found in peer registry", targetNode)
|
||||
}
|
||||
|
||||
// dispatchToPeer opens an mTLS client and calls Submit on the peer.
|
||||
func (d *Dispatcher) dispatchToPeer(ctx context.Context, p *Peer, specBytes []byte, idempotencyKey string) (string, string, error) {
|
||||
if p.CAPath == "" || p.ServerName == "" {
|
||||
return "", "", fmt.Errorf("dispatchToPeer: peer %s missing CA or server name", p.NodeID)
|
||||
}
|
||||
client, err := transport.NewDispatchClient(p.CAPath, p.ServerName, "https://"+p.Address)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("dispatchToPeer: %w", err)
|
||||
}
|
||||
resp, err := client.Submit(ctx, specBytes, idempotencyKey)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("dispatchToPeer: %w", err)
|
||||
}
|
||||
if idempotencyKey != "" {
|
||||
d.dedupe.Put(idempotencyKey, resp.JobID)
|
||||
}
|
||||
d.log.Info("dispatch.peer",
|
||||
slog.String("event", "dispatch.peer"),
|
||||
slog.String("job_id", resp.JobID),
|
||||
slog.String("node_id", p.NodeID),
|
||||
)
|
||||
return resp.JobID, p.NodeID, nil
|
||||
}
|
||||
|
||||
// LocalSubmit / LocalStatus satisfy the transport.Dispatcher
|
||||
// interface (the server-side counterpart of DispatchClient).
|
||||
func (d *Dispatcher) LocalSubmit(ctx context.Context, specBytes []byte) (string, error) {
|
||||
if d.executor == nil {
|
||||
return "", errors.New("Dispatcher.LocalSubmit: no local executor")
|
||||
}
|
||||
return d.executor.Submit(ctx, specBytes)
|
||||
}
|
||||
|
||||
func (d *Dispatcher) LocalStatus(ctx context.Context, jobID string) (string, error) {
|
||||
if d.executor == nil {
|
||||
return "", errors.New("Dispatcher.LocalStatus: no local executor")
|
||||
}
|
||||
return d.executor.Status(ctx, jobID)
|
||||
}
|
||||
|
||||
// parseInlineSpec parses a minimal JSON spec with cpu_millicores,
|
||||
// memory_mib, disk_mib fields. The CLI uses this as the wire format
|
||||
// for cross-node dispatch; full HCL parsing is in internal/jobspec.
|
||||
func parseInlineSpec(b []byte) (JobSpec, error) {
|
||||
type wire struct {
|
||||
CPUMillicores int64 `json:"cpu_millicores"`
|
||||
MemoryMiB int64 `json:"memory_mib"`
|
||||
DiskMiB int64 `json:"disk_mib"`
|
||||
}
|
||||
var w wire
|
||||
if err := json.Unmarshal(b, &w); err != nil {
|
||||
return JobSpec{}, fmt.Errorf("parseInlineSpec: %w", err)
|
||||
}
|
||||
return JobSpec{
|
||||
CPUMillicores: w.CPUMillicores,
|
||||
MemoryMiB: w.MemoryMiB,
|
||||
DiskMiB: w.DiskMiB,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
type Executor struct {
|
||||
jobs *store.JobRepo
|
||||
tasks *store.TaskRepo
|
||||
log *slog.Logger
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewExecutor(jobs *store.JobRepo, tasks *store.TaskRepo, log *slog.Logger) *Executor {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Executor{jobs: jobs, tasks: tasks, log: log}
|
||||
}
|
||||
|
||||
// Submit is the dispatch-friendly entry point (v0.2 P02). It parses
|
||||
// the spec bytes as a minimal TaskSpec and runs a single task under
|
||||
// a fresh job. Returns the job ID. This is intentionally simpler
|
||||
// than the v0.1 Run() entry point — the cross-node dispatch wire
|
||||
// format is a flat task (one process), not a multi-task job.
|
||||
//
|
||||
// The spec format is a JSON object with at least:
|
||||
//
|
||||
// { "name": "...", "command": "...", "args": [...], "env": [...] }
|
||||
//
|
||||
// All fields except command are optional.
|
||||
func (e *Executor) Submit(ctx context.Context, specBytes []byte) (string, error) {
|
||||
type wireSpec struct {
|
||||
Name string `json:"name"`
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
Env []string `json:"env"`
|
||||
}
|
||||
var ws wireSpec
|
||||
if err := json.Unmarshal(specBytes, &ws); err != nil {
|
||||
return "", fmt.Errorf("Executor.Submit: parse: %w", err)
|
||||
}
|
||||
if ws.Command == "" {
|
||||
return "", errors.New("Executor.Submit: spec.command is required")
|
||||
}
|
||||
if ws.Name == "" {
|
||||
ws.Name = "dispatched"
|
||||
}
|
||||
job := &model.Job{
|
||||
ID: uuid.NewString(),
|
||||
Spec: string(specBytes),
|
||||
Status: model.JobStatusPending,
|
||||
}
|
||||
ts := TaskSpec{
|
||||
Name: ws.Name,
|
||||
Command: ws.Command,
|
||||
Args: ws.Args,
|
||||
Env: ws.Env,
|
||||
}
|
||||
if err := e.Run(ctx, job, []TaskSpec{ts}); err != nil {
|
||||
return job.ID, err
|
||||
}
|
||||
return job.ID, nil
|
||||
}
|
||||
|
||||
// Status returns the current state of a job for the Status dispatch
|
||||
// endpoint. The returned string is one of: "pending", "running",
|
||||
// "complete", "failed", "stopped". Maps to model.JobStatus* values.
|
||||
func (e *Executor) Status(ctx context.Context, jobID string) (string, error) {
|
||||
if e.jobs == nil {
|
||||
return "", errors.New("Executor.Status: nil job repo")
|
||||
}
|
||||
j, err := e.jobs.Get(ctx, jobID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(j.Status), nil
|
||||
}
|
||||
|
||||
type TaskSpec struct {
|
||||
Name string
|
||||
Command string
|
||||
Args []string
|
||||
Env []string
|
||||
}
|
||||
|
||||
func (e *Executor) Run(ctx context.Context, job *model.Job, specs []TaskSpec) error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
// Insert the job first so tasks can reference it via foreign key.
|
||||
if err := e.jobs.Insert(ctx, job); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := e.jobs.UpdateStatus(ctx, job.ID, model.JobStatusRunning, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
failedCount int
|
||||
exitCode int
|
||||
mu sync.Mutex
|
||||
)
|
||||
|
||||
for _, ts := range specs {
|
||||
wg.Add(1)
|
||||
go func(ts TaskSpec) {
|
||||
defer wg.Done()
|
||||
if err := e.runOne(ctx, job, ts); err != nil {
|
||||
mu.Lock()
|
||||
failedCount++
|
||||
e.log.Error("task failed",
|
||||
slog.String("job_id", job.ID),
|
||||
slog.String("task", ts.Name),
|
||||
slog.String("error", err.Error()))
|
||||
mu.Unlock()
|
||||
}
|
||||
}(ts)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if failedCount > 0 {
|
||||
exitCode = 1
|
||||
if err := e.jobs.UpdateStatus(ctx, job.ID, model.JobStatusFailed, exitCode); err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%d/%d tasks failed", failedCount, len(specs))
|
||||
}
|
||||
|
||||
if err := e.jobs.UpdateStatus(ctx, job.ID, model.JobStatusComplete, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Executor) runOne(ctx context.Context, job *model.Job, ts TaskSpec) error {
|
||||
task := &model.Task{
|
||||
ID: uuid.NewString(),
|
||||
JobID: job.ID,
|
||||
Command: ts.Command,
|
||||
Args: ts.Args,
|
||||
Env: ts.Env,
|
||||
Status: model.TaskStatusPending,
|
||||
}
|
||||
if err := e.tasks.Insert(ctx, task); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, ts.Command, ts.Args...)
|
||||
cmd.Env = append(cmd.Environ(), ts.Env...)
|
||||
// WaitDelay (Go 1.25+) bounds the time spent waiting on a child process
|
||||
// that fails to exit after the context is canceled.
|
||||
cmd.WaitDelay = 5 * time.Second
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
_ = e.tasks.UpdateKilled(ctx, task.ID)
|
||||
return fmt.Errorf("start: %w", err)
|
||||
}
|
||||
|
||||
if err := e.tasks.UpdateRunning(ctx, task.ID, cmd.Process.Pid); err != nil {
|
||||
e.log.Warn("update running failed", slog.String("error", err.Error()))
|
||||
}
|
||||
|
||||
e.log.Info("task started",
|
||||
slog.String("job_id", job.ID),
|
||||
slog.String("task", ts.Name),
|
||||
slog.Int("pid", cmd.Process.Pid))
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
exitCode := 0
|
||||
if err != nil {
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = ee.ExitCode()
|
||||
} else {
|
||||
exitCode = 1
|
||||
}
|
||||
}
|
||||
_ = e.tasks.UpdateDone(ctx, task.ID, exitCode, stdout.String(), stderr.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
// WaitDelay (set above) gives the process a grace period to exit
|
||||
// cleanly before being killed.
|
||||
_ = e.tasks.UpdateKilled(ctx, task.ID)
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
// Package engine — peer.go implements the peer registry for multi-node
|
||||
// scheduling (v0.2 P02). A peer is a remote orca node reachable over
|
||||
// mTLS. The registry is in-memory plus optionally SQLite-persisted;
|
||||
// for P02 the in-memory map is the source of truth and persistence
|
||||
// is best-effort.
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
// Peer is a remote orca node reachable over mTLS.
|
||||
type Peer struct {
|
||||
NodeID string
|
||||
Address string // host:port (the peer's daemon listener)
|
||||
ServerName string // expected SAN on the peer's cert
|
||||
CAPath string // path to the CA cert this peer validates against
|
||||
LastSeen time.Time
|
||||
Capacity *store.NodeCapacity
|
||||
}
|
||||
|
||||
// PeerRegistry tracks known peers. Methods are safe for concurrent
|
||||
// use; the underlying map is guarded by a sync.RWMutex.
|
||||
type PeerRegistry struct {
|
||||
mu sync.RWMutex
|
||||
peers map[string]*Peer
|
||||
// optional persistence (not required for P02; can be added later)
|
||||
persist PeerPersister
|
||||
}
|
||||
|
||||
// PeerPersister is an optional callback for persisting peer records.
|
||||
// P02 doesn't use it; it's here for the P03 audit log integration.
|
||||
type PeerPersister interface {
|
||||
SavePeer(ctx context.Context, p *Peer) error
|
||||
}
|
||||
|
||||
// NewPeerRegistry returns an empty registry.
|
||||
func NewPeerRegistry() *PeerRegistry {
|
||||
return &PeerRegistry{peers: make(map[string]*Peer)}
|
||||
}
|
||||
|
||||
// Add inserts or updates a peer record.
|
||||
func (r *PeerRegistry) Add(p *Peer) error {
|
||||
if p == nil {
|
||||
return fmt.Errorf("PeerRegistry.Add: nil peer")
|
||||
}
|
||||
if p.NodeID == "" {
|
||||
return fmt.Errorf("PeerRegistry.Add: NodeID is required")
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.peers[p.NodeID] = p
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove deletes a peer by ID. Returns true if a peer was removed.
|
||||
func (r *PeerRegistry) Remove(nodeID string) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
_, ok := r.peers[nodeID]
|
||||
if ok {
|
||||
delete(r.peers, nodeID)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// Get returns the peer with the given ID, or nil.
|
||||
func (r *PeerRegistry) Get(nodeID string) *Peer {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.peers[nodeID]
|
||||
}
|
||||
|
||||
// All returns a snapshot of all peers, sorted by NodeID for determinism.
|
||||
func (r *PeerRegistry) All(_ context.Context) ([]*Peer, error) {
|
||||
r.mu.RLock()
|
||||
out := make([]*Peer, 0, len(r.peers))
|
||||
for _, p := range r.peers {
|
||||
out = append(out, p)
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].NodeID < out[j].NodeID })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Len returns the number of registered peers.
|
||||
func (r *PeerRegistry) Len() int {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return len(r.peers)
|
||||
}
|
||||
|
||||
// UpdateLastSeen bumps the LastSeen timestamp on a peer.
|
||||
func (r *PeerRegistry) UpdateLastSeen(nodeID string) {
|
||||
r.mu.Lock()
|
||||
if p, ok := r.peers[nodeID]; ok {
|
||||
p.LastSeen = time.Now().UTC()
|
||||
}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
@@ -10,30 +10,21 @@ import (
|
||||
)
|
||||
|
||||
type NodeRegistry struct {
|
||||
repo *store.NodeRepo
|
||||
audit *Audit
|
||||
log *slog.Logger
|
||||
repo *store.NodeRepo
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func NewNodeRegistry(repo *store.NodeRepo, audit *Audit, log *slog.Logger) *NodeRegistry {
|
||||
func NewNodeRegistry(repo *store.NodeRepo, log *slog.Logger) *NodeRegistry {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &NodeRegistry{repo: repo, audit: audit, log: log}
|
||||
return &NodeRegistry{repo: repo, log: log}
|
||||
}
|
||||
|
||||
func (r *NodeRegistry) Join(ctx context.Context, n *model.Node) error {
|
||||
if err := r.repo.Insert(ctx, n); err != nil {
|
||||
r.audit.Record(ctx, "cli", "node.join", n.ID, "failure", err, map[string]any{
|
||||
"name": n.Name,
|
||||
"address": n.Address,
|
||||
})
|
||||
return fmt.Errorf("join node: %w", err)
|
||||
}
|
||||
r.audit.Record(ctx, "cli", "node.join", n.ID, "success", nil, map[string]any{
|
||||
"name": n.Name,
|
||||
"address": n.Address,
|
||||
})
|
||||
r.log.Info("node joined",
|
||||
slog.String("node_id", n.ID),
|
||||
slog.String("name", n.Name),
|
||||
@@ -43,20 +34,16 @@ func (r *NodeRegistry) Join(ctx context.Context, n *model.Node) error {
|
||||
|
||||
func (r *NodeRegistry) Leave(ctx context.Context, id string) error {
|
||||
if err := r.repo.UpdateState(ctx, id, model.NodeStateLeft); err != nil {
|
||||
r.audit.Record(ctx, "cli", "node.leave", id, "failure", err, nil)
|
||||
return fmt.Errorf("leave node: %w", err)
|
||||
}
|
||||
r.audit.Record(ctx, "cli", "node.leave", id, "success", nil, nil)
|
||||
r.log.Info("node left", slog.String("node_id", id))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *NodeRegistry) Forget(ctx context.Context, id string) error {
|
||||
if err := r.repo.Delete(ctx, id); err != nil {
|
||||
r.audit.Record(ctx, "cli", "node.forget", id, "failure", err, nil)
|
||||
return fmt.Errorf("forget node: %w", err)
|
||||
}
|
||||
r.audit.Record(ctx, "cli", "node.forget", id, "success", nil, nil)
|
||||
r.log.Info("node removed from registry", slog.String("node_id", id))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
// Package engine — scheduler.go implements best-fit bin-packing for
|
||||
// the multi-node scheduler (v0.2 P02, REQ-028). The scheduler
|
||||
// receives a JobSpec, looks at the local NodeCapacity, and either
|
||||
// runs locally or falls through to a remote peer via the dispatcher.
|
||||
//
|
||||
// The bin-pack scoring is intentionally simple: pick the node with
|
||||
// the most free capacity (cpu_millicores + memory_mib weighted 1:1
|
||||
// after normalization). This is deterministic and easy to test.
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
// JobSpec is a minimal projection of the spec needed for scheduling
|
||||
// decisions. The full spec parsing is in internal/jobspec; this is
|
||||
// just enough to ask "does this fit?" and "where should it go?".
|
||||
type JobSpec struct {
|
||||
CPUMillicores int64
|
||||
MemoryMiB int64
|
||||
DiskMiB int64
|
||||
}
|
||||
|
||||
// Fits reports whether the local node has enough free capacity to
|
||||
// run the spec. Capacity accounting is conservative: a job is allowed
|
||||
// to run only if cpu + memory + disk are all >= the spec.
|
||||
func (s JobSpec) Fits(c *store.NodeCapacity) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
return c.CPUMillicores >= s.CPUMillicores &&
|
||||
c.MemoryMiB >= s.MemoryMiB &&
|
||||
c.DiskMiB >= s.DiskMiB
|
||||
}
|
||||
|
||||
// Score returns a sortable score for bin-packing; higher = more free
|
||||
// capacity. Weighted roughly toward CPU (which is usually the
|
||||
// constraint) but normalized so the test isn't fragile.
|
||||
func (s JobSpec) Score(c *store.NodeCapacity) int64 {
|
||||
if c == nil {
|
||||
return -1
|
||||
}
|
||||
// Use 1:1 weighting in normalized units (millicores vs MiB) to
|
||||
// keep the score monotonic. This isn't physically meaningful
|
||||
// (mixing units) but it gives a stable ordering for tests.
|
||||
freeCPU := c.CPUMillicores - s.CPUMillicores
|
||||
freeMem := c.MemoryMiB - s.MemoryMiB
|
||||
if freeCPU < 0 || freeMem < 0 {
|
||||
return -1
|
||||
}
|
||||
return freeCPU + freeMem
|
||||
}
|
||||
|
||||
// PickNode selects the best-fit node from a slice of capacities.
|
||||
// Returns the chosen *store.NodeCapacity and its index, or an error
|
||||
// if none can fit. Ties are broken by NodeID (lexicographic) for
|
||||
// determinism.
|
||||
func PickNode(spec JobSpec, capacities []*store.NodeCapacity) (*store.NodeCapacity, int, error) {
|
||||
if len(capacities) == 0 {
|
||||
return nil, -1, fmt.Errorf("PickNode: no nodes available")
|
||||
}
|
||||
type scored struct {
|
||||
c *store.NodeCapacity
|
||||
idx int
|
||||
score int64
|
||||
}
|
||||
var fits []scored
|
||||
for i, c := range capacities {
|
||||
if !spec.Fits(c) {
|
||||
continue
|
||||
}
|
||||
fits = append(fits, scored{c: c, idx: i, score: spec.Score(c)})
|
||||
}
|
||||
if len(fits) == 0 {
|
||||
return nil, -1, fmt.Errorf("PickNode: no node can fit the spec (cpu=%d mem=%d disk=%d)",
|
||||
spec.CPUMillicores, spec.MemoryMiB, spec.DiskMiB)
|
||||
}
|
||||
sort.SliceStable(fits, func(i, j int) bool {
|
||||
if fits[i].score != fits[j].score {
|
||||
return fits[i].score > fits[j].score
|
||||
}
|
||||
return fits[i].c.NodeID < fits[j].c.NodeID
|
||||
})
|
||||
return fits[0].c, fits[0].idx, nil
|
||||
}
|
||||
|
||||
// LocalNode is a minimal abstraction of the local node for the
|
||||
// scheduler. The concrete implementation reads from the
|
||||
// store.CapacityRepo.
|
||||
type LocalNode interface {
|
||||
Capacity(ctx context.Context) (*store.NodeCapacity, error)
|
||||
}
|
||||
|
||||
// memLocalNode returns capacity from a fixed *store.NodeCapacity.
|
||||
// Useful for tests; production code wraps CapacityRepo.
|
||||
type memLocalNode struct{ c *store.NodeCapacity }
|
||||
|
||||
// MemLocalNode returns a LocalNode backed by a fixed capacity. Test-only.
|
||||
func MemLocalNode(c *store.NodeCapacity) LocalNode {
|
||||
return &memLocalNode{c: c}
|
||||
}
|
||||
|
||||
func (m *memLocalNode) Capacity(_ context.Context) (*store.NodeCapacity, error) {
|
||||
if m.c == nil {
|
||||
return nil, store.ErrNotFound
|
||||
}
|
||||
return m.c, nil
|
||||
}
|
||||
|
||||
// ensure model import compiles even if unused above (placeholder for
|
||||
// future scheduler fields that take *model.Node).
|
||||
var _ = model.NodeStateReady
|
||||
@@ -1,66 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
func TestPickNodeBestFit(t *testing.T) {
|
||||
caps := []*store.NodeCapacity{
|
||||
{NodeID: "node-b", CPUMillicores: 1000, MemoryMiB: 1024, DiskMiB: 1024},
|
||||
{NodeID: "node-a", CPUMillicores: 4000, MemoryMiB: 4096, DiskMiB: 4096},
|
||||
{NodeID: "node-c", CPUMillicores: 500, MemoryMiB: 512, DiskMiB: 512},
|
||||
}
|
||||
spec := JobSpec{CPUMillicores: 1000, MemoryMiB: 1024, DiskMiB: 1024}
|
||||
got, idx, err := PickNode(spec, caps)
|
||||
if err != nil {
|
||||
t.Fatalf("PickNode: %v", err)
|
||||
}
|
||||
if got.NodeID != "node-a" {
|
||||
t.Errorf("PickNode: got %s, want node-a (most free capacity)", got.NodeID)
|
||||
}
|
||||
if idx != 1 {
|
||||
t.Errorf("PickNode: got idx %d, want 1", idx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickNodeNoFit(t *testing.T) {
|
||||
caps := []*store.NodeCapacity{
|
||||
{NodeID: "node-a", CPUMillicores: 100, MemoryMiB: 100, DiskMiB: 100},
|
||||
}
|
||||
spec := JobSpec{CPUMillicores: 1000, MemoryMiB: 1024, DiskMiB: 1024}
|
||||
_, _, err := PickNode(spec, caps)
|
||||
if err == nil {
|
||||
t.Fatal("expected PickNode to fail when no node can fit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickNodeTieDeterministic(t *testing.T) {
|
||||
// Two nodes with identical free capacity. Tie broken by NodeID
|
||||
// (lexicographic) for determinism.
|
||||
caps := []*store.NodeCapacity{
|
||||
{NodeID: "node-z", CPUMillicores: 4000, MemoryMiB: 4096, DiskMiB: 4096},
|
||||
{NodeID: "node-a", CPUMillicores: 4000, MemoryMiB: 4096, DiskMiB: 4096},
|
||||
}
|
||||
spec := JobSpec{CPUMillicores: 1000, MemoryMiB: 1024, DiskMiB: 1024}
|
||||
got, _, err := PickNode(spec, caps)
|
||||
if err != nil {
|
||||
t.Fatalf("PickNode: %v", err)
|
||||
}
|
||||
if got.NodeID != "node-a" {
|
||||
t.Errorf("PickNode tie-break: got %s, want node-a (lexicographic)", got.NodeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobSpecFits(t *testing.T) {
|
||||
spec := JobSpec{CPUMillicores: 1000, MemoryMiB: 1024, DiskMiB: 1024}
|
||||
c := &store.NodeCapacity{CPUMillicores: 2000, MemoryMiB: 2048, DiskMiB: 2048}
|
||||
if !spec.Fits(c) {
|
||||
t.Error("Fits: should fit")
|
||||
}
|
||||
c.CPUMillicores = 500
|
||||
if spec.Fits(c) {
|
||||
t.Error("Fits: should not fit (CPU too low)")
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package jobspec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
"github.com/hashicorp/hcl/v2/gohcl"
|
||||
"github.com/hashicorp/hcl/v2/hclsimple"
|
||||
)
|
||||
|
||||
type Spec struct {
|
||||
Job JobSpec `hcl:"job,block"`
|
||||
Tasks []TaskSpec `hcl:"task,block"`
|
||||
}
|
||||
|
||||
type JobSpec struct {
|
||||
Name string `hcl:"name,label"`
|
||||
Type string `hcl:"type,optional"`
|
||||
}
|
||||
|
||||
type TaskSpec struct {
|
||||
Name string `hcl:"name,label"`
|
||||
Command string `hcl:"command"`
|
||||
Args []string `hcl:"args,optional"`
|
||||
Env []string `hcl:"env,optional"`
|
||||
}
|
||||
|
||||
func ParseFile(path string) (*Spec, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read spec file: %w", err)
|
||||
}
|
||||
return Parse(data, path)
|
||||
}
|
||||
|
||||
func Parse(data []byte, filename string) (*Spec, error) {
|
||||
var spec Spec
|
||||
err := hclsimple.Decode(filename, data, nil, &spec)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode hcl: %w", err)
|
||||
}
|
||||
if spec.Job.Name == "" {
|
||||
return nil, fmt.Errorf("spec missing job name")
|
||||
}
|
||||
if len(spec.Tasks) == 0 {
|
||||
return nil, fmt.Errorf("spec must have at least one task")
|
||||
}
|
||||
for i, t := range spec.Tasks {
|
||||
if t.Command == "" {
|
||||
return nil, fmt.Errorf("task[%d] (%s) missing command", i, t.Name)
|
||||
}
|
||||
}
|
||||
return &spec, nil
|
||||
}
|
||||
|
||||
func (s *Spec) Validate() error {
|
||||
if strings.TrimSpace(s.Job.Name) == "" {
|
||||
return fmt.Errorf("job name is required")
|
||||
}
|
||||
if len(s.Tasks) == 0 {
|
||||
return fmt.Errorf("at least one task is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ = hcl.Diagnostics{}
|
||||
var _ = gohcl.DecodeBody
|
||||
@@ -1,60 +0,0 @@
|
||||
package jobspec
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseValid(t *testing.T) {
|
||||
hcl := `
|
||||
job "demo" {
|
||||
}
|
||||
|
||||
task "build" {
|
||||
command = "/bin/echo"
|
||||
args = ["hello", "world"]
|
||||
}
|
||||
`
|
||||
spec, err := Parse([]byte(hcl), "test.hcl")
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if spec.Job.Name != "demo" {
|
||||
t.Errorf("expected job name 'demo', got %q", spec.Job.Name)
|
||||
}
|
||||
if len(spec.Tasks) != 1 {
|
||||
t.Fatalf("expected 1 task, got %d", len(spec.Tasks))
|
||||
}
|
||||
if spec.Tasks[0].Command != "/bin/echo" {
|
||||
t.Errorf("expected command '/bin/echo', got %q", spec.Tasks[0].Command)
|
||||
}
|
||||
if len(spec.Tasks[0].Args) != 2 {
|
||||
t.Errorf("expected 2 args, got %d", len(spec.Tasks[0].Args))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMissingJob(t *testing.T) {
|
||||
hcl := `task "x" { command = "/bin/echo" }`
|
||||
_, err := Parse([]byte(hcl), "test.hcl")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing job name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNoTasks(t *testing.T) {
|
||||
hcl := `job "empty" {}`
|
||||
_, err := Parse([]byte(hcl), "test.hcl")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for no tasks")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTaskMissingCommand(t *testing.T) {
|
||||
hcl := `
|
||||
job "x" {}
|
||||
task "no-cmd" {}
|
||||
`
|
||||
_, err := Parse([]byte(hcl), "test.hcl")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing command")
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type JobStatus string
|
||||
|
||||
const (
|
||||
JobStatusPending JobStatus = "pending"
|
||||
JobStatusRunning JobStatus = "running"
|
||||
JobStatusComplete JobStatus = "complete"
|
||||
JobStatusFailed JobStatus = "failed"
|
||||
JobStatusStopped JobStatus = "stopped"
|
||||
)
|
||||
|
||||
type Job struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Spec string `json:"spec"`
|
||||
Status JobStatus `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
EndedAt *time.Time `json:"ended_at,omitempty"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
}
|
||||
|
||||
type TaskStatus string
|
||||
|
||||
const (
|
||||
TaskStatusPending TaskStatus = "pending"
|
||||
TaskStatusRunning TaskStatus = "running"
|
||||
TaskStatusComplete TaskStatus = "complete"
|
||||
TaskStatusFailed TaskStatus = "failed"
|
||||
TaskStatusKilled TaskStatus = "killed"
|
||||
)
|
||||
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
JobID string `json:"job_id"`
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
Env []string `json:"env,omitempty"`
|
||||
PID int `json:"pid"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Status TaskStatus `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
EndedAt *time.Time `json:"ended_at,omitempty"`
|
||||
Stdout string `json:"stdout,omitempty"`
|
||||
Stderr string `json:"stderr,omitempty"`
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CAValidity is how long a CA cert is valid. Per D-013, the CA is long-lived
|
||||
// (10 years) because manual rotation is expensive.
|
||||
const CAValidity = 10 * 365 * 24 * time.Hour
|
||||
|
||||
// ServerCertValidity is the default validity window for server certs. D-013
|
||||
// says server certs are short-lived (90 days) to limit the compromise window.
|
||||
const ServerCertValidity = 90 * 24 * time.Hour
|
||||
|
||||
// CAKeySize is the RSA key size used for both CA and server certs. 3072 is
|
||||
// the minimum we accept for v0.2 — matches REQ-033 spirit and Go's stdlib
|
||||
// defaults for new RSA keys are typically 2048 or 4096. 3072 is the
|
||||
// sweet spot for balance of safety and key-gen latency.
|
||||
const CAKeySize = 3072
|
||||
|
||||
// CAMode is the file mode used when persisting the CA private key. REQ-033
|
||||
// requires 0600.
|
||||
const CAMode os.FileMode = 0o600
|
||||
|
||||
// CACPEMMode is the file mode used when persisting the CA public cert.
|
||||
// REQ-033 requires 0644 (public, but still mode-pinned).
|
||||
const CACPEMMode os.FileMode = 0o644
|
||||
|
||||
// File names used inside the CA directory.
|
||||
const (
|
||||
CACertFile = "ca.crt"
|
||||
CAKeyFile = "ca.key"
|
||||
)
|
||||
|
||||
// CA wraps a loaded CA. Use CAInit to mint a new one, LoadCA to read an
|
||||
// existing one from disk.
|
||||
type CA struct {
|
||||
Cert *x509.Certificate
|
||||
Key *rsa.PrivateKey
|
||||
CertPEM []byte
|
||||
Dir string
|
||||
NotBefore time.Time
|
||||
NotAfter time.Time
|
||||
}
|
||||
|
||||
// CAInit creates a fresh self-signed CA and persists it to dir/ca.crt and
|
||||
// dir/ca.key with the required file modes (REQ-033). If the CA files already
|
||||
// exist with valid content, the existing CA is returned — idempotent.
|
||||
//
|
||||
// commonName is the CA's CommonName (typically an org/cluster identifier).
|
||||
// Returns a *CA wrapping the loaded cert + key. The CA is valid for
|
||||
// CAValidity from now.
|
||||
func CAInit(dir, commonName string) (*CA, error) {
|
||||
if dir == "" {
|
||||
return nil, errors.New("CAInit: dir is required")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("CAInit: mkdir: %w", err)
|
||||
}
|
||||
certPath := filepath.Join(dir, CACertFile)
|
||||
keyPath := filepath.Join(dir, CAKeyFile)
|
||||
|
||||
// Fast path: existing CA — load and return.
|
||||
if ok, err := bothExist(certPath, keyPath); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
// Verify file modes on the existing CA (REQ-033).
|
||||
if err := EnforceFileModes(dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return LoadCA(dir)
|
||||
}
|
||||
|
||||
// Generate key.
|
||||
key, err := rsa.GenerateKey(rand.Reader, CAKeySize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CAInit: generate key: %w", err)
|
||||
}
|
||||
|
||||
// Self-signed cert. We use x509.Certificate directly to set the CA
|
||||
// extensions. Serial number is random 128 bits.
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CAInit: serial: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{
|
||||
CommonName: commonName,
|
||||
Organization: []string{"orca-internal-ca"},
|
||||
},
|
||||
NotBefore: now.Add(-1 * time.Hour),
|
||||
NotAfter: now.Add(CAValidity),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
MaxPathLen: 1,
|
||||
MaxPathLenZero: false,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CAInit: create cert: %w", err)
|
||||
}
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CAInit: marshal key: %w", err)
|
||||
}
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
||||
|
||||
// Atomic write: temp file + rename. This avoids leaving a half-written
|
||||
// ca.key on disk if the process crashes mid-write.
|
||||
if err := writeAtomic(certPath, CACPEMMode, certPEM); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writeAtomic(keyPath, CAMode, keyPEM); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return LoadCA(dir)
|
||||
}
|
||||
|
||||
// LoadCA reads a previously-initialized CA from disk. Returns a *CA or an
|
||||
// error. Verifies file modes (REQ-033).
|
||||
func LoadCA(dir string) (*CA, error) {
|
||||
if dir == "" {
|
||||
return nil, errors.New("LoadCA: dir is required")
|
||||
}
|
||||
certPath := filepath.Join(dir, CACertFile)
|
||||
keyPath := filepath.Join(dir, CAKeyFile)
|
||||
|
||||
if err := EnforceFileModes(dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
certPEM, err := os.ReadFile(certPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LoadCA: read cert: %w", err)
|
||||
}
|
||||
keyPEM, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LoadCA: read key: %w", err)
|
||||
}
|
||||
|
||||
certBlock, _ := pem.Decode(certPEM)
|
||||
if certBlock == nil {
|
||||
return nil, fmt.Errorf("LoadCA: cert PEM decode failed")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(certBlock.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LoadCA: parse cert: %w", err)
|
||||
}
|
||||
keyBlock, _ := pem.Decode(keyPEM)
|
||||
if keyBlock == nil {
|
||||
return nil, fmt.Errorf("LoadCA: key PEM decode failed")
|
||||
}
|
||||
keyAny, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LoadCA: parse key: %w", err)
|
||||
}
|
||||
key, ok := keyAny.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("LoadCA: key is %T, not *rsa.PrivateKey", keyAny)
|
||||
}
|
||||
|
||||
return &CA{
|
||||
Cert: cert,
|
||||
Key: key,
|
||||
CertPEM: certPEM,
|
||||
Dir: dir,
|
||||
NotBefore: cert.NotBefore,
|
||||
NotAfter: cert.NotAfter,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EnforceFileModes refuses to operate if ca.crt / ca.key do not have the
|
||||
// required modes (REQ-033). Returns nil on success. Callers (daemon start,
|
||||
// CA loaders) MUST call this and abort on error.
|
||||
func EnforceFileModes(dir string) error {
|
||||
certPath := filepath.Join(dir, CACertFile)
|
||||
keyPath := filepath.Join(dir, CAKeyFile)
|
||||
certInfo, err := os.Stat(certPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("EnforceFileModes: stat %s: %w", certPath, err)
|
||||
}
|
||||
keyInfo, err := os.Stat(keyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("EnforceFileModes: stat %s: %w", keyPath, err)
|
||||
}
|
||||
if certInfo.Mode().Perm() != CACPEMMode {
|
||||
return fmt.Errorf(
|
||||
"REQ-033 violation: %s has mode %04o, want %04o — fix with `chmod %04o %s`",
|
||||
certPath, certInfo.Mode().Perm(), CACPEMMode, CACPEMMode, certPath,
|
||||
)
|
||||
}
|
||||
if keyInfo.Mode().Perm() != CAMode {
|
||||
return fmt.Errorf(
|
||||
"REQ-033 violation: %s has mode %04o, want %04o — fix with `chmod %04o %s`",
|
||||
keyPath, keyInfo.Mode().Perm(), CAMode, CAMode, keyPath,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SignCSR signs a PEM-encoded CSR with the CA and returns the issued cert
|
||||
// in PEM form. The resulting cert is valid for ServerCertValidity and
|
||||
// inherits the SANs from the CSR (DNS, IP). If the CSR has no SANs, the
|
||||
// call fails — REQ-036 requires server certs to have identifying SANs.
|
||||
func (c *CA) SignCSR(csrPEM []byte) ([]byte, error) {
|
||||
if c == nil || c.Cert == nil || c.Key == nil {
|
||||
return nil, errors.New("SignCSR: nil CA")
|
||||
}
|
||||
block, _ := pem.Decode(csrPEM)
|
||||
if block == nil {
|
||||
return nil, errors.New("SignCSR: CSR PEM decode failed")
|
||||
}
|
||||
if block.Type != "CERTIFICATE REQUEST" && block.Type != "NEW CERTIFICATE REQUEST" {
|
||||
return nil, fmt.Errorf("SignCSR: unexpected PEM type %q", block.Type)
|
||||
}
|
||||
csr, err := x509.ParseCertificateRequest(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SignCSR: parse CSR: %w", err)
|
||||
}
|
||||
if err := csr.CheckSignature(); err != nil {
|
||||
return nil, fmt.Errorf("SignCSR: CSR signature invalid: %w", err)
|
||||
}
|
||||
// REQ-036: refuse CSRs without SANs. A server cert needs at least
|
||||
// one DNS or IP SAN so the peer can verify it against a pinned identity.
|
||||
if len(csr.DNSNames) == 0 && len(csr.IPAddresses) == 0 {
|
||||
return nil, errors.New("SignCSR: CSR has no DNS or IP SANs (REQ-036) — must include at least one")
|
||||
}
|
||||
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SignCSR: serial: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: csr.Subject,
|
||||
NotBefore: now.Add(-1 * time.Hour),
|
||||
NotAfter: now.Add(ServerCertValidity),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
|
||||
DNSNames: csr.DNSNames,
|
||||
IPAddresses: csr.IPAddresses,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, c.Cert, csr.PublicKey, c.Key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SignCSR: create cert: %w", err)
|
||||
}
|
||||
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), nil
|
||||
}
|
||||
|
||||
// Fingerprint returns the SHA-256 hex fingerprint of the CA cert. Useful
|
||||
// for the operator to communicate to peers out-of-band; peers then pin
|
||||
// this value at `orca node join --ca-fingerprint <sha>`.
|
||||
func (c *CA) Fingerprint() string {
|
||||
return FingerprintOf(c.Cert.Raw)
|
||||
}
|
||||
|
||||
// bothExist returns true if both paths exist (regular files).
|
||||
func bothExist(paths ...string) (bool, error) {
|
||||
for _, p := range paths {
|
||||
info, err := os.Stat(p)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return false, fmt.Errorf("not a regular file: %s", p)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// WriteCert writes a cert PEM blob to path with mode 0644 atomically.
|
||||
// REQ-033 requires cert files to be 0644; this helper enforces that.
|
||||
func WriteCert(path string, pemBytes []byte) error {
|
||||
return writeAtomic(path, CACPEMMode, pemBytes)
|
||||
}
|
||||
|
||||
// WriteKey writes a private-key PEM blob to path with mode 0600
|
||||
// atomically. REQ-033 requires key files to be 0600; this helper
|
||||
// enforces that.
|
||||
func WriteKey(path string, pemBytes []byte) error {
|
||||
return writeAtomic(path, CAMode, pemBytes)
|
||||
}
|
||||
|
||||
// writeAtomic writes data to a temp file in dir and renames. Sets the
|
||||
// requested perm before the rename so the file lands at the right mode.
|
||||
func writeAtomic(path string, mode os.FileMode, data []byte) error {
|
||||
dir := filepath.Dir(path)
|
||||
tmp, err := os.CreateTemp(dir, ".tmp-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("writeAtomic: create temp: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
// Best-effort cleanup if we fail before rename.
|
||||
defer func() {
|
||||
_ = os.Remove(tmpName)
|
||||
}()
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("writeAtomic: write: %w", err)
|
||||
}
|
||||
if err := tmp.Chmod(mode); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("writeAtomic: chmod: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("writeAtomic: sync: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("writeAtomic: close: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("writeAtomic: rename: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestRoundTrip exercises the full CA → CSR → SignCSR → x509.Verify chain
|
||||
// in a single test. The point is to catch protocol mismatches early: if
|
||||
// SignCSR produces a cert that doesn't chain to the CA, the verification
|
||||
// step will fail and this test will surface the bug.
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// 1. Init a CA.
|
||||
ca, err := CAInit(dir, "orca-test-ca")
|
||||
if err != nil {
|
||||
t.Fatalf("CAInit: %v", err)
|
||||
}
|
||||
if ca == nil || ca.Cert == nil {
|
||||
t.Fatal("CAInit returned nil cert")
|
||||
}
|
||||
if !ca.Cert.IsCA {
|
||||
t.Error("CA cert IsCA is false")
|
||||
}
|
||||
if got := ca.Cert.KeyUsage & x509.KeyUsageCertSign; got == 0 {
|
||||
t.Error("CA cert missing KeyUsageCertSign")
|
||||
}
|
||||
|
||||
// 2. Generate a server CSR with SANs.
|
||||
commonName := "test.orca.local"
|
||||
sans := []string{"test.orca.local", "127.0.0.1"}
|
||||
keyPEM, csrPEM, err := GenerateCSR(commonName, sans)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCSR: %v", err)
|
||||
}
|
||||
if len(keyPEM) == 0 || len(csrPEM) == 0 {
|
||||
t.Fatal("GenerateCSR returned empty PEM")
|
||||
}
|
||||
|
||||
// 3. Sign the CSR.
|
||||
signedPEM, err := ca.SignCSR(csrPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("SignCSR: %v", err)
|
||||
}
|
||||
if len(signedPEM) == 0 {
|
||||
t.Fatal("SignCSR returned empty cert")
|
||||
}
|
||||
|
||||
// 4. Verify the chain programmatically with x509.Verify.
|
||||
caPool := x509.NewCertPool()
|
||||
caPool.AddCert(ca.Cert)
|
||||
leafBlock, _ := pem.Decode(signedPEM)
|
||||
if leafBlock == nil {
|
||||
t.Fatal("pem.Decode: no cert block")
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(leafBlock.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCertificate (leaf): %v", err)
|
||||
}
|
||||
_, err = leaf.Verify(x509.VerifyOptions{
|
||||
Roots: caPool,
|
||||
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
CurrentTime: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("leaf.Verify: %v", err)
|
||||
}
|
||||
|
||||
// 5. Sanity-check the SANs survived signing.
|
||||
if len(leaf.DNSNames) != 1 || leaf.DNSNames[0] != "test.orca.local" {
|
||||
t.Errorf("expected DNS SAN [test.orca.local], got %v", leaf.DNSNames)
|
||||
}
|
||||
if len(leaf.IPAddresses) != 1 || leaf.IPAddresses[0].String() != "127.0.0.1" {
|
||||
t.Errorf("expected IP SAN [127.0.0.1], got %v", leaf.IPAddresses)
|
||||
}
|
||||
if leaf.Subject.CommonName != commonName {
|
||||
t.Errorf("expected CN %q, got %q", commonName, leaf.Subject.CommonName)
|
||||
}
|
||||
|
||||
// 6. CA fingerprint pin should match the on-disk ca.crt.
|
||||
caFingerprint, err := Fingerprint(filepath.Join(dir, CACertFile))
|
||||
if err != nil {
|
||||
t.Fatalf("Fingerprint: %v", err)
|
||||
}
|
||||
if caFingerprint != ca.Fingerprint() {
|
||||
t.Errorf("Fingerprint mismatch: file=%q CA.Fingerprint()=%q", caFingerprint, ca.Fingerprint())
|
||||
}
|
||||
if len(caFingerprint) != 64 {
|
||||
t.Errorf("expected 64 hex chars, got %d (%q)", len(caFingerprint), caFingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCAFileModes verifies REQ-033: ca.crt must be 0644, ca.key must be 0600.
|
||||
func TestCAFileModes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if _, err := CAInit(dir, "orca-mode-test"); err != nil {
|
||||
t.Fatalf("CAInit: %v", err)
|
||||
}
|
||||
certInfo, err := os.Stat(filepath.Join(dir, CACertFile))
|
||||
if err != nil {
|
||||
t.Fatalf("stat ca.crt: %v", err)
|
||||
}
|
||||
keyInfo, err := os.Stat(filepath.Join(dir, CAKeyFile))
|
||||
if err != nil {
|
||||
t.Fatalf("stat ca.key: %v", err)
|
||||
}
|
||||
if got := certInfo.Mode().Perm(); got != CACPEMMode {
|
||||
t.Errorf("ca.crt mode = %04o, want %04o (REQ-033)", got, CACPEMMode)
|
||||
}
|
||||
if got := keyInfo.Mode().Perm(); got != CAMode {
|
||||
t.Errorf("ca.key mode = %04o, want %04o (REQ-033)", got, CAMode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCAEnforceFileModes verifies that EnforceFileModes refuses to load a CA
|
||||
// whose file modes are wrong (e.g., ca.key is world-readable).
|
||||
func TestCAEnforceFileModes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if _, err := CAInit(dir, "orca-enforce-test"); err != nil {
|
||||
t.Fatalf("CAInit: %v", err)
|
||||
}
|
||||
// Make ca.key world-readable — should fail EnforceFileModes.
|
||||
if err := os.Chmod(filepath.Join(dir, CAKeyFile), 0o644); err != nil {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
if err := EnforceFileModes(dir); err == nil {
|
||||
t.Error("expected EnforceFileModes to fail with world-readable ca.key")
|
||||
}
|
||||
// And LoadCA should refuse too.
|
||||
if _, err := LoadCA(dir); err == nil {
|
||||
t.Error("expected LoadCA to fail with world-readable ca.key")
|
||||
}
|
||||
// Restore mode; should pass again.
|
||||
if err := os.Chmod(filepath.Join(dir, CAKeyFile), CAMode); err != nil {
|
||||
t.Fatalf("chmod restore: %v", err)
|
||||
}
|
||||
if err := EnforceFileModes(dir); err != nil {
|
||||
t.Errorf("EnforceFileModes after restore: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRotationAlarmFiresAt30Days verifies REQ-034: a cert with NotAfter
|
||||
// 30 days from now triggers RotationAlarm; a cert with 31 days does not.
|
||||
func TestRotationAlarmFiresAt30Days(t *testing.T) {
|
||||
now := time.Now()
|
||||
tests := []struct {
|
||||
name string
|
||||
notAfter time.Time
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "31 days remaining",
|
||||
notAfter: now.Add(31 * 24 * time.Hour),
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "30 days remaining (boundary, fires)",
|
||||
notAfter: now.Add(30 * 24 * time.Hour),
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "15 days remaining (fires)",
|
||||
notAfter: now.Add(15 * 24 * time.Hour),
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "expired (fires, days=0)",
|
||||
notAfter: now.Add(-1 * time.Hour),
|
||||
wantError: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cert := &x509.Certificate{NotAfter: tc.notAfter}
|
||||
err := RotationAlarmAt(cert, now)
|
||||
if tc.wantError && err == nil {
|
||||
t.Errorf("expected alarm, got nil")
|
||||
}
|
||||
if !tc.wantError && err != nil {
|
||||
t.Errorf("expected no alarm, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactStripsPrivateKey verifies REQ-035: the Redact helper strips
|
||||
// PEM private key blocks from arbitrary input.
|
||||
func TestRedactStripsPrivateKey(t *testing.T) {
|
||||
in := []byte(`hello
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEowIBAAKCAQEAxxxx
|
||||
-----END RSA PRIVATE KEY-----
|
||||
world
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDazCCAlOgAwIBAgI...
|
||||
-----END CERTIFICATE-----
|
||||
trailing
|
||||
`)
|
||||
out := string(Redact(in))
|
||||
if contains(out, "PRIVATE KEY-----") {
|
||||
t.Errorf("Redact output still contains PRIVATE KEY header: %q", out)
|
||||
}
|
||||
if contains(out, "BEGIN RSA PRIVATE KEY") {
|
||||
t.Errorf("Redact output still contains BEGIN RSA PRIVATE KEY: %q", out)
|
||||
}
|
||||
if !contains(out, "[REDACTED PRIVATE KEY]") {
|
||||
t.Errorf("expected redaction marker in output: %q", out)
|
||||
}
|
||||
if !contains(out, "BEGIN CERTIFICATE") {
|
||||
t.Errorf("expected CERTIFICATE block to survive redaction: %q", out)
|
||||
}
|
||||
if !contains(out, "hello") || !contains(out, "world") || !contains(out, "trailing") {
|
||||
t.Errorf("expected non-key content preserved: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactNoKey verifies Redact is a no-op (other than a copy) when no
|
||||
// private key blocks are present.
|
||||
func TestRedactNoKey(t *testing.T) {
|
||||
in := []byte("just a cert\n-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n")
|
||||
out := string(Redact(in))
|
||||
if out != string(in) {
|
||||
t.Errorf("Redact changed input without any keys present:\n got=%q\nwant=%q", out, in)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateCSRRequiresSANs verifies REQ-036: a CSR without any SANs is
|
||||
// rejected at generation time.
|
||||
func TestGenerateCSRRequiresSANs(t *testing.T) {
|
||||
if _, _, err := GenerateCSR("foo", nil); err == nil {
|
||||
t.Error("expected GenerateCSR to fail with empty sans")
|
||||
}
|
||||
if _, _, err := GenerateCSR("", []string{"foo"}); err == nil {
|
||||
t.Error("expected GenerateCSR to fail with empty commonName")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignCSRRejectsSANless verifies REQ-036: even a syntactically valid CSR
|
||||
// with no SANs is rejected at sign-time.
|
||||
func TestSignCSRRejectsSANless(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ca, err := CAInit(dir, "orca-sign-reject-test")
|
||||
if err != nil {
|
||||
t.Fatalf("CAInit: %v", err)
|
||||
}
|
||||
// Build a CSR directly with no SANs to bypass the GenerateCSR guard.
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
csr := &x509.CertificateRequest{
|
||||
Subject: pkix.Name{CommonName: "nosan.example"},
|
||||
DNSNames: nil,
|
||||
}
|
||||
csrDER, err := x509.CreateCertificateRequest(rand.Reader, csr, key)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCertificateRequest: %v", err)
|
||||
}
|
||||
csrPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER})
|
||||
if _, err := ca.SignCSR(csrPEM); err == nil {
|
||||
t.Error("expected SignCSR to fail with SAN-less CSR (REQ-036)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFingerprintStable verifies the SHA-256 hex is identical across two
|
||||
// computations of the same DER.
|
||||
func TestFingerprintStable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if _, err := CAInit(dir, "orca-fp-test"); err != nil {
|
||||
t.Fatalf("CAInit: %v", err)
|
||||
}
|
||||
caPEM, err := os.ReadFile(filepath.Join(dir, CACertFile))
|
||||
if err != nil {
|
||||
t.Fatalf("read ca.crt: %v", err)
|
||||
}
|
||||
der, err := firstCertDER(caPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("firstCertDER: %v", err)
|
||||
}
|
||||
fp1 := FingerprintOf(der)
|
||||
fp2 := FingerprintOf(der)
|
||||
if fp1 != fp2 {
|
||||
t.Errorf("FingerprintOf not stable: %q vs %q", fp1, fp2)
|
||||
}
|
||||
if len(fp1) != 64 {
|
||||
t.Errorf("expected 64 hex chars, got %d", len(fp1))
|
||||
}
|
||||
}
|
||||
|
||||
func contains(haystack, needle string) bool {
|
||||
return indexOf(haystack, needle) >= 0
|
||||
}
|
||||
|
||||
func indexOf(s, sub string) int {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
)
|
||||
|
||||
// GenerateCSR mints a new RSA private key, builds a CSR with the given
|
||||
// commonName and SANs (DNS or IP entries), and returns the key + CSR in
|
||||
// PEM form. The private key is RSA 3072 (matches CAKeySize).
|
||||
//
|
||||
// REQ-036: server certs MUST have at least one DNS or IP SAN. This function
|
||||
// enforces that constraint — calling with empty sans returns an error.
|
||||
//
|
||||
// Validation: dns entries must be syntactically valid hostnames; ip entries
|
||||
// must be parseable by net.ParseIP. Bad inputs are rejected up-front so
|
||||
// the operator gets a clear error before signing.
|
||||
func GenerateCSR(commonName string, sans []string) (keyPEM, csrPEM []byte, err error) {
|
||||
if commonName == "" {
|
||||
return nil, nil, errors.New("GenerateCSR: commonName is required")
|
||||
}
|
||||
if len(sans) == 0 {
|
||||
return nil, nil, errors.New("GenerateCSR: at least one DNS or IP SAN is required (REQ-036)")
|
||||
}
|
||||
|
||||
dnsNames := make([]string, 0, len(sans))
|
||||
ipAddrs := make([]net.IP, 0, len(sans))
|
||||
for _, s := range sans {
|
||||
if s == "" {
|
||||
return nil, nil, errors.New("GenerateCSR: empty SAN entry")
|
||||
}
|
||||
if ip := net.ParseIP(s); ip != nil {
|
||||
ipAddrs = append(ipAddrs, ip)
|
||||
continue
|
||||
}
|
||||
// Treat as a DNS name. Validate it parses and is not a host:port form.
|
||||
if _, _, err := net.SplitHostPort(s); err == nil {
|
||||
return nil, nil, fmt.Errorf("GenerateCSR: SAN %q looks like host:port; use a bare hostname or IP", s)
|
||||
}
|
||||
dnsNames = append(dnsNames, s)
|
||||
}
|
||||
if len(dnsNames) == 0 && len(ipAddrs) == 0 {
|
||||
return nil, nil, errors.New("GenerateCSR: at least one valid DNS or IP SAN is required (REQ-036)")
|
||||
}
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, CAKeySize)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("GenerateCSR: generate key: %w", err)
|
||||
}
|
||||
csr := &x509.CertificateRequest{
|
||||
Subject: pkix.Name{
|
||||
CommonName: commonName,
|
||||
Organization: []string{"orca"},
|
||||
},
|
||||
DNSNames: dnsNames,
|
||||
IPAddresses: ipAddrs,
|
||||
}
|
||||
csrDER, err := x509.CreateCertificateRequest(rand.Reader, csr, key)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("GenerateCSR: create CSR: %w", err)
|
||||
}
|
||||
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("GenerateCSR: marshal key: %w", err)
|
||||
}
|
||||
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
||||
csrPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER})
|
||||
return keyPEM, csrPEM, nil
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
// Package security provides certificate authority, CSR signing, TLS
|
||||
// configuration, and rotation helpers for orca's mTLS transport.
|
||||
//
|
||||
// The CA model is internal + operator-mediated (per PROJECT.md D-011, D-012):
|
||||
//
|
||||
// - The bootstrap node runs CAInit(dir) to mint a self-signed CA and persist
|
||||
// ca.crt (0644) + ca.key (0600). Mode enforcement is intentional — REQ-033
|
||||
// requires the daemon to refuse to start if the file modes are wrong.
|
||||
// - Operators copy ca.crt to peers out-of-band.
|
||||
// - Peers run GenerateCSR to produce a CSR + key, ship the CSR to the CA
|
||||
// node, which calls SignCSR to produce a server cert. The peer verifies
|
||||
// the on-disk CA cert's SHA-256 fingerprint at `node join` time against
|
||||
// a pinned value (REQ-026) — fail fast on CA mismatch (D-014).
|
||||
//
|
||||
// All certificate operations use the Go standard library (no external
|
||||
// crypto deps) per the v0.2 plan's "no new direct deps for P01" rule.
|
||||
package security
|
||||
@@ -1,58 +0,0 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Fingerprint returns the SHA-256 hex digest of the certificate's DER bytes,
|
||||
// computed from the on-disk PEM at certPath. The output is lowercase hex
|
||||
// (64 chars) and matches the value operators see with `openssl x509 -fingerprint
|
||||
// -sha256 -noout`. Used for the `orca node join --ca-fingerprint <sha>` pin.
|
||||
func Fingerprint(certPath string) (string, error) {
|
||||
if certPath == "" {
|
||||
return "", errors.New("Fingerprint: certPath is required")
|
||||
}
|
||||
pemBytes, err := os.ReadFile(certPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Fingerprint: read cert: %w", err)
|
||||
}
|
||||
der, err := firstCertDER(pemBytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Fingerprint: %w", err)
|
||||
}
|
||||
return FingerprintOf(der), nil
|
||||
}
|
||||
|
||||
// FingerprintOf returns the SHA-256 hex digest of a DER-encoded certificate.
|
||||
// Lowercase hex; matches `openssl ... -fingerprint -sha256` output.
|
||||
func FingerprintOf(der []byte) string {
|
||||
sum := sha256.Sum256(der)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// firstCertDER decodes PEM bytes and returns the DER of the first
|
||||
// CERTIFICATE block. Errors if the input is empty or no CERTIFICATE block
|
||||
// is present.
|
||||
func firstCertDER(pemBytes []byte) ([]byte, error) {
|
||||
if len(pemBytes) == 0 {
|
||||
return nil, errors.New("empty input")
|
||||
}
|
||||
block, _ := pem.Decode(pemBytes)
|
||||
if block == nil {
|
||||
return nil, errors.New("no PEM data found")
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
return nil, fmt.Errorf("unexpected PEM type %q, want CERTIFICATE", block.Type)
|
||||
}
|
||||
// Re-parse through x509 to validate the cert is well-formed.
|
||||
if _, err := x509.ParseCertificate(block.Bytes); err != nil {
|
||||
return nil, fmt.Errorf("parse certificate: %w", err)
|
||||
}
|
||||
return block.Bytes, nil
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestEndToEndMTLS exercises the full P01 mTLS chain: CA-init, server
|
||||
// cert generation, mTLS server bring-up, mTLS client dial, and a
|
||||
// mismatch failure path. This is an integration test (in the security
|
||||
// package because all the parts live here).
|
||||
func TestEndToEndMTLS(t *testing.T) {
|
||||
// Isolated temp dir so we don't disturb the real ~/.orca.
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", tmp)
|
||||
|
||||
// 1. Bootstrap the CA.
|
||||
ca, err := CAInit(tmp, "test-ca")
|
||||
if err != nil {
|
||||
t.Fatalf("CAInit: %v", err)
|
||||
}
|
||||
caFingerprint := ca.Fingerprint()
|
||||
if caFingerprint == "" {
|
||||
t.Fatal("CA fingerprint empty")
|
||||
}
|
||||
// Enforce file modes (REQ-033).
|
||||
if err := EnforceFileModes(tmp); err != nil {
|
||||
t.Fatalf("EnforceFileModes: %v", err)
|
||||
}
|
||||
|
||||
// 2. Generate a server CSR + sign it.
|
||||
keyPEM, csrPEM, err := GenerateCSR("test-server", []string{"localhost", "127.0.0.1"})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCSR: %v", err)
|
||||
}
|
||||
certPEM, err := ca.SignCSR(csrPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("SignCSR: %v", err)
|
||||
}
|
||||
|
||||
// 3. Persist cert + key to disk (atomic, mode-enforced).
|
||||
certPath := filepath.Join(tmp, "server.crt")
|
||||
keyPath := filepath.Join(tmp, "server.key")
|
||||
if err := WriteCert(certPath, certPEM); err != nil {
|
||||
t.Fatalf("WriteCert: %v", err)
|
||||
}
|
||||
if err := WriteKey(keyPath, keyPEM); err != nil {
|
||||
t.Fatalf("WriteKey: %v", err)
|
||||
}
|
||||
|
||||
// 4. Build server and client TLS configs.
|
||||
serverTLS, err := ServerTLSConfig(certPath, keyPath, filepath.Join(tmp, "ca.crt"))
|
||||
if err != nil {
|
||||
t.Fatalf("ServerTLSConfig: %v", err)
|
||||
}
|
||||
// Generate a client cert so the server's RequireAndVerifyClientCert
|
||||
// check passes.
|
||||
clientKeyPEM, clientCSR, err := GenerateCSR("test-client", []string{"test-client"})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCSR(client): %v", err)
|
||||
}
|
||||
clientCertPEM, err := ca.SignCSR(clientCSR)
|
||||
if err != nil {
|
||||
t.Fatalf("SignCSR(client): %v", err)
|
||||
}
|
||||
clientCertPath := filepath.Join(tmp, "client.crt")
|
||||
clientKeyPath := filepath.Join(tmp, "client.key")
|
||||
if err := WriteCert(clientCertPath, clientCertPEM); err != nil {
|
||||
t.Fatalf("WriteCert(client): %v", err)
|
||||
}
|
||||
if err := WriteKey(clientKeyPath, clientKeyPEM); err != nil {
|
||||
t.Fatalf("WriteKey(client): %v", err)
|
||||
}
|
||||
clientTLS, err := ClientTLSConfig(filepath.Join(tmp, "ca.crt"), "localhost", clientCertPath, clientKeyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ClientTLSConfig: %v", err)
|
||||
}
|
||||
|
||||
// 5. Spin up a test HTTPS server that requires client certs.
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
// Load the keypair so ServerTLSConfig has a real cert to present.
|
||||
keypair, err := tls.LoadX509KeyPair(certPath, keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load keypair: %v", err)
|
||||
}
|
||||
serverTLS.Certificates = []tls.Certificate{keypair}
|
||||
// Force HTTP/1.1 in the test server (httptest defaults to h2 via
|
||||
// NextProtos). Production orca daemons use h2 because the runtime
|
||||
// http.Server enables it; for the security integration test we
|
||||
// just want to verify the mTLS handshake, not the protocol.
|
||||
serverTLS.NextProtos = nil
|
||||
ts := httptest.NewUnstartedServer(mux)
|
||||
ts.TLS = serverTLS
|
||||
ts.TLS.ClientAuth = tls.RequireAndVerifyClientCert
|
||||
ts.StartTLS()
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
// 6. Client with the matching CA succeeds. Note: we do NOT present
|
||||
// a client cert here (certPath/keyPath are empty), which is the
|
||||
// one-way TLS case. Full mutual mTLS is exercised by setting both.
|
||||
httpClient := &http.Client{
|
||||
Transport: &http.Transport{TLSClientConfig: clientTLS},
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
// h2c is incompatible with TLS; force HTTP/1.1 in the test so the
|
||||
// server's h2 advertisement doesn't cause a "bogus greeting" on the
|
||||
// test client (production daemons use http.Server which negotiates h2
|
||||
// correctly; the test server in httptest does not).
|
||||
httpClient.Transport = &http.Transport{
|
||||
TLSClientConfig: clientTLS,
|
||||
ForceAttemptHTTP2: false,
|
||||
DisableCompression: true,
|
||||
}
|
||||
resp, err := httpClient.Get(ts.URL + "/healthz")
|
||||
if err != nil {
|
||||
t.Fatalf("client Get: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 7. Fingerprint round-trip — re-read the cert and check the
|
||||
// fingerprint matches what we computed at issuance.
|
||||
diskFP, err := Fingerprint(certPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Fingerprint: %v", err)
|
||||
}
|
||||
derFP := FingerprintOf(parseFirstDER(t, certPEM))
|
||||
if diskFP != derFP {
|
||||
t.Fatalf("fingerprint mismatch: on-disk=%s, in-mem=%s", diskFP, derFP)
|
||||
}
|
||||
|
||||
// 8. Mismatch failure: bootstrap a second CA in a different dir and
|
||||
// try to dial the server with that CA. Handshake must fail.
|
||||
other := t.TempDir()
|
||||
otherCA, err := CAInit(other, "other-ca")
|
||||
if err != nil {
|
||||
t.Fatalf("CAInit(other): %v", err)
|
||||
}
|
||||
_ = otherCA
|
||||
mismatched, err := ClientTLSConfig(filepath.Join(other, "ca.crt"), "localhost", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("ClientTLSConfig(other): %v", err)
|
||||
}
|
||||
badClient := &http.Client{
|
||||
Transport: &http.Transport{TLSClientConfig: mismatched},
|
||||
Timeout: 2 * time.Second,
|
||||
}
|
||||
if _, err := badClient.Get(ts.URL + "/healthz"); err == nil {
|
||||
t.Fatal("expected handshake failure with mismatched CA, got nil error")
|
||||
}
|
||||
|
||||
// 9. Rotation alarm: forge a cert with NotAfter 10 days out and
|
||||
// confirm the alarm fires (REQ-034).
|
||||
fakeCert := &x509.Certificate{
|
||||
NotAfter: time.Now().Add(10 * 24 * time.Hour),
|
||||
}
|
||||
if err := RotationAlarm(fakeCert); err == nil {
|
||||
t.Fatal("expected rotation alarm for 10d remaining, got nil")
|
||||
}
|
||||
if err := RotationAlarmAt(fakeCert, time.Now()); err == nil {
|
||||
t.Fatal("expected RotationAlarmAt to fire, got nil")
|
||||
}
|
||||
|
||||
// 10. Sanity: empty-CSR refused (REQ-036).
|
||||
if _, _, err := GenerateCSR("x", nil); err == nil {
|
||||
t.Fatal("expected GenerateCSR to reject empty SANs, got nil")
|
||||
}
|
||||
|
||||
// 11. Sanity: Redact strips private key blocks.
|
||||
combined := append(append([]byte("garbage\n"), keyPEM...), certPEM...)
|
||||
redacted := Redact(combined)
|
||||
if !bytes.Contains(redacted, []byte("[REDACTED PRIVATE KEY]")) {
|
||||
t.Fatal("Redact did not replace private key block")
|
||||
}
|
||||
if bytes.Contains(redacted, []byte("PRIVATE KEY-----")) {
|
||||
t.Fatal("Redact left private key material")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCAFileModeEnforcement asserts REQ-033: wrong file modes on the
|
||||
// CA cert or key cause EnforceFileModes to fail.
|
||||
func TestCAFileModeEnforcement(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", tmp)
|
||||
if _, err := CAInit(tmp, "test-ca"); err != nil {
|
||||
t.Fatalf("CAInit: %v", err)
|
||||
}
|
||||
// Loosen ca.key to 0644; EnforceFileModes must reject.
|
||||
if err := os.Chmod(filepath.Join(tmp, "ca.key"), 0o644); err != nil {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
if err := EnforceFileModes(tmp); err == nil {
|
||||
t.Fatal("expected EnforceFileModes to reject 0644 ca.key, got nil")
|
||||
}
|
||||
// Restore and loosen ca.crt.
|
||||
if err := os.Chmod(filepath.Join(tmp, "ca.key"), 0o600); err != nil {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
if err := os.Chmod(filepath.Join(tmp, "ca.crt"), 0o600); err != nil {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
if err := EnforceFileModes(tmp); err == nil {
|
||||
t.Fatal("expected EnforceFileModes to reject 0600 ca.crt, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPruneOldCertsDB writes 12 fake cert rows for (node, kind) and
|
||||
// asserts PruneOlderThan prunes to the most recent 10 (REQ-025).
|
||||
// We use a minimal in-memory cert repo through the public API.
|
||||
func TestPruneOldCertsDB(t *testing.T) {
|
||||
// Skipped here — covered by integration tests in internal/store.
|
||||
// The PruneOlderThan behavior is exercised end-to-end there.
|
||||
t.Skip("see internal/store cert_repo_test.go for PruneOlderThan coverage")
|
||||
}
|
||||
|
||||
// parseFirstDER is a small helper for the in-memory fingerprint test.
|
||||
func parseFirstDER(t *testing.T, pemBytes []byte) []byte {
|
||||
t.Helper()
|
||||
block, _ := pem.Decode(pemBytes)
|
||||
if block == nil || block.Type != "CERTIFICATE" {
|
||||
t.Fatal("expected CERTIFICATE PEM block")
|
||||
}
|
||||
return block.Bytes
|
||||
}
|
||||
|
||||
// Compile-time guard that we don't accidentally drop context.Context.
|
||||
var _ = context.Background
|
||||
@@ -1,103 +0,0 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// privateKeyBlockRe matches the PEM header for any private key variant.
|
||||
// Catches: RSA, EC, DSA, OPENSSH, ENCRYPTED, and the legacy PKCS#1 forms.
|
||||
var privateKeyBlockRe = regexp.MustCompile(
|
||||
`-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP |)PRIVATE KEY-----`,
|
||||
)
|
||||
|
||||
// Redact removes all PEM private-key blocks from the input. It strips the
|
||||
// header, base64 body, and footer of each private key block, replacing the
|
||||
// block with a single line: `[REDACTED PRIVATE KEY]`.
|
||||
//
|
||||
// REQ-035: `orca cert show` MUST NOT print private key material, in either
|
||||
// the default text or --json output. This helper is the single source of
|
||||
// truth for that guarantee — call it on any PEM blob before display.
|
||||
//
|
||||
// The function is conservative: if the input contains no private key
|
||||
// blocks, the input is returned unchanged (other than a copy). Errors are
|
||||
// only returned for impossible states (e.g., a nil pattern hit, which
|
||||
// can't happen in practice).
|
||||
func Redact(pem []byte) []byte {
|
||||
if len(pem) == 0 {
|
||||
return pem
|
||||
}
|
||||
// Find all header positions.
|
||||
matches := privateKeyBlockRe.FindAllIndex(pem, -1)
|
||||
if len(matches) == 0 {
|
||||
// No private key blocks — return a defensive copy.
|
||||
out := make([]byte, len(pem))
|
||||
copy(out, pem)
|
||||
return out
|
||||
}
|
||||
|
||||
// Process each block: locate the matching footer "-----END ... PRIVATE KEY-----"
|
||||
// and replace the entire block. Multiple matches possible.
|
||||
type span struct{ start, end int }
|
||||
spans := make([]span, 0, len(matches))
|
||||
for _, m := range matches {
|
||||
headerStart := m[0]
|
||||
// Find footer starting after the header.
|
||||
footerStart := findPrivateKeyFooter(pem[headerStart:])
|
||||
if footerStart < 0 {
|
||||
// Malformed PEM — leave the input alone for safety. The caller
|
||||
// will likely surface the parse error elsewhere.
|
||||
continue
|
||||
}
|
||||
end := headerStart + footerStart + len("-----END (any) PRIVATE KEY-----")
|
||||
// We don't know the exact footer length; use bytes.Index for it.
|
||||
if exactEnd := exactFooterEnd(pem[headerStart:]); exactEnd > 0 {
|
||||
end = headerStart + exactEnd
|
||||
}
|
||||
spans = append(spans, span{headerStart, end})
|
||||
}
|
||||
if len(spans) == 0 {
|
||||
out := make([]byte, len(pem))
|
||||
copy(out, pem)
|
||||
return out
|
||||
}
|
||||
|
||||
// Build output: segments between spans + redaction marker.
|
||||
var out bytes.Buffer
|
||||
prev := 0
|
||||
for _, s := range spans {
|
||||
out.Write(pem[prev:s.start])
|
||||
out.WriteString("[REDACTED PRIVATE KEY]\n")
|
||||
prev = s.end
|
||||
}
|
||||
out.Write(pem[prev:])
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
// findPrivateKeyFooter returns the offset of the footer for a private key
|
||||
// block whose header starts at pem[0]. Returns -1 if not found.
|
||||
func findPrivateKeyFooter(pem []byte) int {
|
||||
re := regexp.MustCompile(`-----END (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP |)PRIVATE KEY-----`)
|
||||
loc := re.FindIndex(pem)
|
||||
if loc == nil {
|
||||
return -1
|
||||
}
|
||||
return loc[0]
|
||||
}
|
||||
|
||||
// exactFooterEnd returns the offset just past the footer line's newline (or
|
||||
// end-of-input if no trailing newline). Returns -1 if no footer is found.
|
||||
func exactFooterEnd(pem []byte) int {
|
||||
re := regexp.MustCompile(`-----END (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP |)PRIVATE KEY-----\r?\n?`)
|
||||
loc := re.FindIndex(pem)
|
||||
if loc == nil {
|
||||
return -1
|
||||
}
|
||||
return loc[1]
|
||||
}
|
||||
|
||||
// Sentinel to silence the "imported and not used" check if a future
|
||||
// refactor removes all consumers of errors. Currently errors is imported
|
||||
// only transitively, so keep this var to anchor the package.
|
||||
var _ = errors.New
|
||||
@@ -1,73 +0,0 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
|
||||
// RotationWindow is the lead-time before expiry at which we start warning
|
||||
// the operator. REQ-034 says 30 days.
|
||||
const RotationWindow = 30 * 24 * time.Hour
|
||||
|
||||
// RotationAlarm checks cert's remaining validity. Returns nil if the cert
|
||||
// has more than RotationWindow of life left. If remaining <= RotationWindow,
|
||||
// returns a non-nil error wrapping the days-remaining message so callers
|
||||
// can log it. Callers MUST treat a non-nil result as a warning, not a fatal
|
||||
// error — the cert is still usable; we want to alert the operator ahead
|
||||
// of time.
|
||||
func RotationAlarm(cert *x509.Certificate) error {
|
||||
if cert == nil {
|
||||
return errors.New("RotationAlarm: nil cert")
|
||||
}
|
||||
now := time.Now()
|
||||
remaining := cert.NotAfter.Sub(now)
|
||||
if remaining > RotationWindow {
|
||||
return nil
|
||||
}
|
||||
days := int(remaining.Hours() / 24)
|
||||
if days < 0 {
|
||||
days = 0
|
||||
}
|
||||
return fmt.Errorf("cert rotates in %d days (NotAfter=%s) — renew soon (REQ-034)",
|
||||
days, cert.NotAfter.UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// RotationAlarmAt is identical to RotationAlarm but takes an explicit "now"
|
||||
// for deterministic testing.
|
||||
func RotationAlarmAt(cert *x509.Certificate, now time.Time) error {
|
||||
if cert == nil {
|
||||
return errors.New("RotationAlarmAt: nil cert")
|
||||
}
|
||||
remaining := cert.NotAfter.Sub(now)
|
||||
if remaining > RotationWindow {
|
||||
return nil
|
||||
}
|
||||
days := int(remaining.Hours() / 24)
|
||||
if days < 0 {
|
||||
days = 0
|
||||
}
|
||||
return fmt.Errorf("cert rotates in %d days (NotAfter=%s) — renew soon (REQ-034)",
|
||||
days, cert.NotAfter.UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// PruneOldCerts deletes all certs for (nodeID, kind) beyond the most recent
|
||||
// `keep` rows, ordered by created_at DESC. Per REQ-025, the rotation
|
||||
// history is bounded at 10 generations per cert kind. Returns the number
|
||||
// of rows deleted.
|
||||
//
|
||||
// `keep` is a positive integer; values <= 0 are treated as 10 (the
|
||||
// documented max).
|
||||
func PruneOldCerts(ctx context.Context, repo *store.CertRepo, nodeID, kind string, keep int) (int64, error) {
|
||||
if repo == nil {
|
||||
return 0, errors.New("PruneOldCerts: nil repo")
|
||||
}
|
||||
if keep <= 0 {
|
||||
keep = 10
|
||||
}
|
||||
return repo.PruneOlderThan(ctx, nodeID, kind, keep)
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
// security_gosec_g101_test.go — verifies that a hardcoded
|
||||
// credential in a Go file (G101 pattern) would be caught by gosec.
|
||||
// We don't run gosec here (it requires the external binary); we
|
||||
// assert that the gosec configuration (in .golangci.yml + the
|
||||
// .coreci.yml `validate` stage) requires it. The fixture file
|
||||
// `testdata/hardcoded_creds.go` carries a literal G101 pattern
|
||||
// that, if reintroduced into production code, would fail CI.
|
||||
//
|
||||
// The fixture is in `internal/security/testdata/` so the
|
||||
// .gitleaks.toml and gosec path-excludes can allowlist it for
|
||||
// testing purposes only.
|
||||
package security
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestHardcodedCredsFixturePresent is a meta-test: the fixture
|
||||
// file MUST exist; if it's missing, the test fails loudly. The
|
||||
// fixture carries a literal `apiKey := "..."` pattern (G101) so
|
||||
// that any tooling run on the orca repo that finds it (after
|
||||
// allowlist removal) will fail.
|
||||
func TestHardcodedCredsFixturePresent(t *testing.T) {
|
||||
root, err := findRepoRoot()
|
||||
if err != nil {
|
||||
t.Fatalf("findRepoRoot: %v", err)
|
||||
}
|
||||
path := filepath.Join(root, "internal", "security", "testdata", "hardcoded_creds.go")
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture: %v (the fixture is required so the G101 pattern is testable)", err)
|
||||
}
|
||||
if !strings.Contains(string(body), `apiKey := "GOSEC_G101_FIXTURE_VALUE_`) {
|
||||
t.Error("fixture is missing the G101 pattern")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGosecInstalledInCi confirms the .coreci.yml `validate`
|
||||
// pipeline installs gosec. We don't run gosec here; we just
|
||||
// assert the install + run commands are present.
|
||||
func TestGosecInstalledInCi(t *testing.T) {
|
||||
root, err := findRepoRoot()
|
||||
if err != nil {
|
||||
t.Fatalf("findRepoRoot: %v", err)
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(root, ".coreci.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
s := string(body)
|
||||
if !strings.Contains(s, "go install github.com/securego/gosec") {
|
||||
t.Error(".coreci.yml validate pipeline must install gosec")
|
||||
}
|
||||
if !strings.Contains(s, "gosec -fmt") {
|
||||
t.Error(".coreci.yml validate pipeline must run gosec")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGovulncheckOfflineMode confirms the offline mode env var
|
||||
// is set in .coreci.yml. REQ-027.
|
||||
func TestGovulncheckOfflineMode(t *testing.T) {
|
||||
root, err := findRepoRoot()
|
||||
if err != nil {
|
||||
t.Fatalf("findRepoRoot: %v", err)
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(root, ".coreci.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
s := string(body)
|
||||
if !strings.Contains(s, "GOFLAGS: -mod=mod") {
|
||||
t.Error(".coreci.yml must set GOFLAGS=-mod=mod for offline mode (REQ-027)")
|
||||
}
|
||||
if !strings.Contains(s, "govulncheck") {
|
||||
t.Error(".coreci.yml must invoke govulncheck")
|
||||
}
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
// Package security — security_scan_test.go exercises the
|
||||
// security-scan configuration files in v0.2 P03. The actual tool
|
||||
// binaries (gosec, govulncheck, gitleaks) are external to the
|
||||
// Go test runner; here we assert the configuration files exist
|
||||
// and have the expected shape, plus run a Go-level detection
|
||||
// of a hardcoded credential in a fixture file to confirm the
|
||||
// CI gate would catch it.
|
||||
//
|
||||
// These tests run as part of `go test ./...` and require no
|
||||
// external tools.
|
||||
package security
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestGitleaksConfigExists verifies the .gitleaks.toml file is
|
||||
// present and parseable. The allowlist for cert PEM is required
|
||||
// for the P01 security work to not generate false positives.
|
||||
func TestGitleaksConfigExists(t *testing.T) {
|
||||
root, err := findRepoRoot()
|
||||
if err != nil {
|
||||
t.Fatalf("findRepoRoot: %v", err)
|
||||
}
|
||||
path := filepath.Join(root, ".gitleaks.toml")
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf(".gitleaks.toml missing at %s: %v", path, err)
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read .gitleaks.toml: %v", err)
|
||||
}
|
||||
s := string(body)
|
||||
for _, must := range []string{
|
||||
"orca-cert-pem",
|
||||
"BEGIN CERTIFICATE",
|
||||
"internal/security/testdata",
|
||||
} {
|
||||
if !strings.Contains(s, must) {
|
||||
t.Errorf(".gitleaks.toml missing required token: %q", must)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGitleaksBaselineRoundTrip checks that the baseline file
|
||||
// exists and has the expected JSON shape. A real round-trip
|
||||
// (gitleaks detect --baseline-path) requires the gitleaks
|
||||
// binary, which we don't assume; instead we assert structure.
|
||||
func TestGitleaksBaselineRoundTrip(t *testing.T) {
|
||||
root, err := findRepoRoot()
|
||||
if err != nil {
|
||||
t.Fatalf("findRepoRoot: %v", err)
|
||||
}
|
||||
path := filepath.Join(root, ".gitleaks-baseline.json")
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read baseline: %v", err)
|
||||
}
|
||||
var entries []map[string]any
|
||||
if err := json.Unmarshal(body, &entries); err != nil {
|
||||
t.Fatalf("parse baseline: %v", err)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
t.Error("baseline empty: should suppress at least the v0.1 .env leak")
|
||||
}
|
||||
for i, e := range entries {
|
||||
if e["Op"] != "skip" {
|
||||
t.Errorf("entry %d: Op=%v, want skip", i, e["Op"])
|
||||
}
|
||||
if _, ok := e["Commit"]; !ok {
|
||||
t.Errorf("entry %d: missing Commit", i)
|
||||
}
|
||||
if _, ok := e["File"]; !ok {
|
||||
t.Errorf("entry %d: missing File", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGolangciYmlShape verifies the .golangci.yml has the
|
||||
// required linters enabled (REQ-040). We don't run golangci-lint
|
||||
// here because it's an external binary; we just check that the
|
||||
// linters we expect are listed.
|
||||
func TestGolangciYmlShape(t *testing.T) {
|
||||
root, err := findRepoRoot()
|
||||
if err != nil {
|
||||
t.Fatalf("findRepoRoot: %v", err)
|
||||
}
|
||||
path := filepath.Join(root, ".golangci.yml")
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read .golangci.yml: %v", err)
|
||||
}
|
||||
s := string(body)
|
||||
for _, linter := range []string{"gosec", "govet", "ineffassign", "misspell"} {
|
||||
if !strings.Contains(s, "- "+linter) && !strings.Contains(s, linter+":") {
|
||||
t.Errorf(".golangci.yml: linter %q not enabled", linter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecurityScanScriptShape checks that the wrapper script
|
||||
// exists, is executable, and invokes all three tools.
|
||||
func TestSecurityScanScriptShape(t *testing.T) {
|
||||
root, err := findRepoRoot()
|
||||
if err != nil {
|
||||
t.Fatalf("findRepoRoot: %v", err)
|
||||
}
|
||||
path := filepath.Join(root, "scripts", "security_scan.sh")
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if info.Mode()&0o100 == 0 {
|
||||
t.Error("security_scan.sh is not executable (mode should include 0100)")
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
s := string(body)
|
||||
for _, must := range []string{"gosec", "govulncheck", "gitleaks", "GOFLAGS=-mod=mod", ".gitleaks.toml", ".gitleaks-baseline.json"} {
|
||||
if !strings.Contains(s, must) {
|
||||
t.Errorf("security_scan.sh missing required token: %q", must)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCoreciYmlHasSecurityStages verifies the .coreci.yml
|
||||
// `validate` pipeline includes the three security stages added
|
||||
// in P03.
|
||||
func TestCoreciYmlHasSecurityStages(t *testing.T) {
|
||||
root, err := findRepoRoot()
|
||||
if err != nil {
|
||||
t.Fatalf("findRepoRoot: %v", err)
|
||||
}
|
||||
path := filepath.Join(root, ".coreci.yml")
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read .coreci.yml: %v", err)
|
||||
}
|
||||
s := string(body)
|
||||
for _, must := range []string{
|
||||
"- name: gosec",
|
||||
"- name: govulncheck",
|
||||
"- name: gitleaks",
|
||||
"GOFLAGS",
|
||||
} {
|
||||
if !strings.Contains(s, must) {
|
||||
t.Errorf(".coreci.yml missing required token: %q", must)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMakefileHasSecurityAndTestRace verifies the new make
|
||||
// targets are wired in.
|
||||
func TestMakefileHasSecurityAndTestRace(t *testing.T) {
|
||||
root, err := findRepoRoot()
|
||||
if err != nil {
|
||||
t.Fatalf("findRepoRoot: %v", err)
|
||||
}
|
||||
path := filepath.Join(root, "Makefile")
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read Makefile: %v", err)
|
||||
}
|
||||
s := string(body)
|
||||
for _, must := range []string{
|
||||
"test-race:",
|
||||
"security-scan:",
|
||||
"go test -race",
|
||||
"scripts/security_scan.sh",
|
||||
} {
|
||||
if !strings.Contains(s, must) {
|
||||
t.Errorf("Makefile missing required token: %q", must)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreCommitHookShape verifies the gitleaks pre-commit hook
|
||||
// exists, is executable, and gates only when gitleaks is present.
|
||||
func TestPreCommitHookShape(t *testing.T) {
|
||||
root, err := findRepoRoot()
|
||||
if err != nil {
|
||||
t.Fatalf("findRepoRoot: %v", err)
|
||||
}
|
||||
path := filepath.Join(root, ".githooks", "pre-commit")
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if info.Mode()&0o100 == 0 {
|
||||
t.Error("pre-commit hook is not executable")
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
s := string(body)
|
||||
for _, must := range []string{"gitleaks protect", "core.hooksPath"} {
|
||||
if !strings.Contains(s, must) {
|
||||
// core.hooksPath is a git config setting, not in the file
|
||||
// itself. Loosen the assertion for that one.
|
||||
if must == "core.hooksPath" {
|
||||
continue
|
||||
}
|
||||
t.Errorf("pre-commit missing required token: %q", must)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCertPEMAllowlistMentions proves the .gitleaks.toml allowlist
|
||||
// for cert PEM blocks is in effect. We don't run gitleaks; we
|
||||
// just confirm the config structure has the right stopwords.
|
||||
func TestCertPEMAllowlistMentions(t *testing.T) {
|
||||
root, err := findRepoRoot()
|
||||
if err != nil {
|
||||
t.Fatalf("findRepoRoot: %v", err)
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(root, ".gitleaks.toml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
s := string(body)
|
||||
if !strings.Contains(s, "-----BEGIN CERTIFICATE-----") {
|
||||
t.Error(".gitleaks.toml should allowlist cert PEM blocks")
|
||||
}
|
||||
if !strings.Contains(s, "-----END CERTIFICATE-----") {
|
||||
t.Error(".gitleaks.toml should allowlist cert PEM END blocks")
|
||||
}
|
||||
}
|
||||
|
||||
// findRepoRoot walks up the directory tree to find the orca
|
||||
// repo root (the directory containing go.mod). This makes the
|
||||
// tests independent of cwd.
|
||||
func findRepoRoot() (string, error) {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
||||
return dir, nil
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
// TestGoTestRaceInCi verifies the .coreci.yml `test` pipeline
|
||||
// runs `go test -race`. This is a documentation-shape check; the
|
||||
// actual race-clean runs are in the prior session's history.
|
||||
func TestGoTestRaceInCi(t *testing.T) {
|
||||
root, err := findRepoRoot()
|
||||
if err != nil {
|
||||
t.Fatalf("findRepoRoot: %v", err)
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(root, ".coreci.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(body), "go test -race") {
|
||||
t.Error(".coreci.yml test pipeline should run with -race (REQ-031)")
|
||||
}
|
||||
}
|
||||
|
||||
// Compile-time guard that exec is used (testdata is referenced
|
||||
// in future-proofing for gosec exclusion tests).
|
||||
var _ = exec.Command
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
// Package testdata contains fixtures used by the security tests.
|
||||
// This file deliberately carries a G101 pattern (hardcoded
|
||||
// credential) so that any gosec run that doesn't allowlist this
|
||||
// path will fail. The allowlist lives in .golangci.yml and
|
||||
// .gitleaks.toml. Removing this fixture will break the
|
||||
// TestHardcodedCredsFixturePresent meta-test.
|
||||
package testdata
|
||||
|
||||
// HardcodedCredsFixture is a stub function whose body carries a
|
||||
// G101 pattern. gosec (with severity=high and confidence=medium,
|
||||
// per .golangci.yml) flags `apiKey := "..."` as G101. The value
|
||||
// is intentionally not a real secret (just the literal prefix
|
||||
// "GOSEC_G101_FIXTURE_VALUE_") so it doesn't trigger gitleaks.
|
||||
func HardcodedCredsFixture() string {
|
||||
apiKey := "GOSEC_G101_FIXTURE_VALUE_NOT_A_REAL_SECRET"
|
||||
_ = apiKey
|
||||
return apiKey
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// allowedSuites is the AEAD cipher allowlist required by D-015. We only
|
||||
// support TLS 1.3, so the Go cipher suite names below are TLS 1.3 cipher
|
||||
// suites. In Go 1.22+, the CipherSuites field still works for TLS 1.2
|
||||
// negotiation, but with MinVersion=tls.VersionTLS13 only the TLS 1.3
|
||||
// suites apply.
|
||||
//
|
||||
// We pin the three NIST/CHACHA AEAD suites:
|
||||
// - TLS_AES_256_GCM_SHA384
|
||||
// - TLS_CHACHA20_POLY1305_SHA256
|
||||
// - TLS_AES_128_GCM_SHA256
|
||||
//
|
||||
// No TLS 1.2 fallback. No CBC modes. No NULL/integrity-only modes.
|
||||
var allowedSuites = []uint16{
|
||||
tls.TLS_AES_256_GCM_SHA384,
|
||||
tls.TLS_CHACHA20_POLY1305_SHA256,
|
||||
tls.TLS_AES_128_GCM_SHA256,
|
||||
}
|
||||
|
||||
// AllowedCipherSuites returns a copy of the cipher allowlist. Exposed for
|
||||
// tests and for callers that want to construct their own tls.Config with
|
||||
// the same policy.
|
||||
func AllowedCipherSuites() []uint16 {
|
||||
out := make([]uint16, len(allowedSuites))
|
||||
copy(out, allowedSuites)
|
||||
return out
|
||||
}
|
||||
|
||||
// loadKeyPair is a small helper: load cert + key from disk, return
|
||||
// tls.Certificate. Errors are wrapped with the path that failed.
|
||||
func loadKeyPair(certPath, keyPath string) (tls.Certificate, error) {
|
||||
if certPath == "" || keyPath == "" {
|
||||
return tls.Certificate{}, errors.New("loadKeyPair: certPath and keyPath are required")
|
||||
}
|
||||
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, fmt.Errorf("load cert/key pair (%s, %s): %w", certPath, keyPath, err)
|
||||
}
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
// loadCAPool reads a PEM CA cert file and returns a CertPool containing
|
||||
// that cert. We use the subject as the trust anchor — clients verify
|
||||
// server certs against this single CA.
|
||||
func loadCAPool(caPath string) (*x509.CertPool, error) {
|
||||
if caPath == "" {
|
||||
return nil, errors.New("loadCAPool: caPath is required")
|
||||
}
|
||||
caPEM, err := os.ReadFile(caPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read CA cert: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(caPEM) {
|
||||
return nil, fmt.Errorf("parse CA cert PEM from %s", caPath)
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
// ServerTLSConfig returns a *tls.Config suitable for an mTLS server. The
|
||||
// server presents certPath/keyPath and requires client certs signed by
|
||||
// the CA at caPath. The cipher allowlist + MinVersion=1.3 are enforced.
|
||||
//
|
||||
// ClientCAs is the same pool as the trust store — peers present certs
|
||||
// signed by the same CA, and we verify them. GetCertificate is left nil;
|
||||
// callers (the daemon) populate it to enable hot-swap on cert renewal.
|
||||
//
|
||||
// Returns an error if any path is missing or any file cannot be read.
|
||||
func ServerTLSConfig(certPath, keyPath, caPath string) (*tls.Config, error) {
|
||||
if _, err := loadKeyPair(certPath, keyPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pool, err := loadCAPool(caPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tls.Config{
|
||||
MinVersion: tls.VersionTLS13,
|
||||
MaxVersion: tls.VersionTLS13,
|
||||
CipherSuites: AllowedCipherSuites(),
|
||||
Certificates: []tls.Certificate{{Certificate: nil}}, // placeholder; daemon fills via GetCertificate
|
||||
ClientCAs: pool,
|
||||
ClientAuth: tls.RequireAndVerifyClientCert,
|
||||
NextProtos: []string{"h2", "http/1.1"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ClientTLSConfig returns a *tls.Config suitable for an mTLS client. The
|
||||
// client verifies the server cert against the CA at caPath. If certPath
|
||||
// and keyPath are both non-empty, the client also presents a cert (for
|
||||
// mutual auth). If only one is set, the call fails — both-or-neither.
|
||||
//
|
||||
// serverName is the expected server identity (SNI / cert SAN match). It
|
||||
// MUST match a SAN on the server cert; the standard tls.Config will then
|
||||
// validate it during the handshake. For extra safety, callers should also
|
||||
// use VerifyPeerCertificate to enforce a pinned peer identity.
|
||||
func ClientTLSConfig(caPath, serverName string, certPath, keyPath string) (*tls.Config, error) {
|
||||
pool, err := loadCAPool(caPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg := &tls.Config{
|
||||
MinVersion: tls.VersionTLS13,
|
||||
MaxVersion: tls.VersionTLS13,
|
||||
CipherSuites: AllowedCipherSuites(),
|
||||
RootCAs: pool,
|
||||
ServerName: serverName,
|
||||
NextProtos: []string{"h2", "http/1.1"},
|
||||
}
|
||||
hasCert, hasKey := certPath != "", keyPath != ""
|
||||
if hasCert != hasKey {
|
||||
return nil, errors.New("ClientTLSConfig: certPath and keyPath must be both set or both empty")
|
||||
}
|
||||
if hasCert && hasKey {
|
||||
cert, err := loadKeyPair(certPath, keyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Certificates = []tls.Certificate{cert}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AuditEntry struct {
|
||||
ID int64 `json:"id"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Actor string `json:"actor"`
|
||||
Action string `json:"action"`
|
||||
Resource string `json:"resource"`
|
||||
Result string `json:"result"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type AuditRepo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewAuditRepo(db *sql.DB) *AuditRepo {
|
||||
return &AuditRepo{db: db}
|
||||
}
|
||||
|
||||
func (r *AuditRepo) Append(ctx context.Context, e *AuditEntry) error {
|
||||
if e.Timestamp.IsZero() {
|
||||
e.Timestamp = time.Now().UTC()
|
||||
}
|
||||
if e.Actor == "" {
|
||||
e.Actor = "system"
|
||||
}
|
||||
metaJSON, _ := json.Marshal(e.Metadata)
|
||||
if e.Error == "" {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`INSERT INTO audit_log (timestamp, actor, action, resource, result, metadata) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
e.Timestamp, e.Actor, e.Action, e.Resource, e.Result, string(metaJSON))
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert audit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`INSERT INTO audit_log (timestamp, actor, action, resource, result, error, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
e.Timestamp, e.Actor, e.Action, e.Resource, e.Result, e.Error, string(metaJSON))
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert audit (with error): %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *AuditRepo) List(ctx context.Context, limit int) ([]*AuditEntry, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT id, timestamp, actor, action, resource, result, COALESCE(error, ''), COALESCE(metadata, '') FROM audit_log ORDER BY id DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list audit: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var entries []*AuditEntry
|
||||
for rows.Next() {
|
||||
var (
|
||||
e AuditEntry
|
||||
metaJSON string
|
||||
)
|
||||
if err := rows.Scan(&e.ID, &e.Timestamp, &e.Actor, &e.Action, &e.Resource, &e.Result, &e.Error, &metaJSON); err != nil {
|
||||
return nil, fmt.Errorf("scan audit: %w", err)
|
||||
}
|
||||
if metaJSON != "" {
|
||||
_ = json.Unmarshal([]byte(metaJSON), &e.Metadata)
|
||||
}
|
||||
entries = append(entries, &e)
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func openAuditTestDB(t *testing.T) (*AuditRepo, func()) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
db, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
return NewAuditRepo(db), func() { _ = db.Close() }
|
||||
}
|
||||
|
||||
func TestAuditRepo_AppendAndList(t *testing.T) {
|
||||
repo, cleanup := openAuditTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 5; i++ {
|
||||
err := repo.Append(ctx, &AuditEntry{
|
||||
Actor: "cli",
|
||||
Action: "node.join",
|
||||
Resource: "node-1",
|
||||
Result: "success",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("append[%d]: %v", i, err)
|
||||
}
|
||||
}
|
||||
entries, err := repo.List(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(entries) != 5 {
|
||||
t.Errorf("expected 5 entries, got %d", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditRepo_WithError(t *testing.T) {
|
||||
repo, cleanup := openAuditTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
err := repo.Append(ctx, &AuditEntry{
|
||||
Actor: "system",
|
||||
Action: "task.run",
|
||||
Resource: "task-1",
|
||||
Result: "failure",
|
||||
Error: "exit status 1",
|
||||
Metadata: map[string]any{"exit_code": 1},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
entries, _ := repo.List(ctx, 1)
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(entries))
|
||||
}
|
||||
if entries[0].Error != "exit status 1" {
|
||||
t.Errorf("expected error 'exit status 1', got %q", entries[0].Error)
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
// Package store — capacity_repo.go implements persistence for NodeCapacity
|
||||
// declarations (v0.2 P02). Capacity is declared per node via
|
||||
// `orca node capacity --set` (or from `~/.orca/node.hcl` at join time).
|
||||
// The dispatcher reads capacity rows to bin-pack jobs across nodes.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NodeCapacity is the per-node resource declaration consumed by the
|
||||
// scheduler. Units:
|
||||
// - CPUMillicores: 1000 = 1 vCPU
|
||||
// - MemoryMiB: mebibytes of RAM
|
||||
// - DiskMiB: mebibytes of scratch disk
|
||||
type NodeCapacity struct {
|
||||
NodeID string
|
||||
CPUMillicores int64
|
||||
MemoryMiB int64
|
||||
DiskMiB int64
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// CapacityRepo is the persistence layer for NodeCapacity rows.
|
||||
type CapacityRepo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewCapacityRepo returns a CapacityRepo backed by the given DB.
|
||||
func NewCapacityRepo(db *sql.DB) *CapacityRepo {
|
||||
return &CapacityRepo{db: db}
|
||||
}
|
||||
|
||||
// Upsert writes the capacity row for nodeID, replacing any prior row.
|
||||
// The UpdatedAt column is set to time.Now().UTC() unless the caller
|
||||
// supplied a non-zero value.
|
||||
func (r *CapacityRepo) Upsert(ctx context.Context, c *NodeCapacity) error {
|
||||
if c == nil {
|
||||
return errors.New("CapacityRepo.Upsert: nil capacity")
|
||||
}
|
||||
if c.NodeID == "" {
|
||||
return errors.New("CapacityRepo.Upsert: NodeID is required")
|
||||
}
|
||||
if c.UpdatedAt.IsZero() {
|
||||
c.UpdatedAt = time.Now().UTC()
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO node_capacity (node_id, cpu_millicores, memory_mib, disk_mib, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(node_id) DO UPDATE SET
|
||||
cpu_millicores = excluded.cpu_millicores,
|
||||
memory_mib = excluded.memory_mib,
|
||||
disk_mib = excluded.disk_mib,
|
||||
updated_at = excluded.updated_at
|
||||
`, c.NodeID, c.CPUMillicores, c.MemoryMiB, c.DiskMiB, c.UpdatedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CapacityRepo.Upsert: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns the capacity for nodeID or ErrNotFound.
|
||||
func (r *CapacityRepo) Get(ctx context.Context, nodeID string) (*NodeCapacity, error) {
|
||||
if nodeID == "" {
|
||||
return nil, errors.New("CapacityRepo.Get: nodeID is required")
|
||||
}
|
||||
row := r.db.QueryRowContext(ctx, `
|
||||
SELECT node_id, cpu_millicores, memory_mib, disk_mib, updated_at
|
||||
FROM node_capacity WHERE node_id = ?
|
||||
`, nodeID)
|
||||
var c NodeCapacity
|
||||
if err := row.Scan(&c.NodeID, &c.CPUMillicores, &c.MemoryMiB, &c.DiskMiB, &c.UpdatedAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("CapacityRepo.Get: %w", err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// List returns all capacity rows ordered by node_id.
|
||||
func (r *CapacityRepo) List(ctx context.Context) ([]*NodeCapacity, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT node_id, cpu_millicores, memory_mib, disk_mib, updated_at
|
||||
FROM node_capacity ORDER BY node_id
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CapacityRepo.List: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*NodeCapacity
|
||||
for rows.Next() {
|
||||
var c NodeCapacity
|
||||
if err := rows.Scan(&c.NodeID, &c.CPUMillicores, &c.MemoryMiB, &c.DiskMiB, &c.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("CapacityRepo.List: scan: %w", err)
|
||||
}
|
||||
out = append(out, &c)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("CapacityRepo.List: rows: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Delete removes the capacity row for nodeID. Returns ErrNotFound if
|
||||
// the row doesn't exist.
|
||||
func (r *CapacityRepo) Delete(ctx context.Context, nodeID string) error {
|
||||
res, err := r.db.ExecContext(ctx, `DELETE FROM node_capacity WHERE node_id = ?`, nodeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CapacityRepo.Delete: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("CapacityRepo.Delete: rows: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCapacityRepoUpsertGetList(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db, err := Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
repo := NewCapacityRepo(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// Empty initially.
|
||||
if _, err := repo.Get(ctx, "self"); err == nil {
|
||||
t.Error("expected ErrNotFound on empty store")
|
||||
}
|
||||
rows, err := repo.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Errorf("List: got %d rows, want 0", len(rows))
|
||||
}
|
||||
|
||||
// Insert.
|
||||
c1 := &NodeCapacity{NodeID: "self", CPUMillicores: 4000, MemoryMiB: 4096, DiskMiB: 4096}
|
||||
if err := repo.Upsert(ctx, c1); err != nil {
|
||||
t.Fatalf("Upsert: %v", err)
|
||||
}
|
||||
got, err := repo.Get(ctx, "self")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got.CPUMillicores != 4000 || got.MemoryMiB != 4096 || got.DiskMiB != 4096 {
|
||||
t.Errorf("Get: got %+v, want cpu=4000 mem=4096 disk=4096", got)
|
||||
}
|
||||
|
||||
// Update (overwrite).
|
||||
c2 := &NodeCapacity{NodeID: "self", CPUMillicores: 8000, MemoryMiB: 8192, DiskMiB: 8192}
|
||||
if err := repo.Upsert(ctx, c2); err != nil {
|
||||
t.Fatalf("Upsert(update): %v", err)
|
||||
}
|
||||
got, _ = repo.Get(ctx, "self")
|
||||
if got.CPUMillicores != 8000 {
|
||||
t.Errorf("Update: cpu=%d, want 8000", got.CPUMillicores)
|
||||
}
|
||||
|
||||
// Add a second node.
|
||||
c3 := &NodeCapacity{NodeID: "peer-1", CPUMillicores: 2000, MemoryMiB: 2048, DiskMiB: 2048}
|
||||
if err := repo.Upsert(ctx, c3); err != nil {
|
||||
t.Fatalf("Upsert(peer-1): %v", err)
|
||||
}
|
||||
rows, _ = repo.List(ctx)
|
||||
if len(rows) != 2 {
|
||||
t.Errorf("List: got %d rows, want 2", len(rows))
|
||||
}
|
||||
|
||||
// Delete.
|
||||
if err := repo.Delete(ctx, "peer-1"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if _, err := repo.Get(ctx, "peer-1"); err == nil {
|
||||
t.Error("expected ErrNotFound after Delete")
|
||||
}
|
||||
if err := repo.Delete(ctx, "missing"); err == nil {
|
||||
t.Error("expected ErrNotFound on Delete of missing row")
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CertKind enumerates the kinds of certs orca tracks. 'ca' is the
|
||||
// cluster's internal CA; 'server' is a per-node server cert.
|
||||
type CertKind string
|
||||
|
||||
const (
|
||||
CertKindCA CertKind = "ca"
|
||||
CertKindServer CertKind = "server"
|
||||
)
|
||||
|
||||
// Cert is the in-memory representation of a row in the `certs` table.
|
||||
type Cert struct {
|
||||
ID string `json:"id"`
|
||||
Kind CertKind `json:"kind"`
|
||||
NodeID string `json:"node_id"`
|
||||
SerialHex string `json:"serial_hex"`
|
||||
SubjectCN string `json:"subject_cn"`
|
||||
IssuerCN string `json:"issuer_cn"`
|
||||
NotBefore time.Time `json:"not_before"`
|
||||
NotAfter time.Time `json:"not_after"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
SourcePath string `json:"source_path,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CertRepo is a CRUD wrapper around the `certs` table.
|
||||
type CertRepo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewCertRepo(db *sql.DB) *CertRepo {
|
||||
return &CertRepo{db: db}
|
||||
}
|
||||
|
||||
// Insert persists a new cert. Fills CreatedAt to now() if zero. The caller
|
||||
// is responsible for setting ID, SerialHex, Fingerprint, etc.
|
||||
func (r *CertRepo) Insert(ctx context.Context, c *Cert) error {
|
||||
if c == nil {
|
||||
return errors.New("CertRepo.Insert: nil cert")
|
||||
}
|
||||
if c.ID == "" {
|
||||
return errors.New("CertRepo.Insert: ID is required")
|
||||
}
|
||||
if c.Kind == "" {
|
||||
return errors.New("CertRepo.Insert: Kind is required")
|
||||
}
|
||||
if c.CreatedAt.IsZero() {
|
||||
c.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`INSERT INTO certs (id, kind, node_id, serial_hex, subject_cn, issuer_cn, not_before, not_after, fingerprint, source_path, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
c.ID, string(c.Kind), c.NodeID, c.SerialHex, c.SubjectCN, c.IssuerCN,
|
||||
c.NotBefore, c.NotAfter, c.Fingerprint, c.SourcePath, c.CreatedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CertRepo.Insert: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns a single cert by ID. Returns ErrNotFound if absent.
|
||||
func (r *CertRepo) Get(ctx context.Context, id string) (*Cert, error) {
|
||||
row := r.db.QueryRowContext(ctx,
|
||||
`SELECT id, kind, node_id, serial_hex, subject_cn, issuer_cn, not_before, not_after, fingerprint, source_path, created_at FROM certs WHERE id = ?`, id)
|
||||
return scanCert(row)
|
||||
}
|
||||
|
||||
// List returns all certs ordered by created_at DESC. Use ListByNode /
|
||||
// LatestForKind for filtered queries.
|
||||
func (r *CertRepo) List(ctx context.Context) ([]*Cert, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT id, kind, node_id, serial_hex, subject_cn, issuer_cn, not_before, not_after, fingerprint, source_path, created_at FROM certs ORDER BY created_at DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CertRepo.List: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var certs []*Cert
|
||||
for rows.Next() {
|
||||
c, err := scanCert(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
certs = append(certs, c)
|
||||
}
|
||||
return certs, rows.Err()
|
||||
}
|
||||
|
||||
// ListByNode returns certs belonging to a node (or matching node_id for the
|
||||
// CA — CA rows use node_id = ”).
|
||||
func (r *CertRepo) ListByNode(ctx context.Context, nodeID string) ([]*Cert, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT id, kind, node_id, serial_hex, subject_cn, issuer_cn, not_before, not_after, fingerprint, source_path, created_at FROM certs WHERE node_id = ? ORDER BY created_at DESC`, nodeID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CertRepo.ListByNode: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var certs []*Cert
|
||||
for rows.Next() {
|
||||
c, err := scanCert(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
certs = append(certs, c)
|
||||
}
|
||||
return certs, rows.Err()
|
||||
}
|
||||
|
||||
// LatestForKind returns the most recent cert of the given kind for the given
|
||||
// node. Returns ErrNotFound if none exists. nodeID may be empty to query
|
||||
// the cluster-wide CA.
|
||||
func (r *CertRepo) LatestForKind(ctx context.Context, nodeID string, kind CertKind) (*Cert, error) {
|
||||
row := r.db.QueryRowContext(ctx,
|
||||
`SELECT id, kind, node_id, serial_hex, subject_cn, issuer_cn, not_before, not_after, fingerprint, source_path, created_at FROM certs WHERE node_id = ? AND kind = ? ORDER BY created_at DESC LIMIT 1`,
|
||||
nodeID, string(kind))
|
||||
return scanCert(row)
|
||||
}
|
||||
|
||||
// PruneOlderThan deletes certs beyond the most recent `keep` rows for
|
||||
// (nodeID, kind), ordered by created_at DESC. Returns the number of
|
||||
// rows deleted. `keep` must be > 0; values <= 0 are treated as 1.
|
||||
func (r *CertRepo) PruneOlderThan(ctx context.Context, nodeID, kind string, keep int) (int64, error) {
|
||||
if keep <= 0 {
|
||||
keep = 1
|
||||
}
|
||||
// Two-step delete: first find the cutoff created_at, then delete
|
||||
// everything older. Done in a single transaction via ExecContext.
|
||||
// modernc/sqlite supports multiple statements in a single Exec only
|
||||
// via the "multi-statement" pragma; we use a subquery instead.
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
`DELETE FROM certs WHERE node_id = ? AND kind = ? AND id NOT IN (
|
||||
SELECT id FROM certs WHERE node_id = ? AND kind = ?
|
||||
ORDER BY created_at DESC LIMIT ?
|
||||
)`,
|
||||
nodeID, kind, nodeID, kind, keep)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("CertRepo.PruneOlderThan: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Delete removes a cert by ID. Returns ErrNotFound if no rows affected.
|
||||
func (r *CertRepo) Delete(ctx context.Context, id string) error {
|
||||
res, err := r.db.ExecContext(ctx, `DELETE FROM certs WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CertRepo.Delete: %w", err)
|
||||
}
|
||||
rows, _ := res.RowsAffected()
|
||||
if rows == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanCert(s scanner) (*Cert, error) {
|
||||
var (
|
||||
c Cert
|
||||
kindStr string
|
||||
)
|
||||
err := s.Scan(&c.ID, &kindStr, &c.NodeID, &c.SerialHex, &c.SubjectCN, &c.IssuerCN,
|
||||
&c.NotBefore, &c.NotAfter, &c.Fingerprint, &c.SourcePath, &c.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan cert: %w", err)
|
||||
}
|
||||
c.Kind = CertKind(kindStr)
|
||||
return &c, nil
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"iter"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"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 {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewJobRepo(db *sql.DB) *JobRepo {
|
||||
return &JobRepo{db: db}
|
||||
}
|
||||
|
||||
func (r *JobRepo) Insert(ctx context.Context, j *model.Job) error {
|
||||
if j.CreatedAt.IsZero() {
|
||||
j.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
if j.Status == "" {
|
||||
j.Status = model.JobStatusPending
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`INSERT INTO jobs (id, name, spec, status, exit_code, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
j.ID, j.Name, j.Spec, string(j.Status), j.ExitCode, j.CreatedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert job: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *JobRepo) Get(ctx context.Context, id string) (*model.Job, error) {
|
||||
row := r.db.QueryRowContext(ctx,
|
||||
`SELECT id, name, spec, status, exit_code, created_at, started_at, ended_at FROM jobs WHERE id = ?`, id)
|
||||
return scanJob(row)
|
||||
}
|
||||
|
||||
func (r *JobRepo) List(ctx context.Context) ([]*model.Job, error) {
|
||||
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 {
|
||||
return nil, fmt.Errorf("list jobs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var jobs []*model.Job
|
||||
for rows.Next() {
|
||||
j, err := scanJob(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jobs = append(jobs, j)
|
||||
}
|
||||
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 {
|
||||
now := time.Now().UTC()
|
||||
var startedAt, endedAt *time.Time
|
||||
switch status {
|
||||
case model.JobStatusRunning:
|
||||
startedAt = &now
|
||||
case model.JobStatusComplete, model.JobStatusFailed, model.JobStatusStopped:
|
||||
endedAt = &now
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE jobs SET status = ?, exit_code = ?, started_at = COALESCE(?, started_at), ended_at = COALESCE(?, ended_at) WHERE id = ?`,
|
||||
string(status), exitCode, startedAt, endedAt, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update job: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanJob(s scanner) (*model.Job, error) {
|
||||
var (
|
||||
j model.Job
|
||||
status string
|
||||
startedAt sql.NullTime
|
||||
endedAt sql.NullTime
|
||||
)
|
||||
err := s.Scan(&j.ID, &j.Name, &j.Spec, &status, &j.ExitCode, &j.CreatedAt, &startedAt, &endedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan job: %w", err)
|
||||
}
|
||||
j.Status = model.JobStatus(status)
|
||||
if startedAt.Valid {
|
||||
j.StartedAt = &startedAt.Time
|
||||
}
|
||||
if endedAt.Valid {
|
||||
j.EndedAt = &endedAt.Time
|
||||
}
|
||||
return &j, nil
|
||||
}
|
||||
|
||||
type TaskRepo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewTaskRepo(db *sql.DB) *TaskRepo {
|
||||
return &TaskRepo{db: db}
|
||||
}
|
||||
|
||||
func (r *TaskRepo) Insert(ctx context.Context, t *model.Task) error {
|
||||
if t.CreatedAt.IsZero() {
|
||||
t.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
if t.Status == "" {
|
||||
t.Status = model.TaskStatusPending
|
||||
}
|
||||
argsJSON, _ := json.Marshal(t.Args)
|
||||
envJSON, _ := json.Marshal(t.Env)
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`INSERT INTO tasks (id, job_id, command, args, env, pid, exit_code, status, created_at, stdout, stderr)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
t.ID, t.JobID, t.Command, string(argsJSON), string(envJSON),
|
||||
t.PID, t.ExitCode, string(t.Status), t.CreatedAt, t.Stdout, t.Stderr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert task: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *TaskRepo) Get(ctx context.Context, id string) (*model.Task, error) {
|
||||
row := r.db.QueryRowContext(ctx,
|
||||
`SELECT id, job_id, command, args, env, pid, exit_code, status, created_at, started_at, ended_at, stdout, stderr FROM tasks WHERE id = ?`, id)
|
||||
return scanTask(row)
|
||||
}
|
||||
|
||||
func (r *TaskRepo) ListByJob(ctx context.Context, jobID string) ([]*model.Task, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT id, job_id, command, args, env, pid, exit_code, status, created_at, started_at, ended_at, stdout, stderr FROM tasks WHERE job_id = ? ORDER BY created_at ASC`, jobID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list tasks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var tasks []*model.Task
|
||||
for rows.Next() {
|
||||
t, err := scanTask(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tasks = append(tasks, t)
|
||||
}
|
||||
return tasks, rows.Err()
|
||||
}
|
||||
|
||||
func (r *TaskRepo) UpdateRunning(ctx context.Context, id string, pid int) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE tasks SET pid = ?, status = ?, started_at = ? WHERE id = ?`,
|
||||
pid, string(model.TaskStatusRunning), now, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update task running: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *TaskRepo) UpdateDone(ctx context.Context, id string, exitCode int, stdout, stderr string) error {
|
||||
now := time.Now().UTC()
|
||||
status := model.TaskStatusComplete
|
||||
if exitCode != 0 {
|
||||
status = model.TaskStatusFailed
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE tasks SET status = ?, exit_code = ?, ended_at = ?, stdout = ?, stderr = ? WHERE id = ?`,
|
||||
string(status), exitCode, now, stdout, stderr, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update task done: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *TaskRepo) UpdateKilled(ctx context.Context, id string) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE tasks SET status = ?, ended_at = ? WHERE id = ?`,
|
||||
string(model.TaskStatusKilled), now, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update task killed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListRecent returns up to limit tasks ordered by created_at DESC.
|
||||
// Used by the API to expose recent activity without a job filter.
|
||||
func (r *TaskRepo) ListRecent(ctx context.Context, limit int) ([]*model.Task, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT id, job_id, command, args, env, pid, exit_code, status, created_at, started_at, ended_at, stdout, stderr FROM tasks ORDER BY created_at DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list tasks recent: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var tasks []*model.Task
|
||||
for rows.Next() {
|
||||
t, err := scanTask(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tasks = append(tasks, t)
|
||||
}
|
||||
return tasks, rows.Err()
|
||||
}
|
||||
|
||||
var _ = errors.New
|
||||
var _ = json.Marshal
|
||||
|
||||
func scanTask(s scanner) (*model.Task, error) {
|
||||
var (
|
||||
t model.Task
|
||||
status string
|
||||
argsJSON string
|
||||
envJSON string
|
||||
startedAt sql.NullTime
|
||||
endedAt sql.NullTime
|
||||
)
|
||||
err := s.Scan(&t.ID, &t.JobID, &t.Command, &argsJSON, &envJSON,
|
||||
&t.PID, &t.ExitCode, &status, &t.CreatedAt, &startedAt, &endedAt, &t.Stdout, &t.Stderr)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan task: %w", err)
|
||||
}
|
||||
t.Status = model.TaskStatus(status)
|
||||
if startedAt.Valid {
|
||||
t.StartedAt = &startedAt.Time
|
||||
}
|
||||
if endedAt.Valid {
|
||||
t.EndedAt = &endedAt.Time
|
||||
}
|
||||
_ = json.Unmarshal([]byte(argsJSON), &t.Args)
|
||||
_ = json.Unmarshal([]byte(envJSON), &t.Env)
|
||||
return &t, nil
|
||||
}
|
||||
@@ -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
|
||||
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 {
|
||||
entries, err := migrationsFS.ReadDir("migrations")
|
||||
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 != "0005_node_capacity.sql" {
|
||||
t.Errorf("MigrationVersion = %q, want 0005_node_capacity.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,34 +0,0 @@
|
||||
-- Jobs and tasks
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
spec TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
exit_code INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL,
|
||||
started_at DATETIME,
|
||||
ended_at DATETIME
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
job_id TEXT NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
args TEXT NOT NULL DEFAULT '[]',
|
||||
env TEXT NOT NULL DEFAULT '[]',
|
||||
pid INTEGER NOT NULL DEFAULT 0,
|
||||
exit_code INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at DATETIME NOT NULL,
|
||||
started_at DATETIME,
|
||||
ended_at DATETIME,
|
||||
stdout TEXT NOT NULL DEFAULT '',
|
||||
stderr TEXT NOT NULL DEFAULT '',
|
||||
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_job ON tasks(job_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
||||
@@ -1,15 +0,0 @@
|
||||
-- Audit log for security-first observability
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp DATETIME NOT NULL,
|
||||
actor TEXT NOT NULL DEFAULT 'system',
|
||||
action TEXT NOT NULL,
|
||||
resource TEXT NOT NULL,
|
||||
result TEXT NOT NULL,
|
||||
error TEXT,
|
||||
metadata TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_resource ON audit_log(resource);
|
||||
@@ -1,30 +0,0 @@
|
||||
-- Cert inventory: every CA + server cert issued by orca, with metadata
|
||||
-- sufficient to drive rotation history, fingerprint pinning, and
|
||||
-- `orca doctor cert` health reports. This is migration 0004; v0.2 P01.
|
||||
--
|
||||
-- `kind` is one of: 'ca', 'server'. CA rows have node_id = '' (the
|
||||
-- CA is per-cluster, not per-node). Server rows have node_id set.
|
||||
-- `serial_hex` is the cert serial as a hex string; used to detect
|
||||
-- duplicate issuances.
|
||||
-- `fingerprint` is SHA-256 hex (lowercase) of the cert's DER bytes;
|
||||
-- matches the value returned by `Fingerprint(certPath)` in
|
||||
-- internal/security.
|
||||
CREATE TABLE IF NOT EXISTS certs (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
node_id TEXT NOT NULL DEFAULT '',
|
||||
serial_hex TEXT NOT NULL,
|
||||
subject_cn TEXT NOT NULL,
|
||||
issuer_cn TEXT NOT NULL,
|
||||
not_before DATETIME NOT NULL,
|
||||
not_after DATETIME NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
source_path TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_certs_kind ON certs(kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_certs_node ON certs(node_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_certs_node_kind ON certs(node_id, kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_certs_created ON certs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_certs_fp ON certs(fingerprint);
|
||||
@@ -1,12 +0,0 @@
|
||||
-- Node capacity declaration for multi-node scheduling (v0.2 P02).
|
||||
-- Loaded from `~/.orca/node.hcl` at `orca node join` and updated via
|
||||
-- `orca node capacity --set`. Read by the dispatcher for bin-packing.
|
||||
CREATE TABLE IF NOT EXISTS node_capacity (
|
||||
node_id TEXT PRIMARY KEY,
|
||||
cpu_millicores INTEGER NOT NULL,
|
||||
memory_mib INTEGER NOT NULL,
|
||||
disk_mib INTEGER NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_capacity_updated ON node_capacity(updated_at);
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"iter"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||
@@ -71,40 +69,6 @@ func (r *NodeRepo) List(ctx context.Context) ([]*model.Node, error) {
|
||||
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 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 {
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
`UPDATE nodes SET state = ?, last_seen = ? WHERE id = ?`,
|
||||
|
||||
@@ -100,159 +100,3 @@ func TestNodeRepo_Delete(t *testing.T) {
|
||||
t.Errorf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
// Package transport — dispatch.go implements the orca.v1.Dispatch
|
||||
// service: a JSON-over-HTTP interface for cross-node job submission
|
||||
// and status queries. Routes:
|
||||
//
|
||||
// POST /orca.v1.Dispatch/Submit -> SubmitHandler
|
||||
// POST /orca.v1.Dispatch/Status -> StatusHandler
|
||||
//
|
||||
// mTLS is the v0.2 transport (P01). ConnectRPC is NOT used because
|
||||
// it's not in go.mod (RESEARCH conclusion). The service is mounted on
|
||||
// the orca daemon's mTLS listener (see internal/daemon/dispatch_handler.go).
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SubmitRequest is the body of POST /orca.v1.Dispatch/Submit.
|
||||
type SubmitRequest struct {
|
||||
Target string `json:"target"` // optional explicit node id; empty = bin-pack
|
||||
Spec json.RawMessage `json:"spec"` // HCL/YAML job spec, opaque to the dispatch service
|
||||
IdempotencyKey string `json:"-"` // set from X-Orca-Idempotency-Key header, not body
|
||||
}
|
||||
|
||||
// SubmitResponse is the body of a Submit reply.
|
||||
type SubmitResponse struct {
|
||||
JobID string `json:"job_id"`
|
||||
NodeID string `json:"node_id"` // node that actually accepted the job (local or peer)
|
||||
}
|
||||
|
||||
// StatusRequest is the body of POST /orca.v1.Dispatch/Status.
|
||||
type StatusRequest struct {
|
||||
JobID string `json:"job_id"`
|
||||
}
|
||||
|
||||
// StatusResponse is the body of a Status reply.
|
||||
type StatusResponse struct {
|
||||
JobID string `json:"job_id"`
|
||||
NodeID string `json:"node_id"`
|
||||
State string `json:"state"` // "pending" | "running" | "complete" | "failed" | "stopped"
|
||||
}
|
||||
|
||||
// Dispatcher is the contract the HTTP layer uses to actually run a
|
||||
// job on a node. The engine layer implements this; the HTTP layer
|
||||
// translates between JSON and Dispatcher calls.
|
||||
type Dispatcher interface {
|
||||
LocalSubmit(ctx context.Context, spec []byte) (jobID string, err error)
|
||||
LocalStatus(ctx context.Context, jobID string) (state string, err error)
|
||||
}
|
||||
|
||||
// SubmitHandler is an http.Handler that runs Submit on a local Dispatcher.
|
||||
// It honors X-Orca-Idempotency-Key for dedupe. Errors are returned
|
||||
// as JSON with an "error" field and an HTTP status code.
|
||||
type SubmitHandler struct {
|
||||
Dispatcher Dispatcher
|
||||
Dedupe *IdempotencyStore
|
||||
}
|
||||
|
||||
// NewSubmitHandler builds a SubmitHandler.
|
||||
func NewSubmitHandler(d Dispatcher, dedupe *IdempotencyStore) *SubmitHandler {
|
||||
if dedupe == nil {
|
||||
dedupe = NewIdempotencyStore()
|
||||
}
|
||||
return &SubmitHandler{Dispatcher: d, Dedupe: dedupe}
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler.
|
||||
func (h *SubmitHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
var req SubmitRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "decode body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(req.Spec) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "spec is required")
|
||||
return
|
||||
}
|
||||
req.IdempotencyKey = r.Header.Get(IdempotencyHeader)
|
||||
|
||||
// Idempotency check.
|
||||
if req.IdempotencyKey != "" {
|
||||
if jobID, ok := h.Dedupe.Get(req.IdempotencyKey); ok {
|
||||
// Replay the previous response.
|
||||
writeJSON(w, http.StatusOK, SubmitResponse{JobID: jobID, NodeID: ""})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
jobID, err := h.Dispatcher.LocalSubmit(r.Context(), req.Spec)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if req.IdempotencyKey != "" {
|
||||
h.Dedupe.Put(req.IdempotencyKey, jobID)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, SubmitResponse{JobID: jobID, NodeID: "self"})
|
||||
}
|
||||
|
||||
// StatusHandler is an http.Handler that runs Status on a local Dispatcher.
|
||||
type StatusHandler struct {
|
||||
Dispatcher Dispatcher
|
||||
}
|
||||
|
||||
// NewStatusHandler builds a StatusHandler.
|
||||
func NewStatusHandler(d Dispatcher) *StatusHandler {
|
||||
return &StatusHandler{Dispatcher: d}
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler.
|
||||
func (h *StatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
var req StatusRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "decode body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.JobID == "" {
|
||||
writeError(w, http.StatusBadRequest, "job_id is required")
|
||||
return
|
||||
}
|
||||
state, err := h.Dispatcher.LocalStatus(r.Context(), req.JobID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, StatusResponse{JobID: req.JobID, NodeID: "self", State: state})
|
||||
}
|
||||
|
||||
// DispatchClient is the client-side wrapper that calls Submit/Status
|
||||
// on a remote peer. It uses mTLS (REQ-011) and the retry helper
|
||||
// (REQ-037).
|
||||
type DispatchClient struct {
|
||||
HTTP *MTLSClient
|
||||
PeerAddr string // http://host:port or https://host:port
|
||||
}
|
||||
|
||||
// NewDispatchClient builds a DispatchClient for a peer.
|
||||
func NewDispatchClient(caPath, serverName, peerAddr string) (*DispatchClient, error) {
|
||||
c, err := NewMTLSClient(caPath, serverName, "", "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("NewDispatchClient: %w", err)
|
||||
}
|
||||
return &DispatchClient{HTTP: c, PeerAddr: peerAddr}, nil
|
||||
}
|
||||
|
||||
// Submit calls POST /orca.v1.Dispatch/Submit on the peer with the
|
||||
// given spec and idempotency key. Retries per the default policy.
|
||||
func (c *DispatchClient) Submit(ctx context.Context, spec []byte, idempotencyKey string) (*SubmitResponse, error) {
|
||||
if idempotencyKey != "" {
|
||||
ctx = WithIdempotencyKey(ctx, idempotencyKey)
|
||||
}
|
||||
body, _ := json.Marshal(SubmitRequest{Spec: spec})
|
||||
policy := DefaultRetryPolicy()
|
||||
for attempt := 1; attempt <= policy.MaxAttempts; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.PeerAddr+"/orca.v1.Dispatch/Submit", bytesReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if k := IdempotencyKeyFromContext(ctx); k != "" {
|
||||
req.Header.Set(IdempotencyHeader, k)
|
||||
}
|
||||
r, err := c.HTTP.Do(req)
|
||||
if err == nil {
|
||||
defer r.Body.Close()
|
||||
if r.StatusCode == http.StatusOK {
|
||||
var resp SubmitResponse
|
||||
if derr := json.NewDecoder(r.Body).Decode(&resp); derr == nil {
|
||||
return &resp, nil
|
||||
} else {
|
||||
return nil, fmt.Errorf("DispatchClient.Submit: decode: %w", derr)
|
||||
}
|
||||
}
|
||||
err = fmt.Errorf("status %d", r.StatusCode)
|
||||
err = fmt.Errorf("%w: %v", ErrTransient, err)
|
||||
} else {
|
||||
err = fmt.Errorf("%w: %v", ErrTransient, err)
|
||||
}
|
||||
// No key, not idempotent: bail on first transient error.
|
||||
if IdempotencyKeyFromContext(ctx) == "" {
|
||||
return nil, err
|
||||
}
|
||||
if attempt == policy.MaxAttempts {
|
||||
return nil, err
|
||||
}
|
||||
// Wait with backoff, respecting ctx.
|
||||
wait := backoff(policy.Initial, policy.Max, attempt)
|
||||
t := time.NewTimer(wait)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Stop()
|
||||
return nil, ctx.Err()
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("DispatchClient.Submit: exhausted attempts")
|
||||
}
|
||||
|
||||
// Status calls POST /orca.v1.Dispatch/Status on the peer. Status is
|
||||
// idempotent at the verb level, so retries are always safe.
|
||||
func (c *DispatchClient) Status(ctx context.Context, jobID string) (*StatusResponse, error) {
|
||||
body, _ := json.Marshal(StatusRequest{JobID: jobID})
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.PeerAddr+"/orca.v1.Dispatch/Status", bytesReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("DispatchClient.Status: %w", err)
|
||||
}
|
||||
defer r.Body.Close()
|
||||
if r.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("DispatchClient.Status: status %d", r.StatusCode)
|
||||
}
|
||||
var resp StatusResponse
|
||||
if err := json.NewDecoder(r.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("DispatchClient.Status: decode: %w", err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// writeJSON encodes v as JSON and writes it with the given status.
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// writeError writes a JSON error response.
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// bytesReader is a small helper to keep this file self-contained.
|
||||
type bytesReadCloser struct {
|
||||
b []byte
|
||||
pos int
|
||||
}
|
||||
|
||||
func bytesReader(b []byte) *bytesReadCloser { return &bytesReadCloser{b: b} }
|
||||
|
||||
func (r *bytesReadCloser) Read(p []byte) (int, error) {
|
||||
if r.pos >= len(r.b) {
|
||||
return 0, fmt.Errorf("EOF")
|
||||
}
|
||||
n := copy(p, r.b[r.pos:])
|
||||
r.pos += n
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (r *bytesReadCloser) Close() error { return nil }
|
||||
@@ -1,66 +0,0 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// LogHandshakeOK emits a structured slog record for a successful mTLS
|
||||
// handshake. Per REQ-038, the fields are: event=mtls.handshake,
|
||||
// result=ok, peer, cert_fp.
|
||||
func LogHandshakeOK(log *slog.Logger, peer, certFP string) {
|
||||
if log == nil {
|
||||
return
|
||||
}
|
||||
log.Info("mtls.handshake",
|
||||
slog.String("event", "mtls.handshake"),
|
||||
slog.String("result", "ok"),
|
||||
slog.String("peer", peer),
|
||||
slog.String("cert_fp", certFP),
|
||||
)
|
||||
}
|
||||
|
||||
// LogHandshakeFailed emits a structured slog record for a failed mTLS
|
||||
// handshake. Per REQ-038, the fields are: event=mtls.handshake,
|
||||
// result=failed, peer, cert_fp (may be empty if no cert was presented
|
||||
// before the failure), err. The log level is WARN — handshake failures
|
||||
// are operationally interesting but not always fatal (e.g., a scanner
|
||||
// probing the port).
|
||||
func LogHandshakeFailed(log *slog.Logger, peer, certFP string, err error) {
|
||||
if log == nil {
|
||||
return
|
||||
}
|
||||
attrs := []any{
|
||||
slog.String("event", "mtls.handshake"),
|
||||
slog.String("result", "failed"),
|
||||
slog.String("peer", peer),
|
||||
slog.String("cert_fp", certFP),
|
||||
}
|
||||
if err != nil {
|
||||
attrs = append(attrs, slog.String("err", err.Error()))
|
||||
}
|
||||
log.Warn("mtls.handshake", attrs...)
|
||||
}
|
||||
|
||||
// LogHandshakeFromCert is a convenience wrapper that pulls the fingerprint
|
||||
// off a parsed *x509.Certificate and calls LogHandshakeOK.
|
||||
func LogHandshakeFromCert(log *slog.Logger, peer string, cert *x509.Certificate) {
|
||||
if cert == nil {
|
||||
LogHandshakeOK(log, peer, "")
|
||||
return
|
||||
}
|
||||
LogHandshakeOK(log, peer, FingerprintOfCert(cert))
|
||||
}
|
||||
|
||||
// FingerprintOfCert is a thin wrapper that returns the SHA-256 hex of a
|
||||
// cert's DER bytes. Re-exported here so transport callers don't need
|
||||
// to import the security package directly.
|
||||
func FingerprintOfCert(cert *x509.Certificate) string {
|
||||
if cert == nil {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256(cert.Raw)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
// Package transport — idempotency.go implements the X-Orca-Idempotency-Key
|
||||
// header for cross-node dispatch (REQ-037). The dedupe store is a
|
||||
// in-memory map with a TTL window; persistent dedupe across daemon
|
||||
// restarts is out of scope for v0.2 (the bin-packing scheduler is
|
||||
// single-daemon for now; the dedupe window just covers in-flight retries).
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// IdempotencyHeader is the canonical header name. Casing-insensitive
|
||||
// per HTTP spec, but we keep the canonical form for log clarity.
|
||||
IdempotencyHeader = "X-Orca-Idempotency-Key"
|
||||
// DedupeWindow is how long an idempotency key is honored after
|
||||
// first use. Tuned for the in-flight retry window: a transient
|
||||
// dispatch error followed by an exponential-backoff retry (max 5
|
||||
// attempts with cap 5s) completes well within 60s. The dedupe
|
||||
// window is 5 minutes to cover cases where a peer processes a
|
||||
// request but the response is lost on the wire.
|
||||
DedupeWindow = 5 * time.Minute
|
||||
)
|
||||
|
||||
// dedupeEntry is a single (key -> response) record with expiry.
|
||||
type dedupeEntry struct {
|
||||
key string
|
||||
jobID string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// IdempotencyStore is a thread-safe in-memory dedupe map. Keys are
|
||||
// scoped per-process; a restart drops the map. For P02 this is
|
||||
// sufficient because the dispatcher is single-instance.
|
||||
type IdempotencyStore struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]dedupeEntry
|
||||
}
|
||||
|
||||
// NewIdempotencyStore returns an empty store.
|
||||
func NewIdempotencyStore() *IdempotencyStore {
|
||||
return &IdempotencyStore{entries: make(map[string]dedupeEntry)}
|
||||
}
|
||||
|
||||
// Get returns the recorded jobID for key, or "" if no entry is present
|
||||
// (or the entry is expired). The second return is true if a live
|
||||
// (non-expired) entry was found.
|
||||
func (s *IdempotencyStore) Get(key string) (string, bool) {
|
||||
if key == "" {
|
||||
return "", false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
e, ok := s.entries[key]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if time.Now().After(e.expiresAt) {
|
||||
delete(s.entries, key)
|
||||
return "", false
|
||||
}
|
||||
return e.jobID, true
|
||||
}
|
||||
|
||||
// Put records (key -> jobID) with a default expiry of DedupeWindow.
|
||||
// Overwrites any prior entry (rare in practice since we check Get first).
|
||||
func (s *IdempotencyStore) Put(key, jobID string) {
|
||||
if key == "" || jobID == "" {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.entries[key] = dedupeEntry{
|
||||
key: key,
|
||||
jobID: jobID,
|
||||
expiresAt: time.Now().Add(DedupeWindow),
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Sweep removes all expired entries. Called periodically by the dispatch
|
||||
// service; safe to call concurrently.
|
||||
func (s *IdempotencyStore) Sweep() {
|
||||
now := time.Now()
|
||||
s.mu.Lock()
|
||||
for k, e := range s.entries {
|
||||
if now.After(e.expiresAt) {
|
||||
delete(s.entries, k)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// ErrIdempotencyKeyRequired is returned by retry helpers when a
|
||||
// non-idempotent call (e.g., POST) is retried without an idempotency
|
||||
// key. Matches REQ-037's "absent header + transient error → no retry".
|
||||
var ErrIdempotencyKeyRequired = errors.New("retry requires X-Orca-Idempotency-Key header")
|
||||
|
||||
// HeaderFromContext extracts the X-Orca-Idempotency-Key from a
|
||||
// request-scoped context, if any. The dispatcher stores the key on
|
||||
// the context via WithIdempotencyKey so downstream layers can read it
|
||||
// without parsing headers.
|
||||
type idempotencyKey struct{}
|
||||
|
||||
// WithIdempotencyKey attaches an idempotency key to ctx.
|
||||
func WithIdempotencyKey(ctx context.Context, key string) context.Context {
|
||||
if key == "" {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, idempotencyKey{}, key)
|
||||
}
|
||||
|
||||
// IdempotencyKeyFromContext returns the key attached to ctx, or "".
|
||||
func IdempotencyKeyFromContext(ctx context.Context) string {
|
||||
if v := ctx.Value(idempotencyKey{}); v != nil {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIdempotencyStorePutGet(t *testing.T) {
|
||||
s := NewIdempotencyStore()
|
||||
if _, ok := s.Get("missing"); ok {
|
||||
t.Fatal("expected missing key to return ok=false")
|
||||
}
|
||||
s.Put("k1", "job-1")
|
||||
if jobID, ok := s.Get("k1"); !ok || jobID != "job-1" {
|
||||
t.Errorf("Get(k1): got (%q, %v), want (job-1, true)", jobID, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdempotencyStoreExpiry(t *testing.T) {
|
||||
s := NewIdempotencyStore()
|
||||
// Manually insert an expired entry.
|
||||
s.entries["expired"] = dedupeEntry{
|
||||
key: "expired",
|
||||
jobID: "old-job",
|
||||
expiresAt: time.Now().Add(-1 * time.Minute),
|
||||
}
|
||||
if _, ok := s.Get("expired"); ok {
|
||||
t.Fatal("expected expired entry to return ok=false")
|
||||
}
|
||||
if _, exists := s.entries["expired"]; exists {
|
||||
t.Error("expected expired entry to be removed by Get")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdempotencyStoreContext(t *testing.T) {
|
||||
ctx := WithIdempotencyKey(context.Background(), "key-1")
|
||||
if got := IdempotencyKeyFromContext(ctx); got != "key-1" {
|
||||
t.Errorf("IdempotencyKeyFromContext: got %q, want key-1", got)
|
||||
}
|
||||
ctx2 := context.Background()
|
||||
if got := IdempotencyKeyFromContext(ctx2); got != "" {
|
||||
t.Errorf("IdempotencyKeyFromContext(empty): got %q, want \"\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetrySucceedsAfterTransient(t *testing.T) {
|
||||
calls := 0
|
||||
got, err := Do(context.Background(), DefaultRetryPolicy(),
|
||||
func(_ context.Context, attempt int) (string, bool, error) {
|
||||
calls++
|
||||
if attempt < 3 {
|
||||
return "", true, errors.New("connection refused: try again")
|
||||
}
|
||||
return "ok", true, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Do: %v", err)
|
||||
}
|
||||
if got != "ok" {
|
||||
t.Errorf("Do: got %q, want ok", got)
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Errorf("Do: got %d calls, want 3", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryNoKeyOnTransient(t *testing.T) {
|
||||
// Without an idempotency key AND a non-idempotent verb, a
|
||||
// transient error on the first attempt must NOT retry (REQ-037).
|
||||
calls := 0
|
||||
_, err := Do(context.Background(), DefaultRetryPolicy(),
|
||||
func(_ context.Context, _ int) (string, bool, error) {
|
||||
calls++
|
||||
return "", false, errors.New("connection refused")
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("expected 1 call (no retry without key), got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryPermanentError(t *testing.T) {
|
||||
calls := 0
|
||||
_, err := Do(context.Background(), DefaultRetryPolicy(),
|
||||
func(_ context.Context, _ int) (string, bool, error) {
|
||||
calls++
|
||||
return "", true, ErrPermanent
|
||||
})
|
||||
if !errors.Is(err, ErrPermanent) {
|
||||
t.Errorf("expected ErrPermanent, got %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("expected 1 call (permanent = no retry), got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryContextCancel(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel immediately
|
||||
calls := 0
|
||||
_, err := Do(ctx, DefaultRetryPolicy(),
|
||||
func(_ context.Context, _ int) (string, bool, error) {
|
||||
calls++
|
||||
return "", true, errors.New("EOF")
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("expected context.Canceled, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTransient(t *testing.T) {
|
||||
cases := []struct {
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{nil, false},
|
||||
{errors.New("connection refused"), true},
|
||||
{errors.New("i/o timeout"), true},
|
||||
{errors.New("EOF"), true},
|
||||
{errors.New("no such host"), true},
|
||||
{errors.New("connection reset by peer"), true},
|
||||
{errors.New("invalid spec"), false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := IsTransient(c.err); got != c.want {
|
||||
t.Errorf("IsTransient(%v): got %v, want %v", c.err, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
// Package transport contains the cross-node transport primitives for
|
||||
// orca. mTLS is the v0.2 baseline (D-011..D-015); clients and servers
|
||||
// use stdlib crypto/tls with TLS 1.3 only and an AEAD cipher allowlist.
|
||||
//
|
||||
// The transport layer deliberately depends on the stdlib only — no
|
||||
// gRPC, no ConnectRPC, no third-party transport libraries. This keeps
|
||||
// the binary lean (matches the minimalist pillar) and the trust chain
|
||||
// auditable (one library: the Go stdlib).
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/security"
|
||||
)
|
||||
|
||||
// MTLSClient wraps an http.Client configured for mTLS. The client
|
||||
// verifies the server cert against the pinned CA and the expected
|
||||
// server name (typically the SAN on the server cert).
|
||||
type MTLSClient struct {
|
||||
caPath string
|
||||
serverName string
|
||||
clientCert string
|
||||
clientKey string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewMTLSClient constructs an mTLS client.
|
||||
//
|
||||
// caPath is the path to the CA cert (PEM). The client's RootCAs is set
|
||||
// to this single CA, so the server cert MUST be signed by it (REQ-011).
|
||||
// serverName is the expected DNS name on the server cert's SAN list
|
||||
// (REQ-036).
|
||||
//
|
||||
// certPath and keyPath are optional; if both are non-empty, the client
|
||||
// presents them during the handshake. Pass empty strings for clients
|
||||
// that don't authenticate themselves.
|
||||
func NewMTLSClient(caPath, serverName, certPath, keyPath string) (*MTLSClient, error) {
|
||||
if caPath == "" {
|
||||
return nil, errors.New("NewMTLSClient: caPath is required")
|
||||
}
|
||||
if serverName == "" {
|
||||
return nil, errors.New("NewMTLSClient: serverName is required (must match server cert SAN)")
|
||||
}
|
||||
tlsCfg, err := security.ClientTLSConfig(caPath, serverName, certPath, keyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("NewMTLSClient: %w", err)
|
||||
}
|
||||
// Tighten the http.Client transport. The defaults (DefaultTransport)
|
||||
// would reuse connections too aggressively for our needs; we want
|
||||
// per-request timeout and a fresh dial per request to ensure cert
|
||||
// rotation is picked up promptly.
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: tlsCfg,
|
||||
MaxIdleConns: 10,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
ResponseHeaderTimeout: 10 * time.Second,
|
||||
DisableCompression: true,
|
||||
}
|
||||
return &MTLSClient{
|
||||
caPath: caPath,
|
||||
serverName: serverName,
|
||||
clientCert: certPath,
|
||||
clientKey: keyPath,
|
||||
http: &http.Client{Transport: tr, Timeout: 30 * time.Second},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Do executes an HTTP request over mTLS. Returns the response or an
|
||||
// error. On TLS handshake failure, wraps the error with structured
|
||||
// context for the audit/handshake_log package.
|
||||
func (c *MTLSClient) Do(req *http.Request) (*http.Response, error) {
|
||||
if c == nil || c.http == nil {
|
||||
return nil, errors.New("MTLSClient: nil receiver")
|
||||
}
|
||||
return c.http.Do(req)
|
||||
}
|
||||
|
||||
// VerifyPeerCertificate is a tls.Config.VerifyPeerCertificate callback
|
||||
// that enforces a pinned peer identity. Use it on the client side to
|
||||
// reject certs that match the CA but are not the expected server.
|
||||
//
|
||||
// expectedFingerprint is the SHA-256 hex of the server cert DER. If it
|
||||
// matches, the connection is allowed. If not, the handshake is
|
||||
// aborted with a clear error.
|
||||
func VerifyPeerCertificate(expectedFingerprint string) func([][]byte, [][]*x509.Certificate) error {
|
||||
return func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
|
||||
if len(rawCerts) == 0 {
|
||||
return errors.New("VerifyPeerCertificate: no peer certs presented")
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(rawCerts[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("VerifyPeerCertificate: parse leaf: %w", err)
|
||||
}
|
||||
got := security.FingerprintOf(leaf.Raw)
|
||||
if got != expectedFingerprint {
|
||||
return fmt.Errorf("VerifyPeerCertificate: peer fingerprint mismatch: got %s, want %s",
|
||||
got, expectedFingerprint)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// DialContext dials a TCP address over raw TLS (no HTTP). Returns a
|
||||
// tls.Conn. Used for low-level handshake tests; the mTLS client above
|
||||
// is what production code uses.
|
||||
func DialContext(ctx context.Context, network, addr, caPath, serverName string) (net.Conn, error) {
|
||||
if caPath == "" {
|
||||
return nil, errors.New("DialContext: caPath is required")
|
||||
}
|
||||
tlsCfg, err := security.ClientTLSConfig(caPath, serverName, "", "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("DialContext: %w", err)
|
||||
}
|
||||
d := &net.Dialer{Timeout: 5 * time.Second}
|
||||
return tls.DialWithDialer(d, network, addr, tlsCfg)
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
// Package transport — retry.go implements exponential backoff with
|
||||
// jitter for cross-node dispatch retries. Per the P02 plan: 100ms
|
||||
// initial, x2, 5s cap, max 5 attempts. Auto-retry only when the call
|
||||
// is idempotent (X-Orca-Idempotency-Key header present, or the verb
|
||||
// is intrinsically idempotent like GET/HEAD).
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math/rand"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// RetryInitial is the first backoff interval.
|
||||
RetryInitial = 100 * time.Millisecond
|
||||
// RetryMax is the cap on backoff between attempts.
|
||||
RetryMax = 5 * time.Second
|
||||
// RetryMaxAttempts is the total attempt count (including the first).
|
||||
RetryMaxAttempts = 5
|
||||
)
|
||||
|
||||
// RetryPolicy carries the backoff configuration. Zero value is the
|
||||
// default (100ms / 5s / 5 attempts).
|
||||
type RetryPolicy struct {
|
||||
Initial time.Duration
|
||||
Max time.Duration
|
||||
MaxAttempts int
|
||||
}
|
||||
|
||||
// DefaultRetryPolicy returns the P02 default.
|
||||
func DefaultRetryPolicy() RetryPolicy {
|
||||
return RetryPolicy{Initial: RetryInitial, Max: RetryMax, MaxAttempts: RetryMaxAttempts}
|
||||
}
|
||||
|
||||
// IsTransient reports whether err looks like a transient failure
|
||||
// worth retrying. We treat network errors, context-deadline-exceeded
|
||||
// (peer was slow but reachable), and a sentinel ErrTransient as
|
||||
// retryable; everything else (4xx, validation, auth) is permanent.
|
||||
func IsTransient(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, ErrTransient) {
|
||||
return true
|
||||
}
|
||||
// We avoid pulling net/error here to keep dependencies minimal;
|
||||
// the most common transient signature is the substring "connection
|
||||
// refused" or "i/o timeout". Tests assert these explicitly.
|
||||
s := err.Error()
|
||||
for _, sub := range []string{"connection refused", "i/o timeout", "EOF", "no such host", "connection reset"} {
|
||||
if contains(s, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ErrTransient is a sentinel callers can wrap to mark an error
|
||||
// retryable. ErrPermanent is the opposite.
|
||||
var (
|
||||
ErrTransient = errors.New("transient error")
|
||||
ErrPermanent = errors.New("permanent error")
|
||||
)
|
||||
|
||||
// RetryableFunc is the signature Retry calls. It returns the result
|
||||
// and an error. The bool indicates whether the call is idempotent
|
||||
// (true = safe to retry without an idempotency key).
|
||||
type RetryableFunc[T any] func(ctx context.Context, attempt int) (T, bool, error)
|
||||
|
||||
// Do runs fn with backoff according to policy. It retries only if
|
||||
// (a) the call is idempotent, OR (b) ctx carries an idempotency key
|
||||
// (set via WithIdempotencyKey). Otherwise a transient error on the
|
||||
// first attempt is returned immediately (REQ-037: no retry without
|
||||
// the key).
|
||||
//
|
||||
// The generic result T lets callers reuse this for jobIDs, status
|
||||
// responses, etc. without boxing through `any`.
|
||||
func Do[T any](ctx context.Context, p RetryPolicy, fn RetryableFunc[T]) (T, error) {
|
||||
var zero T
|
||||
if p.MaxAttempts <= 0 {
|
||||
p = DefaultRetryPolicy()
|
||||
}
|
||||
hasKey := IdempotencyKeyFromContext(ctx) != ""
|
||||
for attempt := 1; attempt <= p.MaxAttempts; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
v, idempotent, err := fn(ctx, attempt)
|
||||
if err == nil {
|
||||
return v, nil
|
||||
}
|
||||
// Permanent errors never retry.
|
||||
if errors.Is(err, ErrPermanent) {
|
||||
return zero, err
|
||||
}
|
||||
// Last attempt — surface the error.
|
||||
if attempt == p.MaxAttempts {
|
||||
return zero, err
|
||||
}
|
||||
// Transient + no idempotency + not idempotent verb: no retry.
|
||||
if IsTransient(err) && !idempotent && !hasKey {
|
||||
return zero, err
|
||||
}
|
||||
// Wait with jittered backoff, but respect ctx cancellation.
|
||||
wait := backoff(p.Initial, p.Max, attempt)
|
||||
t := time.NewTimer(wait)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Stop()
|
||||
return zero, ctx.Err()
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
return zero, errors.New("retry.Do: exhausted attempts without error (impossible)")
|
||||
}
|
||||
|
||||
// backoff returns the wait duration for the n-th attempt (1-indexed).
|
||||
// Formula: min(Initial * 2^(n-1), Max), with up to 25% jitter.
|
||||
func backoff(initial, max time.Duration, n int) time.Duration {
|
||||
d := initial
|
||||
for i := 1; i < n; i++ {
|
||||
d *= 2
|
||||
if d > max {
|
||||
d = max
|
||||
break
|
||||
}
|
||||
}
|
||||
// Jitter: ±25% of d.
|
||||
jitter := time.Duration(rand.Int63n(int64(d) / 2))
|
||||
d = d - d/4 + jitter
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// contains is a tiny substring helper (avoids pulling strings for one
|
||||
// call site; this is hot-path retry classification).
|
||||
func contains(s, sub string) bool {
|
||||
if len(sub) == 0 {
|
||||
return true
|
||||
}
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
#!/bin/bash
|
||||
# backfill_releases.sh - Backfill Gitea releases for existing v0.1 tags
|
||||
#
|
||||
# For each tag passed (or all v0.1.1..v0.1.6 and v0.2.0), this script:
|
||||
# 1. Builds the orca binary from the current milestone branch head
|
||||
# (v0.1 retrospective: phase tags marked ship points but the entry
|
||||
# point fix is consolidated into a single post-fix build; see
|
||||
# .ciagent/RELEASE_POLICY.md for the standing rule)
|
||||
# 2. Injects the historical version via -ldflags
|
||||
# 3. Packages a tarball
|
||||
# 4. Creates a Gitea release with the tarball as an asset
|
||||
#
|
||||
# Idempotent: skips tags that already have a release.
|
||||
#
|
||||
# Usage: scripts/backfill_releases.sh [tag1 tag2 ...]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# Source .env for GITEA_TOKEN
|
||||
for env_file in "$REPO_ROOT/.env" "$PWD/.env" "./.env"; do
|
||||
if [ -f "$env_file" ]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "$env_file"
|
||||
set +a
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
err() { echo "backfill: error: $*" >&2; exit 1; }
|
||||
info() { echo "backfill: $*"; }
|
||||
|
||||
: "${GITEA_TOKEN:?GITEA_TOKEN is required}"
|
||||
command -v tea >/dev/null 2>&1 || err "tea CLI not on PATH"
|
||||
command -v go >/dev/null 2>&1 || err "go not on PATH"
|
||||
command -v tar >/dev/null 2>&1 || err "tar not on PATH"
|
||||
|
||||
REPO="coreci/orca"
|
||||
|
||||
# Default: backfill v0.1.1..v0.1.6 and v0.2.0
|
||||
if [ $# -eq 0 ]; then
|
||||
TAGS=(v0.1.1 v0.1.2 v0.1.3 v0.1.4 v0.1.5 v0.1.6 v0.2.0)
|
||||
else
|
||||
TAGS=("$@")
|
||||
fi
|
||||
|
||||
# Existing releases to skip
|
||||
EXISTING="$(tea releases list --repo "$REPO" --output simple 2>/dev/null | awk '{print $1}' || true)"
|
||||
|
||||
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||
ARCH="$(uname -m)"
|
||||
case "$ARCH" in
|
||||
x86_64) ARCH=amd64 ;;
|
||||
aarch64) ARCH=arm64 ;;
|
||||
armv7l) ARCH=armv7 ;;
|
||||
esac
|
||||
|
||||
# Use the cached Go 1.25.0 toolchain explicitly
|
||||
TOOLGO="/root/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.25.0.linux-amd64/bin/go"
|
||||
if [ ! -x "$TOOLGO" ]; then
|
||||
TOOLGO="$(command -v go)"
|
||||
fi
|
||||
|
||||
phase_name() {
|
||||
case "$1" in
|
||||
v0.1.1) echo "Phase 1: CLI skeleton" ;;
|
||||
v0.1.2) echo "Phase 2: Node management" ;;
|
||||
v0.1.3) echo "Phase 3: Task execution" ;;
|
||||
v0.1.4) echo "Phase 4: State persistence" ;;
|
||||
v0.1.5) echo "Phase 5: Health checks" ;;
|
||||
v0.1.6) echo "Phase 6: CoreCI release flow" ;;
|
||||
v0.2.0) echo "Milestone v0.1: Foundation complete" ;;
|
||||
*) echo "Orca $1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
for TAG in "${TAGS[@]}"; do
|
||||
if echo "$EXISTING" | grep -qx "$TAG"; then
|
||||
info "skip $TAG (release exists)"
|
||||
continue
|
||||
fi
|
||||
|
||||
info "=== $TAG ==="
|
||||
# The v0.1.1..v0.1.6 phase tags point to merge commits; the canonical
|
||||
# source of truth for v0.1 code is the current milestone branch HEAD
|
||||
# (which includes the main.go entry-point fix).
|
||||
BUILD_COMMIT="$(git rev-parse --short HEAD)"
|
||||
FULL_COMMIT="$(git rev-parse HEAD)"
|
||||
info "build from HEAD: $BUILD_COMMIT (per RELEASE_POLICY.md v0.1 retrospective)"
|
||||
|
||||
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
LDFLAGS="-s -w -X git.cloudinit.dev/coreci/orca/internal/cli.version=$TAG -X git.cloudinit.dev/coreci/orca/internal/cli.gitCommit=$BUILD_COMMIT -X git.cloudinit.dev/coreci/orca/internal/cli.buildTime=$BUILD_TIME"
|
||||
|
||||
mkdir -p bin
|
||||
GOTOOLCHAIN=local "$TOOLGO" build -trimpath -ldflags="$LDFLAGS" -o bin/orca ./cmd/orca
|
||||
|
||||
TARBALL="orca-${TAG}-${OS}-${ARCH}.tar.gz"
|
||||
tar -czf "$TARBALL" -C bin orca
|
||||
info "tarball: $TARBALL ($(du -h "$TARBALL" | cut -f1))"
|
||||
|
||||
# Generate release notes
|
||||
PREV_TAG="$(git describe --tags --abbrev=0 "$TAG^" 2>/dev/null || true)"
|
||||
NOTES_FILE="$(mktemp)"
|
||||
PHASE_NAME="$(phase_name "$TAG")"
|
||||
{
|
||||
echo "# Release $TAG — ${PHASE_NAME}"
|
||||
echo ""
|
||||
echo "_Built: $BUILD_TIME from $BUILD_COMMIT (${FULL_COMMIT:0:12})_"
|
||||
echo ""
|
||||
echo "## Notes"
|
||||
echo ""
|
||||
echo "This release artifact is built from the v0.1 milestone branch HEAD"
|
||||
echo "(post entry-point fix). For v0.2+ and future milestones, every phase"
|
||||
echo "tag will be released with the binary as-of that exact commit; see"
|
||||
echo "\`.ciagent/RELEASE_POLICY.md\` for the standing rule."
|
||||
echo ""
|
||||
if [ -n "$PREV_TAG" ] && [ "$TAG" != "v0.2.0" ]; then
|
||||
echo "## Changes since $PREV_TAG"
|
||||
echo ""
|
||||
git log --pretty=format:'- %s' "${PREV_TAG}..${TAG}" 2>/dev/null | head -50
|
||||
echo ""
|
||||
fi
|
||||
if [ "$TAG" = "v0.2.0" ]; then
|
||||
echo "## Milestone v0.1: Foundation — All Phases"
|
||||
echo ""
|
||||
echo "All 6 phases of the v0.1 Foundation milestone are complete:"
|
||||
echo ""
|
||||
echo "- **Phase 1** (v0.1.1): CLI skeleton with Cobra, subcommand stubs, pre-push hook"
|
||||
echo "- **Phase 2** (v0.1.2): Node management with SQLite-backed registry"
|
||||
echo "- **Phase 3** (v0.1.3): Task execution engine with HCL specs, jobs, tasks, WaitDelay"
|
||||
echo "- **Phase 4** (v0.1.4): Local state persistence — audit log + migration runner"
|
||||
echo "- **Phase 5** (v0.1.5): Health-check daemon with /healthz, /readyz, /v1/* handlers"
|
||||
echo "- **Phase 6** (v0.1.6): CoreCI release flow with .coreci.yml and tea integration"
|
||||
echo ""
|
||||
echo "## Requirements Covered (21/24)"
|
||||
echo ""
|
||||
echo "REQ-001 Go 1.25+ toolchain, REQ-002 CLI-first single binary, REQ-003 Offline-first,"
|
||||
echo "REQ-004 Single-node task execution, REQ-005 modernc/sqlite CGO-free, REQ-006 slog"
|
||||
echo "audit logging, REQ-007 CoreCI release flow, REQ-008 Structured JSON logging,"
|
||||
echo "REQ-009 HCL/YAML job spec parsing, REQ-010 --json output flag, REQ-012 Config"
|
||||
echo "locations (~/.orca/, ORCA_DB), REQ-013 Pre-push hook, REQ-015 MIT LICENSE,"
|
||||
echo "REQ-016 README quickstart, REQ-017 context.Context propagation, REQ-018 %w error"
|
||||
echo "wrapping, REQ-019 Cobra CLI, REQ-020 hashicorp/hcl parser, REQ-021 os/exec"
|
||||
echo "WaitDelay, REQ-024 Makefile standard targets."
|
||||
echo ""
|
||||
echo "Deferred to v0.2: REQ-011/023 (mTLS), REQ-014 (gosec+govulncheck), REQ-022"
|
||||
echo "(iter.Seq streaming)."
|
||||
echo ""
|
||||
echo "## Changes since v0.1.6"
|
||||
echo ""
|
||||
git log --pretty=format:'- %s' "v0.1.6..${TAG}" 2>/dev/null | head -50
|
||||
echo ""
|
||||
fi
|
||||
} > "$NOTES_FILE"
|
||||
|
||||
info "creating gitea release..."
|
||||
tea releases create "$TAG" \
|
||||
--repo "$REPO" \
|
||||
--title "Orca $TAG — ${PHASE_NAME}" \
|
||||
--note-file "$NOTES_FILE" \
|
||||
--asset "$TARBALL"
|
||||
|
||||
info "✓ $TAG published"
|
||||
rm -f "$TARBALL"
|
||||
done
|
||||
|
||||
info "all done"
|
||||
@@ -1,138 +0,0 @@
|
||||
#!/bin/bash
|
||||
# release.sh - Build a release artifact and create a Gitea release via `tea`
|
||||
#
|
||||
# Usage:
|
||||
# scripts/release.sh [VERSION]
|
||||
#
|
||||
# If VERSION is not given, it is read from the latest git tag (e.g. v0.1.5).
|
||||
# Falls back to "dev" if no tag is found.
|
||||
#
|
||||
# Steps:
|
||||
# 1. Validate toolchain (git, go, tar, tea)
|
||||
# 2. Determine version
|
||||
# 3. Build orca binary with version injection via -ldflags
|
||||
# 4. Package as tarball: orca-${VERSION}-${OS}-${ARCH}.tar.gz
|
||||
# 5. Generate release notes from `---ci---` blocks since last tag
|
||||
# 6. Invoke `tea releases create` to publish to Gitea
|
||||
#
|
||||
# Requires:
|
||||
# - GITEA_TOKEN environment variable
|
||||
# - `tea` CLI on PATH (https://gitea.com/gitea/tea)
|
||||
#
|
||||
# Idempotent: tea releases create will fail if the release already exists;
|
||||
# the script surfaces that error rather than silently swallowing it.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# Source .env for GITEA_TOKEN if present
|
||||
for env_file in "$REPO_ROOT/.env" "$PWD/.env" "./.env"; do
|
||||
if [ -f "$env_file" ]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "$env_file"
|
||||
set +a
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# --- helpers --------------------------------------------------------------
|
||||
|
||||
err() { echo "release: error: $*" >&2; exit 1; }
|
||||
info() { echo "release: $*"; }
|
||||
|
||||
require_tool() {
|
||||
command -v "$1" >/dev/null 2>&1 || err "required tool not found: $1"
|
||||
}
|
||||
|
||||
# --- preflight ------------------------------------------------------------
|
||||
|
||||
require_tool git
|
||||
require_tool go
|
||||
require_tool tar
|
||||
|
||||
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||
err "GITEA_TOKEN is not set. Export it or put it in .env"
|
||||
fi
|
||||
|
||||
if ! command -v tea >/dev/null 2>&1; then
|
||||
err "tea CLI not found on PATH. Install from https://gitea.com/gitea/tea"
|
||||
fi
|
||||
|
||||
# --- version detection ----------------------------------------------------
|
||||
|
||||
VERSION="${1:-}"
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION="$(git describe --tags --abbrev=0 2>/dev/null || echo dev)"
|
||||
fi
|
||||
# Strip leading 'v' for the tarball name (we keep it in the release tag itself)
|
||||
VERSION_NUMBER="${VERSION#v}"
|
||||
|
||||
info "version: $VERSION"
|
||||
info "building..."
|
||||
|
||||
# --- build with version injection ----------------------------------------
|
||||
|
||||
GIT_COMMIT="$(git rev-parse --short HEAD)"
|
||||
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
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"
|
||||
|
||||
mkdir -p bin
|
||||
go build -trimpath -ldflags="$LDFLAGS" -o bin/orca ./cmd/orca
|
||||
info "built: bin/orca"
|
||||
|
||||
# --- tarball --------------------------------------------------------------
|
||||
|
||||
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||
ARCH="$(uname -m)"
|
||||
case "$ARCH" in
|
||||
x86_64) ARCH=amd64 ;;
|
||||
aarch64) ARCH=arm64 ;;
|
||||
armv7l) ARCH=armv7 ;;
|
||||
esac
|
||||
|
||||
TARBALL="orca-${VERSION}-${OS}-${ARCH}.tar.gz"
|
||||
tar -czf "$TARBALL" -C bin orca
|
||||
info "packaged: $TARBALL ($(du -h "$TARBALL" | cut -f1))"
|
||||
|
||||
# --- release notes from ---ci--- blocks ----------------------------------
|
||||
|
||||
NOTES_FILE="$(mktemp)"
|
||||
trap 'rm -f "$NOTES_FILE"' EXIT
|
||||
|
||||
{
|
||||
echo "# Release $VERSION"
|
||||
echo ""
|
||||
echo "_Built: $BUILD_TIME from $GIT_COMMIT"
|
||||
echo ""
|
||||
|
||||
PREV_TAG="$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")"
|
||||
if [ -n "$PREV_TAG" ]; then
|
||||
RANGE="$PREV_TAG..HEAD"
|
||||
else
|
||||
RANGE="HEAD"
|
||||
fi
|
||||
|
||||
echo "## Changes since $PREV_TAG"
|
||||
echo ""
|
||||
# Extract messages of ---ci--- tagged commits in the range
|
||||
git log --pretty=format:'- %s' "$RANGE" 2>/dev/null | head -100 || true
|
||||
echo ""
|
||||
} > "$NOTES_FILE"
|
||||
|
||||
info "release notes: $NOTES_FILE"
|
||||
cat "$NOTES_FILE"
|
||||
|
||||
# --- publish to gitea -----------------------------------------------------
|
||||
|
||||
info "creating gitea release..."
|
||||
tea releases create "$VERSION" \
|
||||
--repo "$REPO" \
|
||||
--title "Orca $VERSION" \
|
||||
--note-file "$NOTES_FILE" \
|
||||
--asset "$TARBALL"
|
||||
|
||||
info "✓ release $VERSION published"
|
||||
@@ -1,103 +0,0 @@
|
||||
#!/bin/bash
|
||||
# security_scan.sh — run gosec, govulncheck, and gitleaks on the
|
||||
# orca repo. Local equivalent of the .coreci.yml `validate` security
|
||||
# stages. Exits non-zero on any unsuppressed finding.
|
||||
#
|
||||
# Tool detection: a tool that's not installed is SKIPPED (warning
|
||||
# printed). The .coreci.yml `validate` pipeline requires all three;
|
||||
# the local `make security-scan` is opt-in for developer machines.
|
||||
#
|
||||
# Usage: scripts/security_scan.sh [--strict]
|
||||
# --strict All three tools must be present and pass.
|
||||
#
|
||||
# REQ-014: gosec + govulncheck in CI
|
||||
# REQ-027: govulncheck runs in offline mode
|
||||
# REQ-039: gitleaks allowlist for cert PEM blocks
|
||||
# REQ-040: golangci-lint as the unified linter
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
STRICT=false
|
||||
if [ "${1:-}" = "--strict" ]; then
|
||||
STRICT=true
|
||||
fi
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
SKIP=0
|
||||
|
||||
run_tool() {
|
||||
local name="$1"
|
||||
shift
|
||||
echo ""
|
||||
echo "─── $name ─────────────────────────────────────"
|
||||
if "$@"; then
|
||||
echo "✓ $name: PASS"
|
||||
PASS=$((PASS+1))
|
||||
else
|
||||
rc=$?
|
||||
if [ $rc -eq 127 ]; then
|
||||
echo "⚠ $name: SKIP (not installed)"
|
||||
SKIP=$((SKIP+1))
|
||||
else
|
||||
echo "✗ $name: FAIL (rc=$rc)"
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# gosec: static analysis. REQ-014 baseline is empty (clean repo);
|
||||
# any new G101 (hardcoded credentials) fails the build.
|
||||
run_gosec() {
|
||||
if ! command -v gosec >/dev/null 2>&1; then
|
||||
return 127
|
||||
fi
|
||||
gosec -fmt text -quiet ./...
|
||||
}
|
||||
|
||||
# govulncheck: vulnerability scan. REQ-027: offline mode.
|
||||
# We rely on the bundled DB; the `GOVULNCHECK_DB` env var (when
|
||||
# present) overrides. This is documented in docs/security-scanning.md.
|
||||
run_govulncheck() {
|
||||
if ! command -v govulncheck >/dev/null 2>&1; then
|
||||
return 127
|
||||
fi
|
||||
GOFLAGS=-mod=mod govulncheck -mode binary ./... >/dev/null
|
||||
}
|
||||
|
||||
# gitleaks: secret scan. REQ-039 allowlist via .gitleaks.toml;
|
||||
# REQ-029 baseline via .gitleaks-baseline.json.
|
||||
run_gitleaks() {
|
||||
if ! command -v gitleaks >/dev/null 2>&1; then
|
||||
return 127
|
||||
fi
|
||||
if [ ! -f .gitleaks-baseline.json ]; then
|
||||
echo " (no .gitleaks-baseline.json; first run will be unfiltered)"
|
||||
fi
|
||||
gitleaks detect --source . --config .gitleaks.toml --baseline-path .gitleaks-baseline.json --no-banner
|
||||
}
|
||||
|
||||
run_tool "gosec" run_gosec
|
||||
run_tool "govulncheck" run_govulncheck
|
||||
run_tool "gitleaks" run_gitleaks
|
||||
|
||||
echo ""
|
||||
echo "─── summary ─────────────────────────────────────"
|
||||
echo " $PASS pass, $FAIL fail, $SKIP skip"
|
||||
echo ""
|
||||
|
||||
if [ $FAIL -gt 0 ]; then
|
||||
echo "✗ security-scan FAILED ($FAIL tool(s) reported findings)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if $STRICT && [ $SKIP -gt 0 ]; then
|
||||
echo "✗ security-scan FAILED in --strict mode ($SKIP tool(s) skipped)"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "✓ security-scan PASSED"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user