Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 44e2cb1303 | |||
| 38220192bb | |||
| ba5ffd76f9 | |||
| 9b308c79f4 | |||
| a7bb00d935 | |||
| b4d9409e4d | |||
| efdbd2a61d | |||
| 5755f12053 | |||
| 5dba3cef80 | |||
| fc6a6c07e2 |
@@ -1,10 +1,9 @@
|
|||||||
{
|
{
|
||||||
"phase": 3,
|
"phase": 1,
|
||||||
"stage": "complete",
|
"stage": "verify",
|
||||||
"milestone": "v0.3",
|
"milestone": "v0.3",
|
||||||
"milestone_slug": "scheduling-streaming",
|
"milestone_slug": "scheduling-streaming",
|
||||||
"phase_role": "final",
|
"phase_role": "execution",
|
||||||
"attempts": 0,
|
"attempts": 0,
|
||||||
"updated_at": "2026-08-01T00:30:00Z",
|
"updated_at": "2026-08-01T00:10:00Z"
|
||||||
"milestone_complete": true
|
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@ earlier versions of this file.
|
|||||||
| REQ-019 | Cobra CLI framework | High | v0.1 P01 | **Complete** |
|
| REQ-019 | Cobra CLI framework | High | v0.1 P01 | **Complete** |
|
||||||
| REQ-020 | HCL parser integration (`hashicorp/hcl`) | Medium | v0.1 P03 | **Complete** |
|
| REQ-020 | HCL parser integration (`hashicorp/hcl`) | Medium | v0.1 P03 | **Complete** |
|
||||||
| REQ-021 | `os/exec` with `WaitDelay` (Go 1.25+) | Medium | v0.1 P03 | **Complete** |
|
| REQ-021 | `os/exec` with `WaitDelay` (Go 1.25+) | Medium | v0.1 P03 | **Complete** |
|
||||||
| REQ-022 | `iter.Seq` for streaming job lists (Go 1.25+) | Low | **v0.3 P01** | **Complete** (v0.3 P01 shipped v0.3.1) |
|
| REQ-022 | `iter.Seq` for streaming job lists (Go 1.25+) | Low | **v0.3 P01** | Pending (v0.3 P01) |
|
||||||
| REQ-023 | Self-signed mTLS cert generation | Medium | **v0.2 P01** | **Complete** (P01 shipped v0.2.1) |
|
| REQ-023 | Self-signed mTLS cert generation | Medium | **v0.2 P01** | **Complete** (P01 shipped v0.2.1) |
|
||||||
| REQ-024 | `Makefile` with standard targets | High | v0.1 P01 | **Complete** |
|
| REQ-024 | `Makefile` with standard targets | High | v0.1 P01 | **Complete** |
|
||||||
| REQ-025 | Bounded cert rotation history: retain last N=3 server certs per node for rollback | Medium | **v0.2 P01** | **Complete** (P01 shipped v0.2.1) |
|
| REQ-025 | Bounded cert rotation history: retain last N=3 server certs per node for rollback | Medium | **v0.2 P01** | **Complete** (P01 shipped v0.2.1) |
|
||||||
@@ -37,9 +37,9 @@ earlier versions of this file.
|
|||||||
| REQ-027 | `govulncheck` runs in offline mode in CI (no `vuln.go.dev` calls; pre-mirrored DB or `-format json` + `jq` gate) | High | v0.2 P03 | **Complete** (P10 shipped v0.2.3) |
|
| REQ-027 | `govulncheck` runs in offline mode in CI (no `vuln.go.dev` calls; pre-mirrored DB or `-format json` + `jq` gate) | High | v0.2 P03 | **Complete** (P10 shipped v0.2.3) |
|
||||||
| REQ-028 | HCL/YAML schema for `NodeCapacity` declaration (`orca node join` flag and/or `~/.orca/node.hcl`) | High | v0.2 P02 | **Complete** (P09 shipped v0.2.2; `orca node capacity` CLI) |
|
| REQ-028 | HCL/YAML schema for `NodeCapacity` declaration (`orca node join` flag and/or `~/.orca/node.hcl`) | High | v0.2 P02 | **Complete** (P09 shipped v0.2.2; `orca node capacity` CLI) |
|
||||||
| REQ-029 | `gitleaks` baseline file committed to repo to suppress pre-existing `.env` SHA-1 leak in git history | Medium | v0.2 P03 | **Complete** (P10 shipped v0.2.3) |
|
| REQ-029 | `gitleaks` baseline file committed to repo to suppress pre-existing `.env` SHA-1 leak in git history | Medium | v0.2 P03 | **Complete** (P10 shipped v0.2.3) |
|
||||||
| REQ-030 | `--watch` output format mode: table (default) vs streaming one-line JSON per event | Low | **v0.3 P01** | **Complete** (v0.3 P01 shipped v0.3.1) |
|
| REQ-030 | `--watch` output format mode: table (default) vs streaming one-line JSON per event | Low | **v0.3 P01** | Pending (v0.3 P01) |
|
||||||
| REQ-031 | `go test -race` enabled in CI for all v0.2 packages | High | v0.2 P01–P04 | **Complete** (P10; `.coreci.yml` test pipeline runs `-race`) |
|
| REQ-031 | `go test -race` enabled in CI for all v0.2 packages | High | v0.2 P01–P04 | **Complete** (P10; `.coreci.yml` test pipeline runs `-race`) |
|
||||||
| REQ-032 | `orca doctor` subcommand for diagnostics (CA/cert health, db integrity, peer reachability) | Medium | **v0.2 P01 / v0.3 P02** | **Complete** (cert checks P01 v0.2.1; network + db P02 v0.3.2) |
|
| REQ-032 | `orca doctor` subcommand for diagnostics (CA/cert health, db integrity, peer reachability) | Medium | **v0.2 P01 / v0.3 P02** | **Partial** — cert checks complete (P01); network/db are stubs, full impl in v0.3 P02 |
|
||||||
| REQ-033 | Cert file mode enforcement: 0600 for keys, 0644 for certs (refuses to start on violation) | High | **v0.2 P01** | **Complete** (P01 shipped v0.2.1) |
|
| REQ-033 | Cert file mode enforcement: 0600 for keys, 0644 for certs (refuses to start on violation) | High | **v0.2 P01** | **Complete** (P01 shipped v0.2.1) |
|
||||||
| REQ-034 | Cert proactive rotation alarm: structured slog WARN 30 days before `not_after` | Medium | **v0.2 P01** | **Complete** (P01 shipped v0.2.1) |
|
| REQ-034 | Cert proactive rotation alarm: structured slog WARN 30 days before `not_after` | Medium | **v0.2 P01** | **Complete** (P01 shipped v0.2.1) |
|
||||||
| REQ-035 | `orca cert show` redacts private key material from default and `--json` output | High | **v0.2 P01** | **Complete** (P01 shipped v0.2.1) |
|
| REQ-035 | `orca cert show` redacts private key material from default and `--json` output | High | **v0.2 P01** | **Complete** (P01 shipped v0.2.1) |
|
||||||
@@ -71,12 +71,13 @@ deferred to v0.3.
|
|||||||
|
|
||||||
## v0.3 Milestone Summary
|
## v0.3 Milestone Summary
|
||||||
|
|
||||||
**Status: Complete** — P01 (iter.Seq streaming, v0.3.1) and P02 (doctor
|
**Status: In Progress** — 2 execution phases planned (P01 iter.Seq
|
||||||
network+db, v0.3.2) both shipped. REQ-022, REQ-030, REQ-032 all complete.
|
streaming, P02 doctor completion). Covers REQ-022, REQ-030, REQ-032
|
||||||
Re-init SPECIFY audit confirmed all other v0.2-deferred REQs (014, 027,
|
(completion). Re-init SPECIFY audit confirmed all other v0.2-deferred
|
||||||
028, 029, 031, 037, 039, 040) already shipped in P08-P10.
|
REQs (014, 027, 028, 029, 031, 037, 039, 040) already shipped in
|
||||||
|
P08-P10.
|
||||||
|
|
||||||
## Deferred to v0.4
|
## Deferred to v0.3
|
||||||
|
|
||||||
- pprof endpoint on `orca daemon` (idea I-308, 0.70 confidence): deferred
|
- pprof endpoint on `orca daemon` (idea I-308, 0.70 confidence): deferred
|
||||||
to keep v0.2 lean; revisit in v0.3 once P02's dispatcher is stable.
|
to keep v0.2 lean; revisit in v0.3 once P02's dispatcher is stable.
|
||||||
|
|||||||
+9
-9
@@ -20,7 +20,7 @@
|
|||||||
- `iter.Seq` streaming job lists (REQ-022)
|
- `iter.Seq` streaming job lists (REQ-022)
|
||||||
- Frontend / devops personas (no web UI; CoreCI handles release)
|
- Frontend / devops personas (no web UI; CoreCI handles release)
|
||||||
|
|
||||||
## Milestone v0.2: Networking, Observability, Security Hardening — **COMPLETE (merged to main via v0.3)**
|
## Milestone v0.2: Networking, Observability, Security Hardening — **FUNCTIONALLY COMPLETE (pending merge to main)**
|
||||||
|
|
||||||
Scope: extend v0.1 with secure cross-node transport, multi-node scheduling,
|
Scope: extend v0.1 with secure cross-node transport, multi-node scheduling,
|
||||||
richer CI security scanning, and streaming I/O.
|
richer CI security scanning, and streaming I/O.
|
||||||
@@ -28,25 +28,25 @@ richer CI security scanning, and streaming I/O.
|
|||||||
- [x] Phase 8: mTLS handshake + internal CA with CSR join (Wave 1) — shipped v0.2.1
|
- [x] Phase 8: mTLS handshake + internal CA with CSR join (Wave 1) — shipped v0.2.1
|
||||||
- [x] Phase 9: Multi-node scheduling & job dispatch (Wave 1) — shipped v0.2.2
|
- [x] Phase 9: Multi-node scheduling & job dispatch (Wave 1) — shipped v0.2.2
|
||||||
- [x] Phase 10: `gosec` + `govulncheck` + gitleaks in CI (Wave 2) — shipped v0.2.3
|
- [x] Phase 10: `gosec` + `govulncheck` + gitleaks in CI (Wave 2) — shipped v0.2.3
|
||||||
- [x] Phase 11: `iter.Seq` streaming job/node lists (Wave 2) — **completed in v0.3 P01** (shipped v0.3.1)
|
- [ ] Phase 11: `iter.Seq` streaming job/node lists (Wave 2) — **deferred to v0.3 P01**
|
||||||
|
|
||||||
**Milestone tag**: `v0.4.0` (shipped — v0.2 work merged to main via v0.3 milestone).
|
**Milestone tag**: `v0.3.0` (next-minor per feature-milestone promotion rule) — pending merge to main.
|
||||||
|
|
||||||
Per-phase tags: `v0.2.1` (P01), `v0.2.2` (P02), `v0.2.3` (P03) — all shipped.
|
Per-phase tags: `v0.2.1` (P01), `v0.2.2` (P02), `v0.2.3` (P03) — all shipped.
|
||||||
|
|
||||||
## Milestone v0.3: Scheduling & Streaming Completion — **COMPLETE**
|
## Milestone v0.3: Scheduling & Streaming Completion — **IN PROGRESS**
|
||||||
|
|
||||||
Scope: complete the two work items deferred from v0.2 that were not
|
Scope: complete the two work items deferred from v0.2 that were not
|
||||||
already shipped in P08-P10. A re-init SPECIFY codebase audit confirmed
|
already shipped in P08-P10. A re-init SPECIFY codebase audit confirmed
|
||||||
that REQ-014/027/028/029/031/037/039/040 all shipped in P08-P10 despite
|
that REQ-014/027/028/029/031/037/039/040 all shipped in P08-P10 despite
|
||||||
stale REQUIREMENTS.md marking them Pending. The remaining work is lean:
|
stale REQUIREMENTS.md marking them Pending. The remaining work is lean:
|
||||||
|
|
||||||
- [x] Phase 0: Pre-execution (specify → clarify → research → plan → grill) — shipped v0.3.0
|
- [ ] Phase 0: Pre-execution (specify → clarify → research → plan)
|
||||||
- [x] Phase 1: `iter.Seq` streaming for `--watch` flags (REQ-022, REQ-030) — shipped v0.3.1
|
- [ ] Phase 1: `iter.Seq` streaming for `--watch` flags (REQ-022, REQ-030)
|
||||||
- [x] Phase 2: `orca doctor` network + db full implementation (REQ-032 completion) — shipped v0.3.2
|
- [ ] Phase 2: `orca doctor` network + db full implementation (REQ-032 completion)
|
||||||
- [x] Phase 3: Final review + ship + audit (milestone release) — shipped v0.3.3
|
- [ ] Phase 3: Final review + ship + audit (milestone release)
|
||||||
|
|
||||||
**Milestone tag**: `v0.4.0` (next-minor per feature-milestone promotion rule).
|
**Target milestone tag**: `v0.4.0` (next-minor per feature-milestone promotion rule).
|
||||||
|
|
||||||
Per-phase tags: `v0.3.0` (P0), `v0.3.1` (P01), `v0.3.2` (P02), `v0.3.3` (P03 final = milestone release).
|
Per-phase tags: `v0.3.0` (P0), `v0.3.1` (P01), `v0.3.2` (P02), `v0.3.3` (P03 final = milestone release).
|
||||||
Per `.ciagent/RELEASE_POLICY.md`, every phase tag produces a Gitea release.
|
Per `.ciagent/RELEASE_POLICY.md`, every phase tag produces a Gitea release.
|
||||||
|
|||||||
@@ -36,13 +36,3 @@ func ServerCertPath() string { return filepath.Join(Dir(), "server.crt") }
|
|||||||
|
|
||||||
// ServerKeyPath returns the path to server.key.
|
// ServerKeyPath returns the path to server.key.
|
||||||
func ServerKeyPath() string { return filepath.Join(Dir(), "server.key") }
|
func ServerKeyPath() string { return filepath.Join(Dir(), "server.key") }
|
||||||
|
|
||||||
// DBPath returns the path to the orca SQLite database. Honors $ORCA_DB
|
|
||||||
// for testability and explicit override; otherwise defaults to
|
|
||||||
// ~/.orca/orca.db under the same Dir() as the cert files.
|
|
||||||
func DBPath() string {
|
|
||||||
if p := os.Getenv("ORCA_DB"); p != "" {
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
return filepath.Join(Dir(), "orca.db")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ var doctorNetworkCmd = &cobra.Command{
|
|||||||
Use: "network",
|
Use: "network",
|
||||||
Short: "Run the network self-check (P02 impl)",
|
Short: "Run the network self-check (P02 impl)",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c := doctor.Network()
|
c := doctor.NetworkStub()
|
||||||
r, msg := c.Run(cmd.Context())
|
r, msg := c.Run(cmd.Context())
|
||||||
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-5s %s\n", c.Name, r, msg)
|
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-5s %s\n", c.Name, r, msg)
|
||||||
return nil
|
return nil
|
||||||
@@ -62,7 +62,7 @@ var doctorDBCmd = &cobra.Command{
|
|||||||
Use: "db",
|
Use: "db",
|
||||||
Short: "Run the database self-check (P02 impl)",
|
Short: "Run the database self-check (P02 impl)",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c := doctor.DB()
|
c := doctor.DBStub()
|
||||||
r, msg := c.Run(cmd.Context())
|
r, msg := c.Run(cmd.Context())
|
||||||
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-5s %s\n", c.Name, r, msg)
|
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-5s %s\n", c.Name, r, msg)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+10
-1
@@ -8,6 +8,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -21,8 +22,16 @@ import (
|
|||||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func dbPath() string {
|
||||||
|
if p := os.Getenv("ORCA_DB"); p != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
return filepath.Join(home, ".orca", "orca.db")
|
||||||
|
}
|
||||||
|
|
||||||
func openDB() (*sql.DB, func() error, error) {
|
func openDB() (*sql.DB, func() error, error) {
|
||||||
db, err := store.Open(certpaths.DBPath())
|
db, err := store.Open(dbPath())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-116
@@ -19,17 +19,12 @@ import (
|
|||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
||||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/security"
|
"git.cloudinit.dev/coreci/orca/internal/security"
|
||||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/transport"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Result is the outcome of a single check.
|
// Result is the outcome of a single check.
|
||||||
@@ -68,8 +63,8 @@ func All() []Check {
|
|||||||
CertServer(),
|
CertServer(),
|
||||||
CertExpiry(),
|
CertExpiry(),
|
||||||
CertFingerprint(),
|
CertFingerprint(),
|
||||||
Network(),
|
NetworkStub(),
|
||||||
DB(),
|
DBStub(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,122 +177,26 @@ func CertFingerprint() Check {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DB checks SQLite integrity and migration version (REQ-032 completion).
|
// NetworkStub is a stub for the network check; full impl in P02.
|
||||||
func DB() Check {
|
func NetworkStub() Check {
|
||||||
return Check{
|
|
||||||
Name: "db",
|
|
||||||
Description: "SQLite integrity_check + migration version",
|
|
||||||
Run: func(ctx context.Context) (Result, string) {
|
|
||||||
path := certpaths.DBPath()
|
|
||||||
db, err := store.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return ResultFail, fmt.Sprintf("open db: %v", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
var integrity string
|
|
||||||
if err := db.QueryRowContext(ctx, "PRAGMA integrity_check").Scan(&integrity); err != nil {
|
|
||||||
return ResultFail, fmt.Sprintf("integrity_check: %v", err)
|
|
||||||
}
|
|
||||||
if !strings.EqualFold(integrity, "ok") {
|
|
||||||
return ResultFail, fmt.Sprintf("integrity_check: %s", integrity)
|
|
||||||
}
|
|
||||||
|
|
||||||
version, err := store.MigrationVersion(ctx, db)
|
|
||||||
if err != nil {
|
|
||||||
return ResultFail, fmt.Sprintf("migration version: %v", err)
|
|
||||||
}
|
|
||||||
if version == "" {
|
|
||||||
return ResultWarn, "integrity OK but no migrations applied (fresh db)"
|
|
||||||
}
|
|
||||||
return ResultPass, fmt.Sprintf("integrity OK, migrations up to %s", version)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Network probes peer reachability via mTLS /healthz (REQ-032 completion).
|
|
||||||
// Peers are sourced from the persisted nodes table (not the in-memory
|
|
||||||
// PeerRegistry, which is empty at CLI time). Zero peers → WARN (single-node
|
|
||||||
// is legitimate). Any peer unreachable → FAIL (D-038).
|
|
||||||
func Network() Check {
|
|
||||||
return Check{
|
return Check{
|
||||||
Name: "network",
|
Name: "network",
|
||||||
Description: "peer reachability via mTLS /healthz probe",
|
Description: "TCP reachability + mTLS handshake (full impl in P02)",
|
||||||
Run: func(ctx context.Context) (Result, string) {
|
Run: func(_ context.Context) (Result, string) {
|
||||||
caPath := certpaths.CACertPath()
|
return ResultWarn, "network check is a stub in P01; full impl in P02"
|
||||||
certPath := certpaths.ServerCertPath()
|
|
||||||
keyPath := certpaths.ServerKeyPath()
|
|
||||||
|
|
||||||
// Check that cert files exist before attempting probes.
|
|
||||||
if _, err := os.Stat(caPath); err != nil {
|
|
||||||
return ResultFail, fmt.Sprintf("CA cert missing: %v (run `orca cert init`)", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
path := certpaths.DBPath()
|
|
||||||
db, err := store.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return ResultFail, fmt.Sprintf("open db: %v", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
nodes, err := store.NewNodeRepo(db).List(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return ResultFail, fmt.Sprintf("list nodes: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
live := make([]*model.Node, 0, len(nodes))
|
|
||||||
for _, n := range nodes {
|
|
||||||
if n.State != model.NodeStateLeft {
|
|
||||||
live = append(live, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(live) == 0 {
|
|
||||||
return ResultWarn, "no peers registered (single-node?)"
|
|
||||||
}
|
|
||||||
|
|
||||||
var lines []string
|
|
||||||
anyFail := false
|
|
||||||
for _, n := range live {
|
|
||||||
probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
||||||
err := probeHealthz(probeCtx, caPath, certPath, keyPath, n.Name, n.Address)
|
|
||||||
cancel()
|
|
||||||
if err != nil {
|
|
||||||
anyFail = true
|
|
||||||
lines = append(lines, fmt.Sprintf(" ✗ %s (%s): %v", n.Name, n.Address, err))
|
|
||||||
} else {
|
|
||||||
lines = append(lines, fmt.Sprintf(" ✓ %s (%s)", n.Name, n.Address))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result := ResultPass
|
|
||||||
if anyFail {
|
|
||||||
result = ResultFail
|
|
||||||
}
|
|
||||||
return result, strings.Join(lines, "\n")
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// probeHealthz opens an mTLS connection to the peer and GETs /healthz.
|
// DBStub is a stub for the database check; full impl in P02.
|
||||||
func probeHealthz(ctx context.Context, caPath, certPath, keyPath, serverName, addr string) error {
|
func DBStub() Check {
|
||||||
client, err := transport.NewMTLSClient(caPath, serverName, certPath, keyPath)
|
return Check{
|
||||||
if err != nil {
|
Name: "db",
|
||||||
return fmt.Errorf("mTLS client: %w", err)
|
Description: "SQLite open + migration apply (full impl in P02)",
|
||||||
|
Run: func(_ context.Context) (Result, string) {
|
||||||
|
return ResultWarn, "db check is a stub in P01; full impl in P02"
|
||||||
|
},
|
||||||
}
|
}
|
||||||
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
|
// loadCert reads a PEM cert from path and parses the first CERTIFICATE
|
||||||
|
|||||||
+35
-190
@@ -2,74 +2,60 @@ package doctor
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/security"
|
"git.cloudinit.dev/coreci/orca/internal/security"
|
||||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestRunAllChecksWithNoCA runs the full battery in a clean temp dir.
|
// TestRunAllChecksWithNoCA runs the full battery in a clean temp dir
|
||||||
// With the P02 real checks (no stubs): cert checks FAIL (no CA),
|
// and expects all checks to FAIL (no CA, no server cert) except the
|
||||||
// db check PASS (store.Open runs migrations), network check WARN
|
// two stubs which return WARN.
|
||||||
// (no peers).
|
|
||||||
func TestRunAllChecksWithNoCA(t *testing.T) {
|
func TestRunAllChecksWithNoCA(t *testing.T) {
|
||||||
dir := t.TempDir()
|
// Isolated home so we don't touch the real ~/.orca.
|
||||||
t.Setenv("ORCA_HOME", dir)
|
t.Setenv("ORCA_HOME", t.TempDir())
|
||||||
t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))
|
|
||||||
|
|
||||||
rep := Run(context.Background())
|
rep := Run(context.Background())
|
||||||
if len(rep.Checks) == 0 {
|
if len(rep.Checks) == 0 {
|
||||||
t.Fatal("expected checks, got 0")
|
t.Fatal("expected checks, got 0")
|
||||||
}
|
}
|
||||||
|
hasFail := false
|
||||||
byName := make(map[string]CheckResult, len(rep.Checks))
|
hasWarn := false
|
||||||
for _, c := range rep.Checks {
|
for _, c := range rep.Checks {
|
||||||
byName[c.Name] = c
|
if c.Result == ResultFail {
|
||||||
}
|
hasFail = true
|
||||||
|
|
||||||
// Cert checks: no CA → FAIL.
|
|
||||||
for _, name := range []string{"cert.ca", "cert.server", "cert.expiry", "cert.fingerprint"} {
|
|
||||||
c, ok := byName[name]
|
|
||||||
if !ok {
|
|
||||||
t.Errorf("missing check %s", name)
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
if c.Result != ResultFail {
|
if c.Result == ResultWarn {
|
||||||
t.Errorf("%s: got %s, want FAIL — %s", name, c.Result, c.Message)
|
hasWarn = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !hasFail {
|
||||||
// DB check: store.Open runs migrations → PASS.
|
t.Error("expected at least one FAIL (no CA installed)")
|
||||||
if c, ok := byName["db"]; ok {
|
}
|
||||||
if c.Result != ResultPass {
|
if !hasWarn {
|
||||||
t.Errorf("db: got %s, want PASS — %s", c.Result, c.Message)
|
t.Error("expected at least one WARN (stubs in P01)")
|
||||||
}
|
|
||||||
} else {
|
|
||||||
t.Error("missing check db")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Network check: no CA → FAIL (can't build mTLS client without CA).
|
// Render the report — basic shape check.
|
||||||
if c, ok := byName["network"]; ok {
|
out := rep.Print()
|
||||||
if c.Result != ResultFail {
|
if !strings.Contains(out, "PASS") {
|
||||||
t.Errorf("network: got %s, want FAIL (no CA cert) — %s", c.Result, c.Message)
|
t.Errorf("expected PASS in output, got: %s", out)
|
||||||
}
|
}
|
||||||
} else {
|
if !strings.Contains(out, "WARN") {
|
||||||
t.Error("missing check network")
|
t.Errorf("expected WARN in output, got: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "FAIL") {
|
||||||
|
t.Errorf("expected FAIL in output, got: %s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRunWithCAAndServerCert covers the happy path: CA + server cert
|
// TestRunWithCAAndServerCert covers the happy path: CA + server cert
|
||||||
// installed → all cert checks PASS, db PASS, network WARN (no peers).
|
// installed → all cert checks PASS.
|
||||||
func TestRunWithCAAndServerCert(t *testing.T) {
|
func TestRunWithCAAndServerCert(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("ORCA_HOME", dir)
|
t.Setenv("ORCA_HOME", dir)
|
||||||
t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))
|
|
||||||
|
|
||||||
|
// Bootstrap CA.
|
||||||
if _, err := security.CAInit(dir, "test-ca"); err != nil {
|
if _, err := security.CAInit(dir, "test-ca"); err != nil {
|
||||||
t.Fatalf("CAInit: %v", err)
|
t.Fatalf("CAInit: %v", err)
|
||||||
}
|
}
|
||||||
@@ -77,6 +63,7 @@ func TestRunWithCAAndServerCert(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadCA: %v", err)
|
t.Fatalf("LoadCA: %v", err)
|
||||||
}
|
}
|
||||||
|
// Generate + sign server cert.
|
||||||
keyPEM, csrPEM, err := security.GenerateCSR("test-server", []string{"localhost", "127.0.0.1"})
|
keyPEM, csrPEM, err := security.GenerateCSR("test-server", []string{"localhost", "127.0.0.1"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GenerateCSR: %v", err)
|
t.Fatalf("GenerateCSR: %v", err)
|
||||||
@@ -93,155 +80,13 @@ func TestRunWithCAAndServerCert(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rep := Run(context.Background())
|
rep := Run(context.Background())
|
||||||
byName := make(map[string]CheckResult, len(rep.Checks))
|
// The cert-related checks should be PASS; the network/db stubs WARN.
|
||||||
for _, c := range rep.Checks {
|
for _, c := range rep.Checks {
|
||||||
byName[c.Name] = c
|
switch c.Name {
|
||||||
}
|
case "cert.ca", "cert.server", "cert.expiry", "cert.fingerprint":
|
||||||
|
if c.Result != ResultPass {
|
||||||
for _, name := range []string{"cert.ca", "cert.server", "cert.expiry", "cert.fingerprint", "db"} {
|
t.Errorf("%s: got %s, want PASS — %s", c.Name, c.Result, c.Message)
|
||||||
c, ok := byName[name]
|
}
|
||||||
if !ok {
|
|
||||||
t.Errorf("missing check %s", name)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if c.Result != ResultPass {
|
|
||||||
t.Errorf("%s: got %s, want PASS — %s", name, c.Result, c.Message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if c, ok := byName["network"]; ok {
|
|
||||||
if c.Result != ResultWarn {
|
|
||||||
t.Errorf("network: got %s, want WARN (no peers) — %s", c.Result, c.Message)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDBCheck_IntegrityOK verifies the DB check passes on a fresh
|
|
||||||
// database with migrations applied.
|
|
||||||
func TestDBCheck_IntegrityOK(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
t.Setenv("ORCA_HOME", dir)
|
|
||||||
t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))
|
|
||||||
|
|
||||||
db, err := store.Open(filepath.Join(dir, "orca.db"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open db: %v", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
c := DB()
|
|
||||||
r, msg := c.Run(context.Background())
|
|
||||||
if r != ResultPass {
|
|
||||||
t.Errorf("DB check: got %s, want PASS — %s", r, msg)
|
|
||||||
}
|
|
||||||
if !strings.Contains(msg, "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")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ func TestJobRepoWatch_YieldsSnapshots(t *testing.T) {
|
|||||||
defer close(done)
|
defer close(done)
|
||||||
for snap := range repo.Watch(ctx) {
|
for snap := range repo.Watch(ctx) {
|
||||||
snapshots = append(snapshots, snap)
|
snapshots = append(snapshots, snap)
|
||||||
if len(snapshots) >= 40 {
|
if len(snapshots) >= 15 {
|
||||||
cancel()
|
cancel()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,21 +12,6 @@ import (
|
|||||||
//go:embed migrations/*.sql
|
//go:embed migrations/*.sql
|
||||||
var migrationsFS embed.FS
|
var migrationsFS embed.FS
|
||||||
|
|
||||||
// MigrationVersion returns the name of the highest applied migration
|
|
||||||
// (e.g. "0005_node_capacity.sql"). Returns ("", nil) if no migrations
|
|
||||||
// have been applied (fresh or empty database).
|
|
||||||
func MigrationVersion(ctx context.Context, db *sql.DB) (string, error) {
|
|
||||||
var name string
|
|
||||||
err := db.QueryRowContext(ctx, `SELECT name FROM schema_migrations ORDER BY name DESC LIMIT 1`).Scan(&name)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("query migration version: %w", err)
|
|
||||||
}
|
|
||||||
return name, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func migrate(db *sql.DB) error {
|
func migrate(db *sql.DB) error {
|
||||||
entries, err := migrationsFS.ReadDir("migrations")
|
entries, err := migrationsFS.ReadDir("migrations")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestMigrationVersion(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
db, err := Open(filepath.Join(dir, "test.db"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open db: %v", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
version, err := MigrationVersion(ctx, db)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("migration version: %v", err)
|
|
||||||
}
|
|
||||||
if version != "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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -131,7 +131,7 @@ func TestNodeRepoWatch_YieldsSnapshots(t *testing.T) {
|
|||||||
defer close(done)
|
defer close(done)
|
||||||
for snap := range repo.Watch(ctx) {
|
for snap := range repo.Watch(ctx) {
|
||||||
snapshots = append(snapshots, snap)
|
snapshots = append(snapshots, snap)
|
||||||
if len(snapshots) >= 40 {
|
if len(snapshots) >= 15 {
|
||||||
cancel()
|
cancel()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user