Files
orca/internal/doctor/doctor.go
T
Jon Chery df58bc25a3 docs(milestone): complete scheduling-streaming (v0.3)
---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.
2026-08-01 20:06:47 +00:00

319 lines
9.1 KiB
Go

// 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)
}