---ci--- project: orca phase: 3 milestone: v0.3 status: complete requirements: covered: [REQ-022, REQ-030, REQ-032] partial: [] ---/ci--- v0.3 milestone merged to main. Includes all v0.2 work (P08-P10) that was previously on the milestone branch but not yet merged to main, plus the v0.3 completion work (iter.Seq streaming + doctor network/db). v0.2 phases included: P08 (mTLS), P09 (scheduling), P10 (security scan). v0.3 phases: P0 (pre-execution), P1 (iter.Seq streaming), P2 (doctor), P3 (final review+ship). Total: 40 requirements, all complete. No new go.mod dependencies. Full test suite passes under -race. gofmt + go vet clean.
37 KiB
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.Seqstreaming for--watchflags (REQ-022, REQ-030) - P02 —
orca doctornetwork + 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 |
| 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
-
PeerRegistryis non-persistent and always empty at CLI time. The doctor network check cannot rely on it — there is no code path that populates it. Thenodestable IS persisted and hasAddress, but lacks theServerName/CAPathneeded for an mTLS probe. Resolution: doctor network reads thenodestable viaNodeRepo.List, and derivesServerName+CAPathfrom local config (certpaths.CACertPath()+ node name/addr). See D-029. -
doctor.Run/Check.Rundo not plumb a*sql.DBor 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. TheCheck.Runsignature (func(ctx) (Result, string)) is preserved by closure-capturing the handles in the constructor. See D-027, D-031. -
No public migration-version query.
migrate()is unexported and writes toschema_migrations(name, applied_at). P02 adds a publicstore.MigrationVersion(ctx, db)(or method on a repo) that selects the max applied migration name. See D-033. -
Doctor test
TestRunAllChecksWithNoCAasserts 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:
// 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:
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:
-
Add a package var
jobWatch booland registerjobListCmd.Flags().BoolVar(&jobWatch, "watch", false, "stream jobs until Ctrl-C"). -
In
RunE, branch onjobWatch:- If
!jobWatch: keep the existing 5s-timeoutListpath. - 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 amap[string][]byteof 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":{...}}\nand 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.Jobvia reflect/cmp). If changed, emit"\033[2J\033[H"(clear) then the table header + rows. This gives a "top-like" refresh.
Because
iter.Seqdoes not return an error, the watch path swallows per-tick query errors insideWatch(D-034). The CLI relies onctx.Done()for termination. - If
File: internal/cli/node.go — analogous change to nodeListCmd:
- Add
nodeWatch bool, register--watchflag. - Branch in
RunE;seq := store.NewNodeRepo(db).Watch(ctx)(open a freshopenDB()for the watch path;registry.Listis 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, callWatchwith a 10ms interval (via test hook), range overseqcollecting 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. Usecontext.WithTimeoutfor cancellation.TestJobRepoWatch_StopsOnConsumerBreak: range overseqandbreakafter the first yield; assert the function returns (no hang) within a short deadline. This validates theyield==falsepath.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 thejobListCmd.RunEwith--watch --jsonin 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.moddeclaresgo 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:
// 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():
// 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():
// 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():
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:
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), callMigrationVersion, assert it returns0005_node_capacity.sql(the highest current migration). Then manually delete all rows fromschema_migrations, assert returns""with nil error.
internal/doctor/doctor_test.go (update):
- Update
TestRunAllChecksWithNoCA: setORCA_HOMEto temp dir (no CA). Expect: cert.ca FAIL, cert.server FAIL, cert.expiry FAIL, cert.fingerprint FAIL, db WARN (fresh db, no migrations — actuallystore.Openruns 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 viastore.Openin temp, rundoctor.DB().Run(ctx), expect PASS and message contains "0005". - New
TestDBCheck_Corrupt: open db, manuallydb.Exec("DROP TABLE jobs")to introduce inconsistency, run integrity_check — butintegrity_checkmostly detects corruption, not missing tables. More reliable: write garbage to the db file via raw file write, then open —store.Openmay 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, rundoctor.Network().Run(ctx), expect WARN. - New
TestNetworkCheck_PeerReachable: this is an integration test — bootstrap a CA (security.CAInit), generate+sign a server cert with SANlocalhost, start anhttptest.NewUnstartedServerwithts.TLS = serverTLSandClientAuth = RequireAndVerifyClientCert(mirrorsecurity/integration_test.go:88-108), generate+sign a client cert, insert a node row withAddress = ts.Listener.Addr().String()andName = "localhost", setORCA_HOMEto the temp dir holding the CA + client cert, rundoctor.Network().Run(ctx), expect PASS. This reuses the proven pattern fromTestEndToEndMTLS. - New
TestNetworkCheck_PeerUnreachable: insert a node withAddress = "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 --jsonemits 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= nodeName(SAN convention). - D-039 (0.78):
dbPathrelocation tocertpaths— territory overlap.
These three are escalated through the normal decision flow (DecisionEngine) per the researcher protocol, NOT flagged for human validation.