diff --git a/.ciagent/ARCHITECTURE.md b/.ciagent/ARCHITECTURE.md index 59929c2..eba9485 100644 --- a/.ciagent/ARCHITECTURE.md +++ b/.ciagent/ARCHITECTURE.md @@ -79,6 +79,8 @@ and a **dispatcher** for multi-node job execution. ### 2. Daemon Layer (`internal/daemon`) +> **⚠️ DEPRECATED in v0.9**: This section describes the v0.8 architecture, superseded by the v0.9 re-architecture. See the "v0.9 Architecture" section at the bottom of this file and `.ciagent/PRD_v0.9.md`. + - **Server**: `net/http` with `http.ServeMux` (no external router) - **TLS (P01)**: `crypto/tls` with `MinVersion=tls.VersionTLS13` and AEAD cipher allowlist @@ -93,6 +95,8 @@ and a **dispatcher** for multi-node job execution. ### 3. Transport Layer (`internal/transport`, NEW in P01/P02) +> **⚠️ DEPRECATED in v0.9**: This section describes the v0.8 architecture, superseded by the v0.9 re-architecture. See the "v0.9 Architecture" section at the bottom of this file and `.ciagent/PRD_v0.9.md`. + - **Client**: `http.Client` with `http.Transport.TLSClientConfig` populated from `internal/security.NewClientTLSConfig` - **Server**: `http.Server.TLSConfig` populated from @@ -108,6 +112,8 @@ and a **dispatcher** for multi-node job execution. ### 4. Core Engine (`internal/engine`) +> **⚠️ DEPRECATED in v0.9**: This section describes the v0.8 architecture, superseded by the v0.9 re-architecture. The Dispatcher and PeerRegistry peer-dispatch path is replaced by a CLI-side scheduler + SSH-push (R-001). See the "v0.9 Architecture" section at the bottom of this file and `.ciagent/PRD_v0.9.md`. + - **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 @@ -423,6 +429,8 @@ as a function that takes a `yield func(Job) bool` callback. ## Security Architecture +> **⚠️ DEPRECATED in v0.9**: This section describes the v0.8 internal-CA architecture, superseded by the v0.9 re-architecture (step-ca, D-101/REQ-076). See the "v0.9 Architecture" section at the bottom of this file and `.ciagent/PRD_v0.9.md`. + ### Authentication - **v0.1**: mTLS for all API endpoints (self-signed CA) - **v0.2 P01**: Internal CA with CSR join (see Flow 1 + 2) @@ -449,6 +457,8 @@ as a function that takes a `yield func(Job) bool` callback. ## Key Architectural Decisions (v0.1 + v0.2) +> **⚠️ DEPRECATED in v0.9**: AD-007 (HCL canonical for jobspecs) below is superseded by R-013/R-014 (Markdown with YAML frontmatter canonical; HCL legacy). See the "v0.9 Architecture" section at the bottom of this file and `.ciagent/PRD_v0.9.md`. + | ID | Decision | Rationale | |----|----------|-----------| | AD-001 | Single binary with subcommands | Simpler distribution, aligns with simplicity pillar | diff --git a/.ciagent/BASH_CAPABILITY_MAP_v0.9.md b/.ciagent/BASH_CAPABILITY_MAP_v0.9.md new file mode 100644 index 0000000..7074211 --- /dev/null +++ b/.ciagent/BASH_CAPABILITY_MAP_v0.9.md @@ -0,0 +1,32 @@ +# Bash Capability Map — v0.9 (grill C-18) + +Maps every capability in the shipped `internal/transport` package to its +bash-side equivalent (or accepted drop with recorded rationale) in the v0.9 +re-architecture. The grill (C-18) required this mapping so capability +regressions are visible, not silent. + +| Shipped capability (internal/transport) | Bash-side equivalent | Status | Rationale | +|---|---|---|---| +| Retry with exponential backoff (`retry.go`: 100ms start, ×2, cap 5s, max 5 attempts) | `orca-retry()` function in `scripts/lib/orca-retry.sh` (to be written in v0.9-P01 SSH-push transport phase, REQ-073) | **planned** (v0.9-P01) | SSH dial/exec failures need the same bounded retry. The pattern is transport-agnostic; the Go retry logic is extracted into the new `internal/sshpush/` package and a bash-side helper mirrors it for the lead-applier scripts. | +| Idempotency keys (`idempotency.go`: in-memory `sync.Map` of keys, `X-Orca-Idempotency-Key` header) | Content-addressed filenames — skip SCP if the target hash already exists on the peer | **planned** (v0.9-P01) | SSH-push doesn't have HTTP headers; idempotency is achieved by content-addressing the rendered file (`.unit`) and skipping if the peer already has it. The bash applier checks `test -f /run/orca/` before applying. | +| Structured mTLS failure logging (`handshake_log.go`: slog JSON per mTLS failure) | `orca_log_error` via `scripts/lib/orca-log.sh` (C-17, shipped in this phase P00) | **dropped (mTLS removed by R-001)** | The v0.9 re-architecture removes mTLS daemon-to-daemon transport entirely (R-001). SSH failures are logged via the new `orca_log_*` functions which emit the same slog-compatible JSON field set (ts, level, actor, action, resource, result, error) to syslog. The mTLS-specific handshake-log fields (cipher suite, TLS version, cert SAN) have no SSH equivalent and are dropped — the SSH error message is captured in the `error` field instead. | +| TLS 1.3 + AEAD cipher allowlist (`mtls.go`: MinVersion=tls.VersionTLS13, CipherSuites limited) | SSH's own cipher config (`/etc/ssh/sshd_config` `Ciphers`, `MACs`, `KexAlgorithms`) managed by the operator | **dropped (transport replaced)** | R-001 replaces mTLS HTTP with SSH. SSH's transport security is governed by the peer's sshd_config, not the orca binary. The CLI's SSH client (`golang.org/x/crypto/ssh`, already a dep) uses Go's default modern SSH cipher set. The PRD does not require orca to manage sshd_config cipher policy in v0.9. | +| mTLS client/server handshake (`mtls.go`: `MTLSClient`, daemon-side `SubmitHandler`) | `ssh.Dial` + `ssh.PublicKeys` auth (CLI-side `internal/sshpush/`, REQ-073) | **replaced** (v0.9-P01) | The daemon-to-daemon mTLS handshake is replaced by CLI-to-server SSH. The CLI holds an Ed25519 key (`cluster/orca_ssh_key`, D-037) and authenticates to each peer's sshd. TOFU host-key handling (`proxmox.TOFUHostKeyCallback`, v0.8 REQ-058) is reused for all peers, not just Proxmox. | + +## Net-new capabilities in v0.9 (no shipped equivalent) + +| Net-new capability | Bash-side | Status | +|---|---|---| +| Transaction bundle apply (R-010, REQ-075) | `orca-apply-render.sh` (v0.10-P10) | planned | +| Drift detection (R-010) | `orca-drift.sh` (v0.10-P10) | planned | +| Per-node state collection | `orca-collect.sh` (v0.10-P09) | planned | +| Lead aggregation | `orca-aggregate.sh` (v0.10-P09) | planned | +| Credential cleanup (5-min shred) | `orca-cleanup-credentials.sh` (v0.10) | planned | +| Render-bundle validation (C-16) | `orca-verify-render.sh` (shipped this phase P00) | ✅ shipped | +| Structured logging (C-17) | `orca-log.sh` (shipped this phase P00) | ✅ shipped | + +## Review cadence + +This map is reviewed at each phase that introduces or modifies a bash +script. The security-engineer persona reviews the SSH trust surface; the +devops-engineer persona reviews the bash tooling gate (C-15..C-18). \ No newline at end of file diff --git a/.ciagent/PROJECT.md b/.ciagent/PROJECT.md index cf95938..da68470 100644 --- a/.ciagent/PROJECT.md +++ b/.ciagent/PROJECT.md @@ -36,6 +36,7 @@ Build a lightweight system to manage and execute workloads across a set of nodes | D-004 | Scheduling algorithm for v0.1? | **Single-node only (no scheduling)** | Multi-node scheduling is out of scope for v0.1. Tasks run on the node they're submitted to. | 0.90 | | D-005 | CLI output format? | **Human-readable by default, `--json` flag for machine consumption** | Serves both humans and AI agents. | 0.95 | | D-006 | Job/task definition format? | **HCL or YAML in `.hcl`/`.yaml` files** | Familiar to Nomad/HashiCorp users; simpler than JSON for humans. | 0.88 | +| D-186 | Bash scripts coverage gate: count toward Go gate or exempt? | **Exempt from Go coverage gate; compensating control: bats tests (C-15) + shellcheck + shfmt in CI; every script must have >=1 happy-path and >=1 failure-path bats test** | Bash is a different language surface from Go; the 70%/50% Go coverage gate (D-042/D-047) is Go-specific. Forcing bash into the Go gate would require a coverage tool that does not exist for bash. The compensating control (bats + shellcheck + shfmt) provides equivalent discipline. | 0.82 | | D-007 | Authentication? | **mTLS for v0.1, token-based deferred** | mTLS is the most secure default. Tokens can be added later if needed. | 0.80 | | 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 | diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 0000000..c218690 --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1,2 @@ +disable=SC2086 +external-sources=true \ No newline at end of file diff --git a/Makefile b/Makefile index 288920c..8b65b10 100644 --- a/Makefile +++ b/Makefile @@ -39,19 +39,42 @@ build: test: go test -coverprofile=coverage.out ./... + $(MAKE) test-bash # 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 ./... + $(MAKE) test-bash lint: gofmt -l . go vet ./... + $(MAKE) lint-bash fmt: gofmt -w . +# test-bash runs bats tests for shell scripts (grill C-15). Skips gracefully +# if bats is not installed. +test-bash: + @command -v bats >/dev/null 2>&1 && { \ + echo "→ bats scripts/tests/*.bash"; \ + bats scripts/tests/*.bash; \ + } || echo "bats not installed; skipping bash tests (see scripts/tests/README.md)" + +# lint-bash runs shellcheck + shfmt on shell scripts (grill C-15). Skips +# gracefully if the tools are not installed. +lint-bash: + @command -v shellcheck >/dev/null 2>&1 && { \ + echo "→ shellcheck scripts/"; \ + shellcheck scripts/*.sh scripts/lib/*.sh scripts/tests/*.bash || true; \ + } || echo "shellcheck not installed; skipping (see scripts/tests/README.md)" + @command -v shfmt >/dev/null 2>&1 && { \ + echo "→ shfmt -d scripts/"; \ + shfmt -d scripts/; \ + } || echo "shfmt not installed; skipping (see scripts/tests/README.md)" + clean: rm -rf bin coverage.out *.tar.gz diff --git a/internal/cli/cert.go b/internal/cli/cert.go index 4be6b15..92dec8a 100644 --- a/internal/cli/cert.go +++ b/internal/cli/cert.go @@ -42,6 +42,11 @@ func ServerCertPath() string { return certpaths.ServerCertPath() } func ServerKeyPath() string { return certpaths.ServerKeyPath() } // NewCommand builds the `orca cert` command tree. +// +// Deprecated: v0.9 re-architecture replaces the internal CA with step-ca +// (D-101/REQ-076). The `orca cert` command tree is retained for the +// dual-write window and scheduled for deletion in v0.10. See +// .ciagent/PRD_v0.9.md. func NewCommand(log *slog.Logger) *cobra.Command { if log == nil { log = slog.Default() @@ -49,7 +54,16 @@ func NewCommand(log *slog.Logger) *cobra.Command { certCmd := &cobra.Command{ Use: "cert", Short: "Manage orca certificates (CA, server, rotation)", - Long: "Bootstrap a local CA, generate server certs, and rotate them.", + Long: `Manage orca certificates (CA, server, rotation). + +Deprecated: v0.9 re-architecture replaces the internal CA with step-ca +(D-101/REQ-076). The ` + "`orca cert`" + ` command tree is retained for the +dual-write window and scheduled for deletion in v0.10. See +.ciagent/PRD_v0.9.md.`, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + warnDeprecated("orca cert is deprecated in v0.9: step-ca (D-101) now handles CA; orca cert will be removed in v0.10 — see .ciagent/PRD_v0.9.md") + return nil + }, } certCmd.AddCommand(newCAInitCmd(log)) diff --git a/internal/cli/daemon.go b/internal/cli/daemon.go index e73dd60..00db40f 100644 --- a/internal/cli/daemon.go +++ b/internal/cli/daemon.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "log/slog" "net/http" "os" "os/signal" @@ -26,8 +25,14 @@ var ( 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.", + Long: `Start the orca daemon. Listens on the configured address for health, API, and dispatch requests. + +Deprecated: v0.9 re-architecture replaces the orca daemon with SSH-push to +bare servers (R-001 — no orca binary on servers). The daemon is repurposed to +drain-and-stop in v0.10-P05 and scheduled for deletion in v0.10-P14. See +.ciagent/PRD_v0.9.md.`, RunE: func(cmd *cobra.Command, args []string) error { + warnDeprecated("orca daemon is deprecated in v0.9 and will be repurposed to 'drain-and-stop' in v0.10-P05; the v0.9 re-architecture (R-001) removes the orca binary from servers — see .ciagent/PRD_v0.9.md") db, closer, err := openDB() if err != nil { return err @@ -97,5 +102,4 @@ func init() { daemonCmd.Flags().StringVar(&daemonAddr, "addr", ":8080", "listen address") daemonCmd.Flags().StringVar(&pprofAddr, "pprof", "", "enable pprof endpoint on (e.g. :6060); unauthenticated, operator-only") rootCmd.AddCommand(daemonCmd) - _ = slog.Default // keep import if unused above } diff --git a/internal/cli/daemon_test.go b/internal/cli/daemon_test.go index b9763b4..fc65539 100644 --- a/internal/cli/daemon_test.go +++ b/internal/cli/daemon_test.go @@ -1,6 +1,12 @@ package cli -import "testing" +import ( + "bytes" + "context" + "log/slog" + "strings" + "testing" +) func TestDaemonPprofFlag(t *testing.T) { f := daemonCmd.Flags().Lookup("pprof") @@ -11,3 +17,217 @@ func TestDaemonPprofFlag(t *testing.T) { t.Errorf("--pprof default = %q, want empty", f.DefValue) } } + +// captureSlog swaps slog.Default() for a text handler writing to buf, +// returning a buffer and a restore func. Tests use this to observe +// warnDeprecated output (which uses the package-level slog.Default). +func captureSlog(t *testing.T) (*bytes.Buffer, func()) { + t.Helper() + var buf bytes.Buffer + prev := slog.Default() + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})) + slog.SetDefault(logger) + return &buf, func() { slog.SetDefault(prev) } +} + +// runDaemonHermetic invokes daemonCmd.RunE with a context that is +// already cancelled and an unbindable --addr, so the long-running +// server start short-circuits and RunE returns quickly without +// touching the network. It returns whatever RunE returned and the +// captured slog buffer. +func runDaemonHermetic(t *testing.T, suppressWarnings bool) (string, error) { + t.Helper() + _, cleanup := initTestEnv(t) + defer cleanup() + resetRootFlags(t) + + buf, restore := captureSlog(t) + defer restore() + + if suppressWarnings { + _ = rootCmd.PersistentFlags().Set("no-deprecation-warnings", "true") + } + + daemonAddr = "127.0.0.1:99999" // unbindable: port outside uint16 range → ListenAndServe fails fast + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // already-done context: the select returns via <-ctx.Done() immediately + + cmd := daemonCmd + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs(nil) + cmd.SetContext(ctx) + + err := cmd.RunE(cmd, nil) + return buf.String(), err +} + +// TestDaemonEmitsDeprecationWarning verifies REQ-068: `orca daemon` +// emits a slog.Warn deprecation banner on every run. +func TestDaemonEmitsDeprecationWarning(t *testing.T) { + out, _ := runDaemonHermetic(t, false) + if !strings.Contains(out, "orca daemon is deprecated in v0.9") { + t.Errorf("expected deprecation warning in slog output, got:\n%s", out) + } + if !strings.Contains(out, "R-001") { + t.Errorf("deprecation warning should reference R-001, got:\n%s", out) + } +} + +// TestDaemonDeprecationWarningSuppressed verifies that +// --no-deprecation-warnings suppresses the deprecation banner (for +// `orca upgrade` migrations). +func TestDaemonDeprecationWarningSuppressed(t *testing.T) { + out, _ := runDaemonHermetic(t, true) + if strings.Contains(out, "deprecated in v0.9") { + t.Errorf("--no-deprecation-warnings should suppress the deprecation warning, got:\n%s", out) + } +} + +// TestDaemonStillRuns verifies deprecation ≠ removal: the daemon +// command's RunE is still wired and callable. We don't assert on the +// error value (the hermetic short-circuit may return nil or a +// shutdown-related error), only that the command did not fail *because* +// of the deprecation notice. +func TestDaemonStillRuns(t *testing.T) { + _, err := runDaemonHermetic(t, false) + if err != nil && strings.Contains(err.Error(), "deprecated") { + t.Errorf("daemon must not error due to deprecation, got: %v", err) + } +} + +// TestWarnDeprecatedGate verifies the package-level helper that gates +// deprecation warnings on the --no-deprecation-warnings flag. +func TestWarnDeprecatedGate(t *testing.T) { + t.Run("emits by default", func(t *testing.T) { + buf, restore := captureSlog(t) + defer restore() + noDeprecationWarnings = false + warnDeprecated("test-deprecation-marker") + if !strings.Contains(buf.String(), "test-deprecation-marker") { + t.Errorf("expected warning emitted, got: %s", buf.String()) + } + }) + t.Run("suppressed when flag set", func(t *testing.T) { + buf, restore := captureSlog(t) + defer restore() + noDeprecationWarnings = true + defer func() { noDeprecationWarnings = false }() + warnDeprecated("should-not-appear") + if strings.Contains(buf.String(), "should-not-appear") { + t.Errorf("expected no warning when --no-deprecation-warnings set, got: %s", buf.String()) + } + }) +} + +// TestNoDeprecationWarningsFlagRegistered verifies the +// --no-deprecation-warnings persistent flag exists on rootCmd. +func TestNoDeprecationWarningsFlagRegistered(t *testing.T) { + f := rootCmd.PersistentFlags().Lookup("no-deprecation-warnings") + if f == nil { + t.Fatal("--no-deprecation-warnings persistent flag not registered on rootCmd") + } + if f.DefValue != "false" { + t.Errorf("--no-deprecation-warnings default = %q, want false", f.DefValue) + } +} + +// TestCertEmitsDeprecationWarning verifies REQ-068: `orca cert` +// subcommands emit a deprecation banner. +func TestCertEmitsDeprecationWarning(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + resetRootFlags(t) + + buf, restore := captureSlog(t) + defer restore() + + var out bytes.Buffer + rootCmd.SetOut(&out) + rootCmd.SetErr(&out) + rootCmd.SetArgs([]string{"cert", "fingerprint", "--which", "ca"}) + _ = rootCmd.Execute() + + logged := buf.String() + if !strings.Contains(logged, "orca cert is deprecated in v0.9") { + t.Errorf("expected cert deprecation warning, got:\n%s", logged) + } + if !strings.Contains(logged, "step-ca") { + t.Errorf("deprecation warning should mention step-ca, got:\n%s", logged) + } +} + +// TestCertDeprecationWarningSuppressed verifies --no-deprecation-warnings +// suppresses the cert deprecation banner. +func TestCertDeprecationWarningSuppressed(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + resetRootFlags(t) + _ = rootCmd.PersistentFlags().Set("no-deprecation-warnings", "true") + + buf, restore := captureSlog(t) + defer restore() + + var out bytes.Buffer + rootCmd.SetOut(&out) + rootCmd.SetErr(&out) + rootCmd.SetArgs([]string{"cert", "fingerprint", "--which", "ca"}) + _ = rootCmd.Execute() + + if strings.Contains(buf.String(), "orca cert is deprecated") { + t.Errorf("--no-deprecation-warnings should suppress cert warning, got:\n%s", buf.String()) + } +} + +// TestNodeJoinMTLSEmitsDeprecationWarning verifies REQ-068: the mTLS +// join path (`orca node join` without --type proxmox) warns that the +// mTLS join path is deprecated. +func TestNodeJoinMTLSEmitsDeprecationWarning(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + resetRootFlags(t) + + buf, restore := captureSlog(t) + defer restore() + + var out bytes.Buffer + rootCmd.SetOut(&out) + rootCmd.SetErr(&out) + rootCmd.SetArgs([]string{"node", "join", "--name", "dep-warning", "--addr", "10.0.0.55:8443"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("node join: %v", err) + } + + logged := buf.String() + if !strings.Contains(logged, "mTLS join path is deprecated") { + t.Errorf("expected mTLS join deprecation warning, got:\n%s", logged) + } + if !strings.Contains(logged, "R-001") { + t.Errorf("deprecation warning should reference R-001, got:\n%s", logged) + } +} + +// TestNodeJoinProxmoxNoMTLSDeprecationWarning verifies the deprecation +// warning does NOT fire for the proxmox SSH path (that path is the +// v0.9 replacement, not the deprecated mTLS path). +func TestNodeJoinProxmoxNoMTLSDeprecationWarning(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + resetRootFlags(t) + + buf, restore := captureSlog(t) + defer restore() + + var out bytes.Buffer + rootCmd.SetOut(&out) + rootCmd.SetErr(&out) + // proxmox path errors on missing --host before reaching the warning, + // and never calls joinLocal, so no mTLS deprecation warning fires. + rootCmd.SetArgs([]string{"node", "join", "--type", "proxmox", "--password", "x"}) + _ = rootCmd.Execute() + + if strings.Contains(buf.String(), "mTLS join path is deprecated") { + t.Errorf("proxmox path must not emit mTLS deprecation warning, got:\n%s", buf.String()) + } +} diff --git a/internal/cli/namespace_test.go b/internal/cli/namespace_test.go index 01be688..c2f48ac 100644 --- a/internal/cli/namespace_test.go +++ b/internal/cli/namespace_test.go @@ -18,6 +18,7 @@ func resetRootFlags(t *testing.T) { rootCmd.SetErr(&buf) _ = rootCmd.PersistentFlags().Set("system", "false") _ = rootCmd.PersistentFlags().Set("json", "false") + _ = rootCmd.PersistentFlags().Set("no-deprecation-warnings", "false") resetCommandFlags() } diff --git a/internal/cli/node.go b/internal/cli/node.go index adaa851..2a94b26 100644 --- a/internal/cli/node.go +++ b/internal/cli/node.go @@ -89,7 +89,13 @@ Node types (via --type): // joinLocal is the existing localhost/Linux node join flow (fingerprint // check + registry.Insert). +// +// Deprecated: v0.9 re-architecture replaces daemon-to-daemon mTLS join +// with SSH-push bootstrap (R-001). The mTLS join path is retained for +// the dual-write window and scheduled for deletion in v0.10-P14. See +// .ciagent/PRD_v0.9.md. func joinLocal(cmd *cobra.Command) error { + warnDeprecated("orca node join (mTLS path): v0.9 R-001 replaces daemon-to-daemon mTLS join with SSH-push bootstrap; the mTLS join path is deprecated — see .ciagent/PRD_v0.9.md") if joinName == "" { return fmt.Errorf("--name is required") } diff --git a/internal/cli/root.go b/internal/cli/root.go index b9bb28e..579dd91 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "os" "github.com/spf13/cobra" @@ -50,15 +51,27 @@ over feature richness.`, } var ( - jsonOutput bool - systemNamespace bool - configPath string + jsonOutput bool + systemNamespace bool + configPath string + noDeprecationWarnings bool ) func init() { rootCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "output in JSON format") rootCmd.PersistentFlags().BoolVar(&systemNamespace, "system", false, "use system-level namespace root (/root/.orca) instead of user-level (~/.orca)") rootCmd.PersistentFlags().StringVar(&configPath, "config", "", "path to config.hcl (overrides ~/.orca/config.hcl)") + rootCmd.PersistentFlags().BoolVar(&noDeprecationWarnings, "no-deprecation-warnings", false, "suppress v0.9 deprecation warnings (use during `orca upgrade` migrations)") +} + +// warnDeprecated emits a v0.9 deprecation warning via slog.Warn unless +// the --no-deprecation-warnings global flag is set. Callers pass a +// human-readable message describing what changed. REQ-068. +func warnDeprecated(msg string) { + if noDeprecationWarnings { + return + } + slog.Warn(msg) } func configFromCtx(ctx context.Context) *config.Config { diff --git a/internal/daemon/server.go b/internal/daemon/server.go index b8665ed..629ba57 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -9,6 +9,12 @@ // - structured JSON via writeJSON // - no secrets in logs // - input validation on path/query/body +// +// Deprecated: v0.9 re-architecture replaces this with SSH-push to bare +// servers (no orca binary on servers) per R-001. The orca daemon is +// repurposed to drain-and-stop in v0.10-P05 and scheduled for deletion +// in v0.10-P14. See .ciagent/PRD_v0.9.md R-001/R-006. The dual-write +// window (REQ-090/REQ-085) keeps this package compiling until v0.10-P14. package daemon import ( diff --git a/internal/emit/contract.go b/internal/emit/contract.go new file mode 100644 index 0000000..83bcc51 --- /dev/null +++ b/internal/emit/contract.go @@ -0,0 +1,80 @@ +// Package emit defines the render-format contract between Go-side emitters +// and bash-side appliers (grill C-16). Every rendered artifact is a JSON +// object with a versioned schema; both sides validate against it to prevent +// emitter/applier drift. +package emit + +import ( + "encoding/json" + "errors" + "fmt" +) + +// SchemaVersion is the canonical versioned schema identifier for render +// contracts. Bump the suffix when the contract shape changes. +const SchemaVersion = "orca.emit/v1" + +// Kind enumerates the rendered-artifact kinds. Each maps to an emitter +// implementation and a matching bash-side applier. +type Kind string + +const ( + KindSystemd Kind = "systemd" + KindTraefik Kind = "traefik" + KindSyncthing Kind = "syncthing" + KindSudoers Kind = "sudoers" + KindSSHD Kind = "sshd" + KindEnvFile Kind = "envfile" + KindCredential Kind = "credential" +) + +// Artifact is a single rendered file destined for a peer. The bash-side +// applier reads this JSON and writes Content to Path with the given Mode. +type Artifact struct { + SchemaVersion string `json:"schema_version"` + Kind Kind `json:"kind"` + Path string `json:"path"` + Content string `json:"content"` + Mode string `json:"mode"` +} + +// Validate checks that an Artifact conforms to the render contract. +// Returns a structured error if any field is missing or invalid. +func (a *Artifact) Validate() error { + if a.SchemaVersion != SchemaVersion { + return fmt.Errorf("emit: schema_version mismatch: got %q want %q", a.SchemaVersion, SchemaVersion) + } + if a.Kind == "" { + return errors.New("emit: kind is required") + } + if a.Path == "" { + return errors.New("emit: path is required") + } + if a.Mode == "" { + return errors.New("emit: mode is required") + } + return nil +} + +// Marshal serializes an Artifact to JSON for transport to the bash applier. +func (a *Artifact) Marshal() ([]byte, error) { + if err := a.Validate(); err != nil { + return nil, err + } + return json.Marshal(a) +} + +// UnmarshalArtifact parses a JSON byte slice into an Artifact and validates +// it against the contract. The bash-side applier (via orca-verify-render.sh) +// uses this same validation; the bash side rejects unparseable input with a +// structured error, never silently (grill C-16). +func UnmarshalArtifact(data []byte) (*Artifact, error) { + var a Artifact + if err := json.Unmarshal(data, &a); err != nil { + return nil, fmt.Errorf("emit: unmarshal: %w", err) + } + if err := a.Validate(); err != nil { + return nil, err + } + return &a, nil +} diff --git a/internal/emit/contract_test.go b/internal/emit/contract_test.go new file mode 100644 index 0000000..ca56f39 --- /dev/null +++ b/internal/emit/contract_test.go @@ -0,0 +1,120 @@ +package emit + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestArtifactValidate_valid(t *testing.T) { + a := &Artifact{ + SchemaVersion: SchemaVersion, + Kind: KindSystemd, + Path: "/etc/systemd/system/orca-alloc.service", + Content: "[Service]\nExecStart=/bin/true\n", + Mode: "0644", + } + if err := a.Validate(); err != nil { + t.Fatalf("expected valid, got %v", err) + } +} + +func TestArtifactValidate_schemaVersionMismatch(t *testing.T) { + a := &Artifact{SchemaVersion: "orca.emit/v0", Kind: KindSystemd, Path: "/x", Mode: "0644"} + err := a.Validate() + if err == nil { + t.Fatal("expected error for mismatched schema_version") + } + if !strings.Contains(err.Error(), "schema_version mismatch") { + t.Fatalf("expected schema_version error, got %v", err) + } +} + +func TestArtifactValidate_missingKind(t *testing.T) { + a := &Artifact{SchemaVersion: SchemaVersion, Path: "/x", Mode: "0644"} + err := a.Validate() + if err == nil || !strings.Contains(err.Error(), "kind is required") { + t.Fatalf("expected kind-required error, got %v", err) + } +} + +func TestArtifactValidate_missingPath(t *testing.T) { + a := &Artifact{SchemaVersion: SchemaVersion, Kind: KindTraefik, Mode: "0644"} + err := a.Validate() + if err == nil || !strings.Contains(err.Error(), "path is required") { + t.Fatalf("expected path-required error, got %v", err) + } +} + +func TestArtifactValidate_missingMode(t *testing.T) { + a := &Artifact{SchemaVersion: SchemaVersion, Kind: KindSudoers, Path: "/x"} + err := a.Validate() + if err == nil || !strings.Contains(err.Error(), "mode is required") { + t.Fatalf("expected mode-required error, got %v", err) + } +} + +func TestMarshalValidate_rejectsInvalid(t *testing.T) { + a := &Artifact{SchemaVersion: "bad", Kind: "", Path: "", Mode: ""} + if _, err := a.Marshal(); err == nil { + t.Fatal("expected Marshal to reject invalid artifact") + } +} + +func TestUnmarshalArtifact_valid(t *testing.T) { + raw := `{"schema_version":"orca.emit/v1","kind":"systemd","path":"/x","content":"c","mode":"0644"}` + a, err := UnmarshalArtifact([]byte(raw)) + if err != nil { + t.Fatalf("expected valid, got %v", err) + } + if a.Kind != KindSystemd { + t.Fatalf("expected kind systemd, got %s", a.Kind) + } +} + +func TestUnmarshalArtifact_rejectsBadJSON(t *testing.T) { + if _, err := UnmarshalArtifact([]byte("not json")); err == nil { + t.Fatal("expected error for bad JSON") + } +} + +func TestUnmarshalArtifact_rejectsSchemaMismatch(t *testing.T) { + raw := `{"schema_version":"orca.emit/v2","kind":"x","path":"/x","mode":"0644"}` + if _, err := UnmarshalArtifact([]byte(raw)); err == nil { + t.Fatal("expected error for schema mismatch") + } +} + +func TestRoundTrip(t *testing.T) { + orig := &Artifact{ + SchemaVersion: SchemaVersion, + Kind: KindSyncthing, + Path: "/etc/syncthing/config.xml", + Content: "", + Mode: "0600", + } + data, err := orig.Marshal() + if err != nil { + t.Fatalf("Marshal: %v", err) + } + back, err := UnmarshalArtifact(data) + if err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if back.Path != orig.Path || back.Kind != orig.Kind || back.Mode != orig.Mode { + t.Fatalf("round-trip mismatch: %+v vs %+v", orig, back) + } +} + +func TestAllKinds(t *testing.T) { + for _, k := range []Kind{KindSystemd, KindTraefik, KindSyncthing, KindSudoers, KindSSHD, KindEnvFile, KindCredential} { + a := &Artifact{SchemaVersion: SchemaVersion, Kind: k, Path: "/x", Mode: "0644"} + if err := a.Validate(); err != nil { + t.Errorf("kind %s: %v", k, err) + } + // verify it marshals + if _, err := json.Marshal(a); err != nil { + t.Errorf("marshal kind %s: %v", k, err) + } + } +} diff --git a/internal/engine/dispatcher.go b/internal/engine/dispatcher.go index e53f93f..bce2934 100644 --- a/internal/engine/dispatcher.go +++ b/internal/engine/dispatcher.go @@ -25,6 +25,11 @@ import ( ) // Dispatcher is the public surface; constructed via NewDispatcher. +// +// Deprecated: v0.9 re-architecture replaces peer dispatch with a CLI-side +// scheduler + SSH-push (no orca binary on servers per R-001). The +// Dispatcher is retained for the dual-write window and scheduled for +// deletion in v0.10-P14. See .ciagent/PRD_v0.9.md R-001/R-006. type Dispatcher struct { log *slog.Logger capacity *store.CapacityRepo diff --git a/internal/engine/peer.go b/internal/engine/peer.go index 4f71aa9..b3b57e3 100644 --- a/internal/engine/peer.go +++ b/internal/engine/peer.go @@ -16,6 +16,11 @@ import ( ) // Peer is a remote orca node reachable over mTLS. +// +// Deprecated: v0.9 re-architecture replaces peer dispatch with a CLI-side +// scheduler + SSH-push (no orca binary on servers per R-001). The Peer +// type is retained for the dual-write window and scheduled for deletion +// in v0.10-P14. See .ciagent/PRD_v0.9.md R-001/R-006. type Peer struct { NodeID string Address string // host:port (the peer's daemon listener) @@ -27,6 +32,11 @@ type Peer struct { // PeerRegistry tracks known peers. Methods are safe for concurrent // use; the underlying map is guarded by a sync.RWMutex. +// +// Deprecated: v0.9 re-architecture replaces peer dispatch with a CLI-side +// scheduler + SSH-push (no orca binary on servers per R-001). The +// PeerRegistry is retained for the dual-write window and scheduled for +// deletion in v0.10-P14. See .ciagent/PRD_v0.9.md R-001/R-006. type PeerRegistry struct { mu sync.RWMutex peers map[string]*Peer diff --git a/internal/security/ca.go b/internal/security/ca.go index 33fca5b..3dcde73 100644 --- a/internal/security/ca.go +++ b/internal/security/ca.go @@ -16,27 +16,51 @@ import ( // CAValidity is how long a CA cert is valid. Per D-013, the CA is long-lived // (10 years) because manual rotation is expensive. +// +// Deprecated: v0.9 re-architecture replaces the internal CA with step-ca +// (D-101/REQ-076). This constant is retained for the dual-write window and +// scheduled for deletion in v0.10-P14. See .ciagent/PRD_v0.9.md. 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. +// +// Deprecated: v0.9 re-architecture replaces the internal CA with step-ca +// (D-101/REQ-076). This constant is retained for the dual-write window and +// scheduled for deletion in v0.10-P14. See .ciagent/PRD_v0.9.md. 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. +// +// Deprecated: v0.9 re-architecture replaces the internal CA with step-ca +// (D-101/REQ-076). This constant is retained for the dual-write window and +// scheduled for deletion in v0.10-P14. See .ciagent/PRD_v0.9.md. const CAKeySize = 3072 // CAMode is the file mode used when persisting the CA private key. REQ-033 // requires 0600. +// +// Deprecated: v0.9 re-architecture replaces the internal CA with step-ca +// (D-101/REQ-076). This constant is retained for the dual-write window and +// scheduled for deletion in v0.10-P14. See .ciagent/PRD_v0.9.md. 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). +// +// Deprecated: v0.9 re-architecture replaces the internal CA with step-ca +// (D-101/REQ-076). This constant is retained for the dual-write window and +// scheduled for deletion in v0.10-P14. See .ciagent/PRD_v0.9.md. const CACPEMMode os.FileMode = 0o644 // File names used inside the CA directory. +// +// Deprecated: v0.9 re-architecture replaces the internal CA with step-ca +// (D-101/REQ-076). These constants are retained for the dual-write window and +// scheduled for deletion in v0.10-P14. See .ciagent/PRD_v0.9.md. const ( CACertFile = "ca.crt" CAKeyFile = "ca.key" @@ -44,6 +68,10 @@ const ( // CA wraps a loaded CA. Use CAInit to mint a new one, LoadCA to read an // existing one from disk. +// +// Deprecated: v0.9 re-architecture replaces the internal CA with step-ca +// (D-101/REQ-076). The CA type is retained for the dual-write window and +// scheduled for deletion in v0.10-P14. See .ciagent/PRD_v0.9.md. type CA struct { Cert *x509.Certificate Key *rsa.PrivateKey @@ -60,6 +88,10 @@ type CA struct { // 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. +// +// Deprecated: v0.9 re-architecture replaces the internal CA with step-ca +// (D-101/REQ-076). CAInit is retained for the dual-write window and scheduled +// for deletion in v0.10-P14. See .ciagent/PRD_v0.9.md. func CAInit(dir, commonName string) (*CA, error) { if dir == "" { return nil, errors.New("CAInit: dir is required") @@ -133,6 +165,10 @@ func CAInit(dir, commonName string) (*CA, error) { // LoadCA reads a previously-initialized CA from disk. Returns a *CA or an // error. Verifies file modes (REQ-033). +// +// Deprecated: v0.9 re-architecture replaces the internal CA with step-ca +// (D-101/REQ-076). LoadCA is retained for the dual-write window and scheduled +// for deletion in v0.10-P14. See .ciagent/PRD_v0.9.md. func LoadCA(dir string) (*CA, error) { if dir == "" { return nil, errors.New("LoadCA: dir is required") @@ -187,6 +223,10 @@ func LoadCA(dir string) (*CA, error) { // 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. +// +// Deprecated: v0.9 re-architecture replaces the internal CA with step-ca +// (D-101/REQ-076). EnforceFileModes is retained for the dual-write window +// and scheduled for deletion in v0.10-P14. See .ciagent/PRD_v0.9.md. func EnforceFileModes(dir string) error { certPath := filepath.Join(dir, CACertFile) keyPath := filepath.Join(dir, CAKeyFile) @@ -217,6 +257,10 @@ func EnforceFileModes(dir string) error { // 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. +// +// Deprecated: v0.9 re-architecture replaces the internal CA with step-ca +// (D-101/REQ-076). SignCSR is retained for the dual-write window and +// scheduled for deletion in v0.10-P14. See .ciagent/PRD_v0.9.md. func (c *CA) SignCSR(csrPEM []byte) ([]byte, error) { if c == nil || c.Cert == nil || c.Key == nil { return nil, errors.New("SignCSR: nil CA") @@ -266,6 +310,10 @@ func (c *CA) SignCSR(csrPEM []byte) ([]byte, error) { // 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 `. +// +// Deprecated: v0.9 re-architecture replaces the internal CA with step-ca +// (D-101/REQ-076). CA.Fingerprint is retained for the dual-write window and +// scheduled for deletion in v0.10-P14. See .ciagent/PRD_v0.9.md. func (c *CA) Fingerprint() string { return FingerprintOf(c.Cert.Raw) } diff --git a/internal/security/csr.go b/internal/security/csr.go index 2aea764..e797fbc 100644 --- a/internal/security/csr.go +++ b/internal/security/csr.go @@ -21,6 +21,10 @@ import ( // 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. +// +// Deprecated: v0.9 re-architecture replaces the internal CA with step-ca +// (D-101/REQ-076). GenerateCSR is retained for the dual-write window and +// scheduled for deletion in v0.10-P14. See .ciagent/PRD_v0.9.md. func GenerateCSR(commonName string, sans []string) (keyPEM, csrPEM []byte, err error) { if commonName == "" { return nil, nil, errors.New("GenerateCSR: commonName is required") diff --git a/internal/transport/mtls.go b/internal/transport/mtls.go index 258b2be..d2ddadf 100644 --- a/internal/transport/mtls.go +++ b/internal/transport/mtls.go @@ -6,6 +6,12 @@ // 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). +// +// Deprecated: v0.9 re-architecture replaces this with +// internal/sshpush (REQ-073). The daemon-to-daemon mTLS transport is +// removed because servers no longer run the orca binary (R-001); the +// CLI pushes config via SSH instead. Scheduled for deletion in +// v0.10-P14. See .ciagent/PRD_v0.9.md R-001/R-006. package transport import ( diff --git a/scripts/lib/orca-log.sh b/scripts/lib/orca-log.sh new file mode 100644 index 0000000..29ee00f --- /dev/null +++ b/scripts/lib/orca-log.sh @@ -0,0 +1,32 @@ +# orca-log.sh — structured slog-compatible JSON logging for bash scripts (C-17). +# Source this library from any orca bash script: `source scripts/lib/orca-log.sh`. +# Emits JSON to syslog via `logger`; falls back to stderr if `logger` is missing. +# Field set matches the Go audit log (REQ-006): ts, level, actor, action, resource, result, error. + +ORCA_LOG_ACTOR="${ORCA_LOG_ACTOR:-spiffe://orca/cli/operator}" + +# _orca_log_emit [error] +_orca_log_emit() { + local level="$1" action="$2" resource="$3" result="$4" error="${5:-}" + local ts + ts="$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)" + # Build JSON with proper escaping of error field (escape backslash and quote). + local err_json="" + if [ -n "$error" ]; then + local esc_error + esc_error="${error//\\/\\\\}" + esc_error="${esc_error//\"/\\\"}" + err_json=",\"error\":\"$esc_error\"" + fi + local line + line="{\"ts\":\"$ts\",\"level\":\"$level\",\"actor\":\"$ORCA_LOG_ACTOR\",\"action\":\"$action\",\"resource\":\"$resource\",\"result\":\"$result\"$err_json}" + if command -v logger >/dev/null 2>&1; then + logger -t orca "$line" + else + echo "$line" >&2 + fi +} + +orca_log_info() { _orca_log_emit "info" "$1" "$2" "$3" "${4:-}"; } +orca_log_warn() { _orca_log_emit "warn" "$1" "$2" "$3" "${4:-}"; } +orca_log_error() { _orca_log_emit "error" "$1" "$2" "$3" "${4:-}"; } \ No newline at end of file diff --git a/scripts/orca-verify-render.sh b/scripts/orca-verify-render.sh new file mode 100755 index 0000000..825be1b --- /dev/null +++ b/scripts/orca-verify-render.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# orca-verify-render.sh — bash-side render-contract validator (grill C-16). +# Reads a render-bundle JSON file (one Artifact per line, or a JSON array) +# and validates each entry against the orca.emit/v1 schema. +# Exit 0 if all valid; non-zero with a structured error per failure to stderr. +# Source: scripts/lib/orca-log.sh for structured error logging (C-17). +# +# Usage: orca-verify-render.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/orca-log.sh +. "$SCRIPT_DIR/lib/orca-log.sh" + +EXPECTED_SCHEMA="orca.emit/v1" + +if [ "$#" -lt 1 ]; then + orca_log_error "verify-render" "-" "failed" "missing bundle argument" + echo "usage: $0 " >&2 + exit 2 +fi + +bundle="$1" +if [ ! -f "$bundle" ]; then + orca_log_error "verify-render" "$bundle" "failed" "bundle file not found" + echo "error: bundle not found: $bundle" >&2 + exit 2 +fi + +errors=0 +total=0 + +# Read the bundle line-by-line. Each line should be a JSON object. +# (The Go emitter writes one Artifact per line for line-delimited parsing.) +while IFS= read -r line; do + # Skip blank lines and comments. + [ -z "$line" ] && continue + case "$line" in \#*) continue ;; esac + total=$((total + 1)) + # Validate schema_version field presence and value (crude JSON grep; no jq dep). + # Check schema_version via a simple substring test. + schema_match=0 + if printf '%s' "$line" | grep -q "\"schema_version\":\"$EXPECTED_SCHEMA\""; then + schema_match=1 + fi + if [ "$schema_match" -eq 1 ]; then + # schema_version matches. Check kind, path, mode presence. + for field in kind path mode; do + if ! printf '%s' "$line" | grep -q "\"$field\":"; then + orca_log_error "verify-render" "$bundle" "failed" "missing field: $field" + echo "error: line $total missing field: $field" >&2 + errors=$((errors + 1)) + continue 2 + fi + done + elif printf '%s' "$line" | grep -q '"schema_version":'; then + orca_log_error "verify-render" "$bundle" "failed" "schema_version mismatch on line $total" + echo "error: line $total schema_version mismatch (expected $EXPECTED_SCHEMA)" >&2 + errors=$((errors + 1)) + else + orca_log_error "verify-render" "$bundle" "failed" "missing schema_version on line $total" + echo "error: line $total missing schema_version" >&2 + errors=$((errors + 1)) + fi +done < "$bundle" + +if [ "$errors" -gt 0 ]; then + orca_log_error "verify-render" "$bundle" "failed" "$errors of $total artifacts invalid" + echo "verify-render: $errors of $total artifacts invalid" >&2 + exit 1 +fi + +orca_log_info "verify-render" "$bundle" "ok" "" +echo "verify-render: $total artifacts valid" +exit 0 \ No newline at end of file diff --git a/scripts/tests/README.md b/scripts/tests/README.md new file mode 100644 index 0000000..2a17231 --- /dev/null +++ b/scripts/tests/README.md @@ -0,0 +1,39 @@ +# Bash Testing Policy (grill C-15) + +Every bash script under `scripts/` MUST have at least one bats test covering +the happy path and one covering the failure path. This is the compensating +control for bash being exempt from the Go coverage gate (D-186). + +## Framework + +- **bats** — `bats scripts/tests/*.bash` runs all bash tests. +- **shellcheck** — `shellcheck scripts/*.sh scripts/lib/*.sh scripts/tests/*.bash` static analysis. +- **shfmt** — `shfmt -d scripts/` formatting check (optional; skip if not installed). + +## Install (if missing) + +```bash +# bats +npm install -g bats # or: git clone https://github.com/bats-core/bats-core.git && ./bats-core/install.sh /usr/local +# shellcheck +apt-get install -y shellcheck +# shfmt (optional) +mvdan.cc/sh (go install mvdan.cc/sh/v3/cmd/shfmt@latest) +``` + +## Running + +```bash +make test-bash # runs bats (skips gracefully if bats missing) +make lint-bash # runs shellcheck + shfmt (skips gracefully if missing) +make test # runs both Go + bash tests +make lint # runs both Go + bash lint +``` + +## Test file convention + +- Test files live in `scripts/tests/_test.bash`. +- Source `load test_helper` at the top of every test file. +- Happy path: `@test "