From 4bfc246be4cb5c514a2e1e6169d8e6f19473fd7a Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Mon, 3 Aug 2026 18:05:01 +0000 Subject: [PATCH] feat(P01): unified namespace root via ORCA_HOME + --system flag REQ-041: ORCA_HOME is now the single namespace root for all components (db, certs, init, daemon). store.Open("") and init command both route through certpaths.Dir()/DBPath() instead of hardcoding ~/.orca. Backward compatible: empty ORCA_HOME -> ~/.orca. REQ-042: --system persistent flag on rootCmd sets ORCA_HOME=/root/.orca via PersistentPreRunE. Errors on conflict with pre-set ORCA_HOME. Tests: 7 new tests in namespace_test.go (default, ORCA_HOME override, --system sets root, conflict detection, init --json, flag registered). Full suite passes (no regressions). Docs: docs/namespace.md covers default, ORCA_HOME, --system, ORCA_DB, resolution order, and path layout tables. ---ci--- project: orca phase: 1 milestone: v0.5 status: verify ---/ci--- --- .ciagent/CHECKPOINT.json | 10 +-- .ciagent/PHASE1_VERIFICATION.md | 74 ++++++++++++++++++++ docs/namespace.md | 96 +++++++++++++++++++++++++ internal/cli/init.go | 11 ++- internal/cli/namespace_test.go | 120 ++++++++++++++++++++++++++++++++ internal/cli/root.go | 20 +++++- internal/store/store.go | 8 +-- 7 files changed, 321 insertions(+), 18 deletions(-) create mode 100644 .ciagent/PHASE1_VERIFICATION.md create mode 100644 docs/namespace.md create mode 100644 internal/cli/namespace_test.go diff --git a/.ciagent/CHECKPOINT.json b/.ciagent/CHECKPOINT.json index df80cc8..d0e9c9c 100644 --- a/.ciagent/CHECKPOINT.json +++ b/.ciagent/CHECKPOINT.json @@ -1,10 +1,10 @@ { - "phase": 0, - "stage": "complete", + "phase": 1, + "stage": "verify", "milestone": "v0.5", "milestone_slug": "distribution", - "phase_role": "pre_execution", + "phase_role": "execution", "attempts": 0, - "updated_at": "2026-08-03T18:30:00Z", + "updated_at": "2026-08-03T18:40:00Z", "milestone_complete": false -} +} \ No newline at end of file diff --git a/.ciagent/PHASE1_VERIFICATION.md b/.ciagent/PHASE1_VERIFICATION.md new file mode 100644 index 0000000..b4ce73a --- /dev/null +++ b/.ciagent/PHASE1_VERIFICATION.md @@ -0,0 +1,74 @@ +# Phase 1 Verification: Namespace Unification (v0.5 P1) + +**Phase**: 1 (namespace unification) +**Milestone**: v0.5 Distribution +**Requirements covered**: REQ-041, REQ-042 +**Date**: 2026-08-03 + +## Structural Layer + +- `gofmt -l .` → clean (no files need formatting). +- `go vet ./...` → clean (no warnings). +- `go build ./...` → succeeds. +- New files: `internal/cli/namespace_test.go`, `docs/namespace.md`. +- Modified files: `internal/cli/root.go`, `internal/cli/init.go`, `internal/store/store.go`. + +## Behavioral Layer + +### Unit tests (new) +- `TestNamespaceDefaultsToUserHome` ✓ — empty `ORCA_HOME` → `~/.orca`. +- `TestNamespaceHonorsORCAHOME` ✓ — `ORCA_HOME=/tmp/x` → `Dir()=/tmp/x`, `DBPath()=/tmp/x/orca.db`. +- `TestInitHonorsORCAHOME` ✓ — `init` creates `$ORCA_HOME` dir. +- `TestSystemFlagSetsORCAHOME` ✓ — `--system` sets `ORCA_HOME=/root/.orca`. +- `TestSystemFlagConflictsWithORCAHOME` ✓ — `--system` + `ORCA_HOME=/custom` → error. +- `TestInitJSONOutput` ✓ — `init --json` returns `{"path":"...","status":"initialized"}`. +- `TestSystemFlagIsPersistent` ✓ — `--system` registered as persistent flag on `rootCmd`. + +### Unit tests (regression — all pass) +- `internal/cli/` (9.8s) ✓ +- `internal/store/` ✓ +- `internal/doctor/` ✓ +- `internal/daemon/` ✓ +- `internal/security/` ✓ +- `internal/engine/` ✓ +- `internal/jobspec/` ✓ +- `internal/transport/` ✓ + +### Manual e2e +- `ORCA_HOME=/tmp/orca-test-user ./bin/orca init` → creates `/tmp/orca-test-user` ✓ +- `./bin/orca --system init` → creates `/root/.orca` ✓ +- `ORCA_HOME=/custom ./bin/orca --system init` → error "conflicts with ORCA_HOME" ✓ +- `./bin/orca version --json` → `{"version":"v0.4.1",...}` ✓ + +## Security Layer + +- No new secret handling. The namespace unification moves path resolution + but does not change cert/key file modes (0600/0644 per REQ-033 unchanged). +- `--system` flag does not escalate privileges — it only changes the + namespace root path. Running as non-root with `--system` will fail at + `os.MkdirAll("/root/.orca")` with a permission error (expected). +- No new network surface. + +## Quality Layer + +- **Backward compatibility**: empty `ORCA_HOME` + no `--system` → `~/.orca` + (identical to pre-v0.5 behavior). All existing tests pass unmodified. +- **Single source of truth**: `certpaths.Dir()` is the only namespace root + resolver. `store.Open("")` and `init` both route through it. +- **No redundant implementations**: the `--system` flag maps to `ORCA_HOME` + rather than introducing a parallel path mechanism. +- **Documentation**: `docs/namespace.md` covers default, `ORCA_HOME`, and + `--system` with examples and resolution order. + +## Must-Haves Checklist + +- [x] `go test ./...` passes (including new namespace_test.go). +- [x] `ORCA_HOME=/tmp/x orca init` creates `/tmp/x` (not `~/.orca`). +- [x] `orca --system init` creates `/root/.orca` (when run as root). +- [x] Empty `ORCA_HOME` + no `--system` → `~/.orca` (backward compat). +- [x] `orca version --json` works (needed by install.sh in P2). + +## Verdict + +**PASS** — all 4 verification layers pass. REQ-041 and REQ-042 are +satisfied. Ready to ship as `v0.4.2`. \ No newline at end of file diff --git a/docs/namespace.md b/docs/namespace.md new file mode 100644 index 0000000..6fa751b --- /dev/null +++ b/docs/namespace.md @@ -0,0 +1,96 @@ +# Namespace and Paths + +Orca stores all on-disk state (SQLite database, CA certs, server certs, +config) under a single **namespace root** directory. This document +describes how that root is resolved and how to override it. + +## Default: User-Level (`~/.orca`) + +By default, the namespace root is `~/.orca` (i.e., `$HOME/.orca`). +All orca state lives under this directory: + +| Path | Contents | +|------|----------| +| `~/.orca/orca.db` | SQLite database (jobs, nodes, tasks, audit log, capacity) | +| `~/.orca/ca.crt` | CA certificate (PEM, mode 0644) | +| `~/.orca/ca.key` | CA private key (PEM, mode 0600) | +| `~/.orca/server.crt` | Server certificate (PEM, mode 0644) | +| `~/.orca/server.key` | Server private key (PEM, mode 0600) | + +## Override: `ORCA_HOME` Environment Variable (REQ-041) + +Set the `ORCA_HOME` environment variable to change the namespace root +for **all** orca components (database, certs, init, daemon): + +```bash +export ORCA_HOME=/var/lib/orca +orca init # creates /var/lib/orca/ +orca daemon # reads /var/lib/orca/orca.db +orca cert ca-init # writes CA to /var/lib/orca/ +``` + +This is the single source of truth for the namespace root. Every +component that reads or writes on-disk state resolves the root via +`ORCA_HOME` (falling back to `~/.orca` when unset). + +### Use cases + +- **Testing**: point `ORCA_HOME` at a temp directory. +- **Multi-instance**: run multiple orca daemons on the same host with + different `ORCA_HOME` values. +- **Custom layout**: store state on a mounted volume + (`ORCA_HOME=/mnt/orca-data`). + +## System-Level: `--system` Flag (REQ-042) + +The `--system` persistent flag selects the system-level namespace root +`/root/.orca`. This is intended for root-owned system deployments +(where orca runs as a system service under root): + +```bash +sudo orca --system init # creates /root/.orca/ +sudo orca --system daemon # reads /root/.orca/orca.db +sudo orca --system cert ca-init # writes CA to /root/.orca/ +``` + +The `--system` flag is equivalent to setting `ORCA_HOME=/root/.orca`, +but it is a CLI convenience that does not require exporting an env var. +If `ORCA_HOME` is already set to a different value, `--system` returns +an error (to avoid silent namespace mismatches). + +### Path layout + +System-level uses the same directory shape as user-level, just under +`/root/.orca` instead of `~/.orca`: + +| Path | Contents | +|------|----------| +| `/root/.orca/orca.db` | SQLite database | +| `/root/.orca/ca.crt` | CA certificate | +| `/root/.orca/ca.key` | CA private key | +| `/root/.orca/server.crt` | Server certificate | +| `/root/.orca/server.key` | Server private key | + +## Resolution Order + +1. If `--system` flag is passed → root is `/root/.orca` (errors if + `ORCA_HOME` is set to a conflicting value). +2. Else if `ORCA_HOME` is set → root is `$ORCA_HOME`. +3. Else → root is `~/.orca` (`$HOME/.orca`). + +## `ORCA_DB` Override + +For finer-grained control, `ORCA_DB` overrides **only** the database +path (not the cert paths). This is primarily a testing affordance. When +`ORCA_DB` is set, certs still resolve under `ORCA_HOME` (or `~/.orca`). + +```bash +export ORCA_DB=/tmp/test.db +orca daemon # uses /tmp/test.db for the DB, ~/.orca/ for certs +``` + +## See Also + +- [Install Guide](install.md) — 1-liner install with `install.sh`. +- [Docker Guide](docker.md) — running orca in a container (uses + `ORCA_HOME=/var/lib/orca` inside the image). \ No newline at end of file diff --git a/internal/cli/init.go b/internal/cli/init.go index 9a30c9c..7a95ab9 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -3,21 +3,18 @@ package cli import ( "fmt" "os" - "path/filepath" "github.com/spf13/cobra" + + "git.cloudinit.dev/coreci/orca/internal/certpaths" ) var initCmd = &cobra.Command{ Use: "init", Short: "Initialize local orca state directory", - Long: "Create the local orca state directory at ~/.orca/ and write a default config file.", + Long: "Create the local orca state directory (honors $ORCA_HOME; defaults to ~/.orca) and write a default config file.", RunE: func(cmd *cobra.Command, args []string) error { - home, err := os.UserHomeDir() - if err != nil { - return fmt.Errorf("get home dir: %w", err) - } - orcaDir := filepath.Join(home, ".orca") + orcaDir := certpaths.Dir() if err := os.MkdirAll(orcaDir, 0o755); err != nil { return fmt.Errorf("create orca dir: %w", err) } diff --git a/internal/cli/namespace_test.go b/internal/cli/namespace_test.go new file mode 100644 index 0000000..c7b92e7 --- /dev/null +++ b/internal/cli/namespace_test.go @@ -0,0 +1,120 @@ +package cli + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "git.cloudinit.dev/coreci/orca/internal/certpaths" +) + +func resetRootFlags(t *testing.T) { + t.Helper() + rootCmd.SetArgs(nil) + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + _ = rootCmd.PersistentFlags().Set("system", "false") + _ = rootCmd.PersistentFlags().Set("json", "false") +} + +func TestNamespaceDefaultsToUserHome(t *testing.T) { + t.Setenv("ORCA_HOME", "") + home, err := os.UserHomeDir() + if err != nil { + t.Fatalf("UserHomeDir: %v", err) + } + want := filepath.Join(home, ".orca") + if got := certpaths.Dir(); got != want { + t.Errorf("certpaths.Dir() = %q, want %q", got, want) + } +} + +func TestNamespaceHonorsORCAHOME(t *testing.T) { + tmp := t.TempDir() + t.Setenv("ORCA_HOME", tmp) + if got := certpaths.Dir(); got != tmp { + t.Errorf("certpaths.Dir() = %q, want %q", got, tmp) + } + if got := certpaths.DBPath(); got != filepath.Join(tmp, "orca.db") { + t.Errorf("certpaths.DBPath() = %q, want %q", got, filepath.Join(tmp, "orca.db")) + } +} + +func TestInitHonorsORCAHOME(t *testing.T) { + tmp := t.TempDir() + t.Setenv("ORCA_HOME", tmp) + resetRootFlags(t) + + rootCmd.SetArgs([]string{"init"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("init: %v", err) + } + + info, err := os.Stat(tmp) + if err != nil { + t.Fatalf("stat %s: %v", tmp, err) + } + if !info.IsDir() { + t.Errorf("%s is not a directory", tmp) + } +} + +func TestSystemFlagSetsORCAHOME(t *testing.T) { + t.Setenv("ORCA_HOME", "") + resetRootFlags(t) + + rootCmd.SetArgs([]string{"--system", "init"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("--system init: %v", err) + } + if got := os.Getenv("ORCA_HOME"); got != systemNamespaceRoot { + t.Errorf("ORCA_HOME = %q, want %q", got, systemNamespaceRoot) + } +} + +func TestSystemFlagConflictsWithORCAHOME(t *testing.T) { + t.Setenv("ORCA_HOME", "/custom/path") + resetRootFlags(t) + + rootCmd.SetArgs([]string{"--system", "init"}) + err := rootCmd.Execute() + if err == nil { + t.Fatal("expected error for --system + ORCA_HOME conflict, got nil") + } +} + +func TestInitJSONOutput(t *testing.T) { + tmp := t.TempDir() + t.Setenv("ORCA_HOME", tmp) + resetRootFlags(t) + + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetArgs([]string{"init", "--json"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("init --json: %v", err) + } + + var result map[string]string + if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &result); err != nil { + t.Fatalf("unmarshal init output: %v\noutput: %s", err, buf.String()) + } + if result["path"] != tmp { + t.Errorf("init --json path = %q, want %q", result["path"], tmp) + } + if result["status"] != "initialized" { + t.Errorf("init --json status = %q, want %q", result["status"], "initialized") + } +} + +func TestSystemFlagIsPersistent(t *testing.T) { + for _, name := range []string{"system", "json"} { + f := rootCmd.PersistentFlags().Lookup(name) + if f == nil { + t.Errorf("persistent flag %q not found", name) + } + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 815d36e..9fd7151 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -3,6 +3,7 @@ package cli import ( "encoding/json" "fmt" + "os" "github.com/spf13/cobra" ) @@ -13,6 +14,8 @@ var ( buildTime = "unknown" ) +const systemNamespaceRoot = "/root/.orca" + var rootCmd = &cobra.Command{ Use: "orca", Short: "Orca — offline/CLI-first orchestration engine", @@ -21,12 +24,27 @@ inspired by HashiCorp Nomad, prioritizing stability, security, and simplicity over feature richness.`, SilenceUsage: true, SilenceErrors: true, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + if systemNamespace { + if existing := os.Getenv("ORCA_HOME"); existing != "" && existing != systemNamespaceRoot { + return fmt.Errorf("--system conflicts with ORCA_HOME=%q (already set); unset ORCA_HOME or drop --system", existing) + } + if err := os.Setenv("ORCA_HOME", systemNamespaceRoot); err != nil { + return fmt.Errorf("set ORCA_HOME for --system: %w", err) + } + } + return nil + }, } -var jsonOutput bool +var ( + jsonOutput bool + systemNamespace 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)") } func Execute() error { diff --git a/internal/store/store.go b/internal/store/store.go index ad9e170..39e6212 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -7,15 +7,13 @@ import ( "path/filepath" _ "modernc.org/sqlite" + + "git.cloudinit.dev/coreci/orca/internal/certpaths" ) func Open(path string) (*sql.DB, error) { if path == "" { - home, err := os.UserHomeDir() - if err != nil { - return nil, fmt.Errorf("get home dir: %w", err) - } - path = filepath.Join(home, ".orca", "orca.db") + path = certpaths.DBPath() } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return nil, fmt.Errorf("create db dir: %w", err)