Compare commits

...

6 Commits

Author SHA1 Message Date
Jon Chery fef03c5b56 verify(P0a2): 4-layer verification PASS — REQ-082
---ci---
project: orca
phase: P0a2
milestone: v0.9
status: verify
---/ci---
2026-08-05 16:49:12 +00:00
Jon Chery 7bb31d4c09 feat(P0a2): namespace CRUD + inheritance engine (REQ-082)
P0a2 — Namespace inheritance resolver + orca ns CLI subcommands.

Resolver (REQ-082, internal/ns/resolve.go):
- Pure Resolve() function: DFS post-order chain assembly (most-specific
  first, _defaults implicit last D-185). Child-wins-scalar env merge, de-duped
  union constraints. Cycle detection with readable cycle path. Missing-parent
  + missing-_defaults + misordering (['_defaults','x']) rejection. Opt-out
  impossible (D-187). 89.6% coverage.

Parser (internal/ns/parse.go):
- ParseNSMd: hand-rolled YAML frontmatter (no yaml.v3 dep). Validates
  kind:Namespace + name, parses parents flow-array, inherits_env/secrets.
- ParseNSMdDir: walks root/*/ns.md, skips cluster/, requires _defaults.

CLI (internal/cli/ns.go, D-176):
- orca ns list/create/delete/inspect/validate. Inspect + validate use the
  resolver. Create refuses _defaults/cluster; delete refuses _defaults +
  non-empty namespaces. JSON output support. 85.2% coverage.
- Registered on rootCmd.

Tests: resolve_test.go (11 tests), parse_test.go (14 tests), ns_test.go
(21 tests). 18 packages pass, 20 bats pass, gofmt clean, verify-reqs 90
consistent.

---ci---
project: orca
phase: P0a2
milestone: v0.9
status: execute
---/ci---
2026-08-05 16:49:12 +00:00
Jon Chery 7b5193674e docs(P0a1): ship — v0.8.2 tagged, released, merged
---ci---
project: orca
phase: P0a1
milestone: v0.9
status: complete
---/ci---
2026-08-05 16:38:46 +00:00
Jon Chery f6de82d712 verify(P0a1): 4-layer verification PASS — REQ-063,069,070; gate C-07
---ci---
project: orca
phase: P0a1
milestone: v0.9
status: verify
---/ci---
2026-08-05 16:38:36 +00:00
Jon Chery 437aab39b4 feat(P0a1): multi-namespace path resolver + config demotion + known_hosts flock + CA migration spec (v0.9 P0a1)
P0a1 — Re-architecture Foundation (path resolver + config demotion).

Path resolver (REQ-070, R-002):
- internal/paths/paths.go: 23 functions for the multi-namespace layout
  (Root/ClusterDir/NamespaceDir/NS*/DefaultNamespace/CA/MasterKey/CacheDB/
  Txn/Peers/KnownHosts/SSH/Server/Config). Honors $ORCA_HOME. 100% coverage.
- internal/certpaths/certpaths.go: refactored as thin shim delegating to
  paths, preserving the v0.8 flat-layout API for backward compat during
  the dual-write window (REQ-090). Package doc explains the v0.10-P14
  migration plan. certpaths deleted after v0.10-P14. 100% coverage.

Config demotion (REQ-069, R-014):
- internal/config/markdown.go: minimal hand-rolled YAML frontmatter parser
  (no new dep — yaml.v3 not in go.mod). Returns same *Config struct as HCL.
- internal/config/config.go: renamed Load body to LoadHCL (// Deprecated
  per R-013), added dispatcher Load() routing on extension (.hcl->HCL,
  .md->Markdown, .yaml->Markdown). Signature preserved so root.go unchanged.
- dispatch_test.go + markdown_test.go: 89.8% coverage on config package.

Known_hosts flock (REQ-063, deferred P1 from REVIEW_v0.8 A2):
- internal/security/flock.go: stdlib syscall.Flock advisory lock helper.
- internal/proxmox/bootstrap.go: TOFUHostKeyCallback capture + ResetHostKey
  both acquire the flock before read-modify-write on known_hosts. Prevents
  concurrent writers under v0.9 parallel SSH fan-out. 3 flock tests.

CA migration spec (grill C-07):
- .ciagent/CA_MIGRATION_SPEC_v0.9.md: Option A (preserve trust root,
  RECOMMENDED) vs Option B (forced re-bootstrap). Pre-flight checks,
  migration steps, rollback, post-migration invariants, spike plan.

Verification: build pass, 17/17 Go packages pass, 20/20 bats pass, gofmt
clean, go vet clean, verify-reqs 90 consistent. Coverage: paths 100%,
certpaths 100%, config 89.8%, emit covered.

---ci---
project: orca
phase: P0a1
milestone: v0.9
status: execute
---/ci---
2026-08-05 16:38:26 +00:00
Jon Chery e5d2711d71 docs(P00): ship P00 — v0.8.1 tagged, released, merged to milestone/v0.9-rearchitecture
---ci---
project: orca
phase: P00
milestone: v0.9
status: complete
---/ci---
2026-08-05 16:27:17 +00:00
20 changed files with 2983 additions and 87 deletions
+109
View File
@@ -0,0 +1,109 @@
# CA Migration Spec — v0.8 Internal CA → v0.9 step-ca (grill C-07)
**Status**: spec (must be implemented in v0.10-P14a, REQ-066)
**Gate**: C-07 — blocks v0.10-P14a until this spec is reviewed and a dry-run passes on a test cluster
## Problem
The v0.8 internal Go CA (`internal/security/ca.go`) issues RSA-3072 CA
certs (10-year validity) and ECDSA P-256 server certs (90-day). The CA
material lives at `~/.orca/ca.crt` and `~/.orca/ca.key` (flat layout, D-011).
The v0.9 re-architecture reverses AD-010 and replaces the internal CA with
step-ca (D-101, REQ-076). Existing v0.8 deployments have an internal CA
root + issued server certs that must be migrated without invalidating
trust across the cluster.
## Migration options (decision required before v0.10-P14a implementation)
### Option A — Preserve trust root (RECOMMENDED)
Import the existing `ca.key` into step-ca as the root CA key. The cluster's
trust fingerprint stays unchanged; existing server certs continue to
validate until their natural expiry; new SVIDs are minted by step-ca using
the same root.
```bash
orca upgrade --to-v1.0 --import-ca
# reads ~/.orca/ca.key → step ca init --deployment-type standalone \
# --remote-management --key $(cat ~/.orca/ca.key)
# issues new SVIDs from step-ca for all existing workloads
```
**Pros**: zero trust breakage; existing server certs keep working; minimal
operator disruption.
**Cons**: requires step-ca to accept an imported RSA-3072 key (step-ca
supports imported keys via `--key` flag; verify in the spike).
**Post-migration**: old `internal/security/ca.go` and `csr.go` are deleted
(v0.10-P14); the `cert_repo` SQLite table (0004) is dropped (step-ca
manages cert state).
### Option B — Forced re-bootstrap
Document that v0.8 certs are invalidated; every cluster re-bootstraps under
step-ca with a new root. Existing workloads are re-enrolled.
**Pros**: clean slate; no legacy RSA root.
**Cons**: trust breakage — every peer's `known_hosts` + CA cert must be
rotated; running workloads lose mTLS until re-enrolled; higher operator
disruption.
**Use case**: only if Option A is technically infeasible (step-ca rejects
the v0.8 key format).
## Pre-flight checks (must pass before migration)
1. `orca doctor` reports zero FAILs on the v0.8 cluster
2. All peers reachable via SSH
3. No in-flight transactions (the migration is stop-the-world for the CA)
4. Snapshot taken (`orca backup --include-master-key`)
5. step-ca installed on the lead via `apt-get install step-ca`
6. `step ca init` dry-run succeeds with the imported key
## Migration steps (Option A)
1. SSH to the lead; install step-ca via apt
2. Run `step ca init --deployment-type standalone --remote-management \
--key <v0.8-ca-key-path> --provisioner orca-admin`
3. Move the root cert: `cp ~/.orca/ca.crt $ORCA_HOME/cluster/ca.crt`
4. Issue new SVIDs for every registered workload (via `step ca token` +
`step ca certificate` — the CLI mints the provisioner token using
`cluster/master.key`-derived material)
5. Deploy the new SVIDs to peers via SSH-push (the v0.9 SSH-push transport)
6. Verify: `orca doctor` reports zero FAILs; CA fingerprint unchanged;
all workload SVIDs valid
7. Archive the old `internal/security/ca.go`/`csr.go` and `cert_repo` table
## Rollback
If any post-migration invariant fails:
1. Restore the v0.8 snapshot via `orca upgrade --rollback <tarball>`
2. Restart the v0.8 orca daemon on the lead
3. Verify `orca doctor` passes on the v0.8 cluster
The v0.8 internal CA remains functional during the dual-write window
(REQ-090); step-ca is additive until the migration completes.
## Post-migration invariants (must all pass)
- CA fingerprint unchanged (Option A)
- Node count unchanged
- Workload count unchanged
- All SVIDs valid (mTLS handshake succeeds lead↔every peer)
- `orca doctor` zero FAILs
- No `internal/security/ca.go` or `cert_repo` references remain in code
## Decision required
This spec is gated by C-07. The decision (Option A vs B) must be made
before v0.10-P14a implementation. Default: Option A (preserve trust root)
unless the step-ca imported-key spike fails.
## Spike (must run before v0.10-P14a)
Run on a test cluster:
1. Install step-ca on a clean Linux host
2. Generate a v0.8-style RSA-3072 CA key via the v0.8 `internal/security` package
3. Run `step ca init --key <v8-key>` and verify step-ca accepts it
4. Mint a test SVID via `step ca token` + `step ca certificate`
5. Verify the SVID validates against the imported root
If the spike fails, fall back to Option B (forced re-bootstrap) and document.
+1 -20
View File
@@ -1,20 +1 @@
{
"phase": "P00",
"stage": "verify",
"milestone": "v0.9",
"milestone_slug": "rearchitecture",
"phase_role": "execution",
"attempts": 0,
"updated_at": "2026-08-05T02:45:00Z",
"milestone_complete": false,
"gates_cleared": ["C-03", "C-05", "C-06", "C-15", "C-16", "C-17", "C-18"],
"verify": {
"build": "pass",
"go_test": "16/16 packages pass",
"bats": "20/20 tests pass",
"gofmt": "clean",
"go_vet": "clean",
"verify_reqs": "90 requirements consistent",
"shellcheck": "info-level only (no errors)"
}
}
{ "phase": "P0a2", "stage": "verify", "milestone": "v0.9", "phase_role": "execution", "updated_at": "2026-08-05T03:15:00Z", "milestone_complete": false, "gates_cleared_this_phase": [], "verify": { "build": "pass", "go_test": "18/18", "bats": "20/20", "gofmt": "clean", "verify_reqs": "90 consistent" } }
+52 -43
View File
@@ -1,64 +1,73 @@
// Package certpaths centralizes the on-disk locations of the CA and
// server cert/key files. The CLI layer, the security layer, and the
// doctor layer all need to agree on these paths, so they're factored
// into their own package to avoid import cycles (cli <-> doctor).
// Package certpaths is the v0.8 path shim. It returns v0.8 flat-layout
// paths for backward compatibility during the v0.9 dual-write window
// (REQ-090). The v0.9 paths package (internal/paths) returns the new
// multi-namespace layout (R-002).
//
// certpaths will be deleted after the v0.10-P14 migration. New code
// should use internal/paths, NOT certpaths.
//
// Migration notes (per v0.10-P14):
// - CA cert/key, server cert/key, SSH key/pub, known_hosts currently
// live at the flat Root() location. The v0.9 internal/paths package
// returns the new ClusterDir()/... locations; certpaths keeps the
// v0.8 flat locations until the CA migration moves them.
// - DBPath keeps returning Root()/orca.db (v0.8 location). The new
// paths.NSDb("_defaults") returns Root()/_defaults/db/orca.db; the DB
// moves in v0.10-P14.
package certpaths
import (
"os"
"path/filepath"
"git.cloudinit.dev/coreci/orca/internal/paths"
)
const (
defaultCADir = ".orca"
caCertFilename = "ca.crt"
caKeyFilename = "ca.key"
)
// Dir returns the v0.8 flat root directory. Delegates to paths.Root()
// (which honors $ORCA_HOME, else ~/.orca). v0.8 callers expect the CA
// and DB to live directly under this directory; that does not change
// until the v0.10-P14 migration.
func Dir() string { return paths.Root() }
// Dir returns the directory the local CA lives in. Honors $ORCA_HOME
// for testability; otherwise defaults to ~/.orca.
func Dir() string {
if p := os.Getenv("ORCA_HOME"); p != "" {
return p
}
home, _ := os.UserHomeDir()
return filepath.Join(home, defaultCADir)
}
// CACertPath returns the v0.8 CA cert path: Dir()/ca.crt.
// The v0.9 location is paths.CACertPath() = ClusterDir()/ca.crt; certpaths
// keeps the v0.8 flat location until the CA migration in v0.10-P14.
func CACertPath() string { return filepath.Join(paths.Root(), "ca.crt") }
// CACertPath returns the path to ca.crt.
func CACertPath() string { return filepath.Join(Dir(), caCertFilename) }
// CAKeyPath returns the v0.8 CA key path: Dir()/ca.key.
// See CACertPath for migration notes.
func CAKeyPath() string { return filepath.Join(paths.Root(), "ca.key") }
// CAKeyPath returns the path to ca.key.
func CAKeyPath() string { return filepath.Join(Dir(), caKeyFilename) }
// ServerCertPath returns the v0.8 server cert path: Dir()/server.crt.
// See CACertPath for migration notes.
func ServerCertPath() string { return filepath.Join(paths.Root(), "server.crt") }
// ServerCertPath returns the path to server.crt.
func ServerCertPath() string { return filepath.Join(Dir(), "server.crt") }
// ServerKeyPath returns the path to server.key.
func ServerKeyPath() string { return filepath.Join(Dir(), "server.key") }
// ServerKeyPath returns the v0.8 server key path: Dir()/server.key.
// See CACertPath for migration notes.
func ServerKeyPath() string { return filepath.Join(paths.Root(), "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.
// for testability and explicit override; otherwise defaults to the v0.8
// flat location Dir()/orca.db. The v0.9 location is
// paths.NSDb(paths.DefaultNamespace()) = Root()/_defaults/db/orca.db;
// certpaths keeps the v0.8 flat location until the DB move in v0.10-P14.
func DBPath() string {
if p := os.Getenv("ORCA_DB"); p != "" {
return p
}
return filepath.Join(Dir(), "orca.db")
return filepath.Join(paths.Root(), "orca.db")
}
// SSHKeyPath returns the path to the orca SSH private key (Ed25519,
// D-037). Used by `orca node join --type proxmox` to authenticate
// to remote Proxmox hosts after the initial password-based bootstrap.
// File mode 0600 (enforced by security.WriteKey).
func SSHKeyPath() string { return filepath.Join(Dir(), "orca_ssh_key") }
// SSHKeyPath returns the v0.8 SSH private key path: Dir()/orca_ssh_key.
// The v0.9 location is paths.SSHKeyPath() = ClusterDir()/orca_ssh_key;
// certpaths keeps the v0.8 flat location until the migration.
func SSHKeyPath() string { return filepath.Join(paths.Root(), "orca_ssh_key") }
// SSHPubPath returns the path to the orca SSH public key (authorized_keys
// format). Deployed to remote Proxmox hosts during `orca node join`.
// File mode 0644 (enforced by security.WriteCert).
func SSHPubPath() string { return filepath.Join(Dir(), "orca_ssh_key.pub") }
// SSHPubPath returns the v0.8 SSH public key path: Dir()/orca_ssh_key.pub.
// See SSHKeyPath for migration notes.
func SSHPubPath() string { return filepath.Join(paths.Root(), "orca_ssh_key.pub") }
// KnownHostsPath returns the path to the SSH known_hosts file used for
// TOFU host-key pinning (D-035). Captured on first connect, verified
// on all subsequent connects via golang.org/x/crypto/ssh/knownhosts.
func KnownHostsPath() string { return filepath.Join(Dir(), "known_hosts") }
// KnownHostsPath returns the v0.8 known_hosts path: Dir()/known_hosts.
// The v0.9 location is paths.KnownHostsPath() = ClusterDir()/known_hosts;
// certpaths keeps the v0.8 flat location until the migration.
func KnownHostsPath() string { return filepath.Join(paths.Root(), "known_hosts") }
+49 -22
View File
@@ -3,15 +3,17 @@ package certpaths
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"git.cloudinit.dev/coreci/orca/internal/paths"
)
const defaultHomeSubdir = ".orca"
func TestPaths_HonorORCAHOME(t *testing.T) {
dir := t.TempDir()
t.Setenv("ORCA_HOME", dir)
// Ensure ORCA_DB doesn't leak from the environment / prior tests.
t.Setenv("ORCA_DB", "")
cases := []struct {
@@ -36,17 +38,26 @@ func TestPaths_HonorORCAHOME(t *testing.T) {
})
}
// DBPath defaults to $ORCA_HOME/orca.db.
if got, want := DBPath(), filepath.Join(dir, "orca.db"); got != want {
t.Errorf("DBPath = %q, want %q", got, want)
}
// Dir() returns ORCA_HOME verbatim.
if got, want := Dir(), dir; got != want {
t.Errorf("Dir = %q, want %q", got, want)
}
}
func TestShim_DelegatesDirToPaths(t *testing.T) {
dir := t.TempDir()
t.Setenv("ORCA_HOME", dir)
if got, want := Dir(), paths.Root(); got != want {
t.Errorf("Dir() = %q, paths.Root() = %q (shim must delegate)", got, want)
}
if got, want := Dir(), dir; got != want {
t.Errorf("Dir() = %q, want %q", got, want)
}
}
func TestDBPath_OrcaDBOverride(t *testing.T) {
home := t.TempDir()
t.Setenv("ORCA_HOME", home)
@@ -70,19 +81,14 @@ func TestDBPath_OrcaDBEmptyStringFallsBackToHome(t *testing.T) {
}
func TestDir_DefaultHomeFallback(t *testing.T) {
// Unset ORCA_HOME so Dir() falls back to ~/.orca.
// We can't reliably mutate the real HOME in a portable way, so just
// assert that the returned path ends with the default subdir on the
// current OS and is absolute.
os.Unsetenv("ORCA_HOME")
// Also clear ORCA_DB so DBPath's fallback to Dir() is exercised.
os.Unsetenv("ORCA_DB")
home, err := os.UserHomeDir()
if err != nil {
t.Skipf("os.UserHomeDir: %v (cannot verify default fallback)", err)
}
want := filepath.Join(home, defaultCADir)
want := filepath.Join(home, defaultHomeSubdir)
if got := Dir(); got != want {
t.Errorf("Dir() default = %q, want %q", got, want)
}
@@ -92,25 +98,22 @@ func TestDir_DefaultHomeFallback(t *testing.T) {
}
func TestDir_ORCAHOMEEmptyFallsBack(t *testing.T) {
// Empty string ORCA_HOME is treated as unset → ~/.orca fallback.
t.Setenv("ORCA_HOME", "")
home, err := os.UserHomeDir()
if err != nil {
t.Skipf("os.UserHomeDir: %v", err)
}
want := filepath.Join(home, defaultCADir)
want := filepath.Join(home, defaultHomeSubdir)
if got := Dir(); got != want {
t.Errorf("Dir() with empty ORCA_HOME = %q, want %q", got, want)
}
}
func TestDir_ORCAHOMERelativePath(t *testing.T) {
// A relative ORCA_HOME is honored verbatim (no cleaning/absolutizing).
t.Setenv("ORCA_HOME", "relative/orca/home")
if got, want := Dir(), "relative/orca/home"; got != want {
t.Errorf("Dir() relative = %q, want %q", got, want)
}
// CACertPath joins the relative dir with ca.crt using filepath.Join.
if got, want := CACertPath(), filepath.Join("relative/orca/home", "ca.crt"); got != want {
t.Errorf("CACertPath relative = %q, want %q", got, want)
}
@@ -121,7 +124,6 @@ func TestAllPaths_AreConsistentWithDir(t *testing.T) {
t.Setenv("ORCA_HOME", dir)
t.Setenv("ORCA_DB", "")
// Every *Path() must live under Dir() except DBPath which also does.
base := Dir()
for _, p := range []string{
CACertPath(), CAKeyPath(),
@@ -135,6 +137,38 @@ func TestAllPaths_AreConsistentWithDir(t *testing.T) {
}
}
func TestShim_ReturnsV08FlatPaths(t *testing.T) {
dir := t.TempDir()
t.Setenv("ORCA_HOME", dir)
t.Setenv("ORCA_DB", "")
root := paths.Root()
if got, want := CACertPath(), filepath.Join(root, "ca.crt"); got != want {
t.Errorf("CACertPath = %q, want v0.8 flat %q", got, want)
}
if got, want := CAKeyPath(), filepath.Join(root, "ca.key"); got != want {
t.Errorf("CAKeyPath = %q, want v0.8 flat %q", got, want)
}
if got, want := ServerCertPath(), filepath.Join(root, "server.crt"); got != want {
t.Errorf("ServerCertPath = %q, want v0.8 flat %q", got, want)
}
if got, want := ServerKeyPath(), filepath.Join(root, "server.key"); got != want {
t.Errorf("ServerKeyPath = %q, want v0.8 flat %q", got, want)
}
if got, want := SSHKeyPath(), filepath.Join(root, "orca_ssh_key"); got != want {
t.Errorf("SSHKeyPath = %q, want v0.8 flat %q", got, want)
}
if got, want := SSHPubPath(), filepath.Join(root, "orca_ssh_key.pub"); got != want {
t.Errorf("SSHPubPath = %q, want v0.8 flat %q", got, want)
}
if got, want := KnownHostsPath(), filepath.Join(root, "known_hosts"); got != want {
t.Errorf("KnownHostsPath = %q, want v0.8 flat %q", got, want)
}
if got, want := DBPath(), filepath.Join(root, "orca.db"); got != want {
t.Errorf("DBPath = %q, want v0.8 flat %q", got, want)
}
}
func TestSSHPaths_Filenames(t *testing.T) {
dir := t.TempDir()
t.Setenv("ORCA_HOME", dir)
@@ -148,10 +182,3 @@ func TestSSHPaths_Filenames(t *testing.T) {
t.Errorf("KnownHostsPath base = %q, want %q", got, want)
}
}
func init() {
// On Windows the default home subdir is still ".orca"; the test for
// default fallback uses os.UserHomeDir which is platform-aware. This
// guard keeps the suite from running a meaningless check on plan9.
_ = runtime.GOOS
}
+1
View File
@@ -34,6 +34,7 @@ func resetCommandFlags() {
stopID, runTarget, runIDKey, jobWatch = "", "", "", false
capSetCPU, capSetMem, capSetDisk, capNodeID = 0, 0, 0, ""
auditLimit = 50
resetNSFlags()
}
func TestNamespaceDefaultsToUserHome(t *testing.T) {
+338
View File
@@ -0,0 +1,338 @@
// Package cli: ns.go implements the `orca ns` subcommand family
// (REQ-082, D-176). Subcommands:
//
// orca ns list — list all namespaces under ORCA_HOME
// orca ns create <name> — create a namespace dir + ns.md
// orca ns delete <name> — remove an empty namespace dir
// orca ns inspect <name> — print effective chain + merged env
// orca ns validate <name> — cycle + missing-parent + schema checks
//
// All subcommands honor $ORCA_HOME via internal/paths. The inheritance
// resolver (internal/ns) is a pure function shared by inspect + validate.
package cli
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/spf13/cobra"
"git.cloudinit.dev/coreci/orca/internal/ns"
"git.cloudinit.dev/coreci/orca/internal/paths"
)
var nsCmd = &cobra.Command{
Use: "ns",
Short: "Manage orca namespaces",
Long: `Manage orca namespaces under ORCA_HOME (R-002).
Each namespace is a directory with ns.md, .env, .env.secrets, db/,
jobs/, alloc/. The implicit root namespace _defaults always exists
(D-159); every namespace inherits from _defaults (D-185) and cannot
opt out (D-187).`,
}
var (
nsCreateParent string
nsCreateInheritsEnv bool
nsCreateInheritsSecret bool
)
var nsListCmd = &cobra.Command{
Use: "list",
Short: "List all namespaces under ORCA_HOME",
Long: `List all namespaces under ORCA_HOME (directories containing ns.md, plus the implicit _defaults).`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
root := paths.Root()
entries, err := os.ReadDir(root)
if err != nil {
return fmt.Errorf("read ORCA_HOME %s: %w", root, err)
}
type nsRow struct {
Name string `json:"name"`
Path string `json:"path"`
Default bool `json:"default"`
}
var rows []nsRow
for _, ent := range entries {
if !ent.IsDir() {
continue
}
if ent.Name() == "cluster" {
continue
}
nsMd := filepath.Join(root, ent.Name(), "ns.md")
if _, err := os.Stat(nsMd); err != nil {
continue
}
rows = append(rows, nsRow{
Name: ent.Name(),
Path: filepath.Join(root, ent.Name()),
Default: ent.Name() == paths.DefaultNamespace(),
})
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Name == paths.DefaultNamespace() {
return true
}
if rows[j].Name == paths.DefaultNamespace() {
return false
}
return rows[i].Name < rows[j].Name
})
if jsonOutput {
return printJSON(rows)
}
if len(rows) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "No namespaces found. Run 'orca init' first.")
return nil
}
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-10s %s\n", "NAME", "DEFAULT", "PATH")
for _, r := range rows {
def := ""
if r.Default {
def = "*"
}
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-10s %s\n", r.Name, def, r.Path)
}
return nil
},
}
var nsCreateCmd = &cobra.Command{
Use: "create <name>",
Short: "Create a namespace directory + ns.md",
Long: `Create a namespace under ORCA_HOME. Builds the dir structure
(db/, jobs/, alloc/) and writes ns.md frontmatter. --parent may be
repeated to declare inheritance; _defaults is always appended last.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
if name == paths.DefaultNamespace() {
return fmt.Errorf("cannot create the implicit root namespace %q with `ns create` (it is auto-managed)", name)
}
if name == "cluster" {
return fmt.Errorf("name %q is reserved for the cluster-wide dir", name)
}
if nsCreateParent == "" {
nsCreateParent = paths.DefaultNamespace()
}
nsDir := paths.NamespaceDir(name)
if _, err := os.Stat(nsDir); err == nil {
if _, statErr := os.Stat(paths.NSMd(name)); statErr == nil {
return fmt.Errorf("namespace %q already exists at %s", name, nsDir)
}
}
for _, sub := range []string{"db", "jobs", "alloc"} {
if err := os.MkdirAll(filepath.Join(nsDir, sub), 0o755); err != nil {
return fmt.Errorf("create %s/%s: %w", nsDir, sub, err)
}
}
parents := []string{nsCreateParent}
if nsCreateParent == paths.DefaultNamespace() {
// Explicit _defaults listing is allowed (de-duped silently).
}
body := renderNSMd(name, parents, nsCreateInheritsEnv, nsCreateInheritsSecret)
if err := os.WriteFile(paths.NSMd(name), []byte(body), 0o644); err != nil {
return fmt.Errorf("write ns.md: %w", err)
}
if jsonOutput {
return printJSON(map[string]any{
"name": name,
"path": nsDir,
"parents": parents,
"ns_md": paths.NSMd(name),
"inherits_env": nsCreateInheritsEnv,
"inherits_secrets": nsCreateInheritsSecret,
})
}
fmt.Fprintf(cmd.OutOrStdout(), "✓ Namespace created: %s (%s)\n", name, nsDir)
return nil
},
}
var nsDeleteCmd = &cobra.Command{
Use: "delete <name>",
Short: "Remove an empty namespace directory",
Long: `Remove a namespace directory. Refuses if jobs/ or alloc/
contain any files (non-empty namespace). The implicit root _defaults
cannot be deleted.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
if name == paths.DefaultNamespace() {
return fmt.Errorf("cannot delete the implicit root namespace %q", name)
}
nsDir := paths.NamespaceDir(name)
if _, err := os.Stat(nsDir); err != nil {
return fmt.Errorf("namespace %q not found: %w", name, err)
}
for _, sub := range []string{"jobs", "alloc"} {
dir := filepath.Join(nsDir, sub)
if err := dirNonEmpty(dir); err != nil {
return fmt.Errorf("refusing to delete %q: %s is non-empty (%w); clear it first", name, sub, err)
}
}
if err := os.RemoveAll(nsDir); err != nil {
return fmt.Errorf("delete %s: %w", nsDir, err)
}
if jsonOutput {
return printJSON(map[string]string{"name": name, "deleted": nsDir})
}
fmt.Fprintf(cmd.OutOrStdout(), "✓ Namespace deleted: %s (%s)\n", name, nsDir)
return nil
},
}
var nsInspectCmd = &cobra.Command{
Use: "inspect <name>",
Short: "Print the effective chain, merged env, and constraints",
Long: `Resolve a namespace's inheritance chain and print the merged env and unioned constraints (uses the resolver).`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
root := paths.Root()
cfgs, err := ns.ParseNSMdDir(root)
if err != nil {
return fmt.Errorf("load namespaces: %w", err)
}
if _, ok := cfgs[name]; !ok {
return fmt.Errorf("namespace %q not found under %s", name, root)
}
resolved, err := ns.Resolve(cfgs)
if err != nil {
return fmt.Errorf("resolve: %w", err)
}
r := resolved[name]
if r == nil {
return fmt.Errorf("namespace %q resolved to nil", name)
}
if jsonOutput {
return printJSON(map[string]any{
"name": r.Name,
"chain": r.Chain,
"env": r.Env,
"constraints": r.Constraints,
})
}
fmt.Fprintf(cmd.OutOrStdout(), "Namespace: %s\n", r.Name)
fmt.Fprintf(cmd.OutOrStdout(), "Chain: %s\n", strings.Join(r.Chain, " -> "))
fmt.Fprintln(cmd.OutOrStdout(), "Env:")
keys := sortedKeys(r.Env)
for _, k := range keys {
fmt.Fprintf(cmd.OutOrStdout(), " %s = %s\n", k, r.Env[k])
}
fmt.Fprintln(cmd.OutOrStdout(), "Constraints:")
if len(r.Constraints) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), " (none)")
} else {
for _, c := range r.Constraints {
fmt.Fprintf(cmd.OutOrStdout(), " - %s\n", c)
}
}
return nil
},
}
var nsValidateCmd = &cobra.Command{
Use: "validate <name>",
Short: "Run cycle + missing-parent + schema checks on a namespace",
Long: `Validate a namespace's inheritance chain and ns.md frontmatter.
Exits 0 if valid, 1 on error. Runs over ALL namespaces under ORCA_HOME
(parsing + resolving validates cycles and missing parents across the
set).`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
root := paths.Root()
cfgs, err := ns.ParseNSMdDir(root)
if err != nil {
return fmt.Errorf("load namespaces: %w", err)
}
if _, ok := cfgs[name]; !ok {
return fmt.Errorf("namespace %q not found under %s", name, root)
}
resolved, err := ns.Resolve(cfgs)
if err != nil {
return fmt.Errorf("validate: %w", err)
}
r := resolved[name]
if r == nil {
return fmt.Errorf("namespace %q resolved to nil", name)
}
if jsonOutput {
return printJSON(map[string]any{
"name": r.Name,
"valid": true,
"chain": r.Chain,
})
}
fmt.Fprintf(cmd.OutOrStdout(), "✓ %s valid\n chain: %s\n", name, strings.Join(r.Chain, " -> "))
return nil
},
}
// renderNSMd writes a minimal ns.md frontmatter for `orca ns create`.
func renderNSMd(name string, parents []string, inheritsEnv, inheritsSecrets bool) string {
var b strings.Builder
b.WriteString("---\n")
b.WriteString("kind: Namespace\n")
b.WriteString("name: ")
b.WriteString(name)
b.WriteString("\n")
if len(parents) > 0 {
quoted := make([]string, len(parents))
for i, p := range parents {
quoted[i] = fmt.Sprintf("%q", p)
}
b.WriteString("parents: [")
b.WriteString(strings.Join(quoted, ", "))
b.WriteString("]\n")
}
fmt.Fprintf(&b, "inherits_env: %t\n", inheritsEnv)
fmt.Fprintf(&b, "inherits_secrets: %t\n", inheritsSecrets)
b.WriteString("---\n")
return b.String()
}
// dirNonEmpty returns an error wrapping the offending entry if dir
// contains any entries.
func dirNonEmpty(dir string) error {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
for _, e := range entries {
return fmt.Errorf("contains %s", e.Name())
}
return nil
}
func sortedKeys(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
func init() {
nsCreateCmd.Flags().StringVar(&nsCreateParent, "parent", "", "parent namespace (default _defaults; the implicit root is always appended last)")
nsCreateCmd.Flags().BoolVar(&nsCreateInheritsEnv, "inherits-env", true, "inherit env from parents (default true)")
nsCreateCmd.Flags().BoolVar(&nsCreateInheritsSecret, "inherits-secrets", true, "inherit secrets from parents (default true)")
nsCmd.AddCommand(nsListCmd)
nsCmd.AddCommand(nsCreateCmd)
nsCmd.AddCommand(nsDeleteCmd)
nsCmd.AddCommand(nsInspectCmd)
nsCmd.AddCommand(nsValidateCmd)
rootCmd.AddCommand(nsCmd)
}
+407
View File
@@ -0,0 +1,407 @@
package cli
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"git.cloudinit.dev/coreci/orca/internal/paths"
)
// resetNSFlags zeroes the ns subcommand flag-bound vars so tests don't
// leak state.
func resetNSFlags() {
nsCreateParent = ""
nsCreateInheritsEnv = true
nsCreateInheritsSecret = true
}
func writeDefaultsNS(t *testing.T, root string) {
t.Helper()
nsDir := filepath.Join(root, "_defaults")
if err := os.MkdirAll(nsDir, 0o755); err != nil {
t.Fatalf("mkdir _defaults: %v", err)
}
body := "---\nkind: Namespace\nname: _defaults\ninherits_env: true\ninherits_secrets: true\n---\n# defaults\n"
if err := os.WriteFile(filepath.Join(nsDir, "ns.md"), []byte(body), 0o644); err != nil {
t.Fatalf("write _defaults ns.md: %v", err)
}
}
func writeCustomNS(t *testing.T, root, name, parentsList string) {
t.Helper()
nsDir := filepath.Join(root, name)
if err := os.MkdirAll(nsDir, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", name, err)
}
body := "---\nkind: Namespace\nname: " + name + "\n"
if parentsList != "" {
body += "parents: " + parentsList + "\n"
}
body += "inherits_env: true\ninherits_secrets: true\n---\n# " + name + "\n"
if err := os.WriteFile(filepath.Join(nsDir, "ns.md"), []byte(body), 0o644); err != nil {
t.Fatalf("write %s ns.md: %v", name, err)
}
}
func TestNSListEmpty(t *testing.T) {
t.Setenv("ORCA_HOME", t.TempDir())
resetRootFlags(t)
resetNSFlags()
rootCmd.SetArgs([]string{"ns", "list"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("ns list: %v", err)
}
}
func TestNSListWithNamespaces(t *testing.T) {
root := t.TempDir()
t.Setenv("ORCA_HOME", root)
resetRootFlags(t)
resetNSFlags()
writeDefaultsNS(t, root)
writeCustomNS(t, root, "prod", "")
rootCmd.SetArgs([]string{"ns", "list"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("ns list: %v", err)
}
}
func TestNSCreateHappy(t *testing.T) {
root := t.TempDir()
t.Setenv("ORCA_HOME", root)
resetRootFlags(t)
resetNSFlags()
rootCmd.SetArgs([]string{"ns", "create", "prod"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("ns create: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "prod", "ns.md")); err != nil {
t.Fatalf("ns.md not created: %v", err)
}
for _, sub := range []string{"db", "jobs", "alloc"} {
if _, err := os.Stat(filepath.Join(root, "prod", sub)); err != nil {
t.Errorf("subdir %s not created: %v", sub, err)
}
}
}
func TestNSCreateWithParent(t *testing.T) {
root := t.TempDir()
t.Setenv("ORCA_HOME", root)
resetRootFlags(t)
resetNSFlags()
writeDefaultsNS(t, root)
writeCustomNS(t, root, "base", "")
rootCmd.SetArgs([]string{"ns", "create", "child", "--parent", "base"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("ns create: %v", err)
}
data, err := os.ReadFile(filepath.Join(root, "child", "ns.md"))
if err != nil {
t.Fatalf("read ns.md: %v", err)
}
if !strings.Contains(string(data), "parents: [") || !strings.Contains(string(data), "\"base\"") {
t.Errorf("ns.md missing parents: %s", string(data))
}
}
func TestNSCreateExisting(t *testing.T) {
root := t.TempDir()
t.Setenv("ORCA_HOME", root)
resetRootFlags(t)
resetNSFlags()
writeDefaultsNS(t, root)
writeCustomNS(t, root, "prod", "")
rootCmd.SetArgs([]string{"ns", "create", "prod"})
err := rootCmd.Execute()
if err == nil {
t.Fatal("expected error creating existing namespace, got nil")
}
if !strings.Contains(err.Error(), "already exists") {
t.Errorf("error = %q, want contains 'already exists'", err.Error())
}
}
func TestNSCreateDefaultsRefused(t *testing.T) {
root := t.TempDir()
t.Setenv("ORCA_HOME", root)
resetRootFlags(t)
resetNSFlags()
rootCmd.SetArgs([]string{"ns", "create", "_defaults"})
err := rootCmd.Execute()
if err == nil {
t.Fatal("expected error creating _defaults, got nil")
}
}
func TestNSCreateClusterRefused(t *testing.T) {
root := t.TempDir()
t.Setenv("ORCA_HOME", root)
resetRootFlags(t)
resetNSFlags()
rootCmd.SetArgs([]string{"ns", "create", "cluster"})
err := rootCmd.Execute()
if err == nil {
t.Fatal("expected error creating cluster, got nil")
}
}
func TestNSDeleteHappy(t *testing.T) {
root := t.TempDir()
t.Setenv("ORCA_HOME", root)
resetRootFlags(t)
resetNSFlags()
writeDefaultsNS(t, root)
writeCustomNS(t, root, "prod", "")
rootCmd.SetArgs([]string{"ns", "delete", "prod"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("ns delete: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "prod")); !os.IsNotExist(err) {
t.Errorf("prod dir still exists after delete")
}
}
func TestNSDeleteDefaultsRefused(t *testing.T) {
root := t.TempDir()
t.Setenv("ORCA_HOME", root)
resetRootFlags(t)
resetNSFlags()
writeDefaultsNS(t, root)
rootCmd.SetArgs([]string{"ns", "delete", "_defaults"})
err := rootCmd.Execute()
if err == nil {
t.Fatal("expected error deleting _defaults, got nil")
}
}
func TestNSDeleteNonEmpty(t *testing.T) {
root := t.TempDir()
t.Setenv("ORCA_HOME", root)
resetRootFlags(t)
resetNSFlags()
writeDefaultsNS(t, root)
writeCustomNS(t, root, "prod", "")
// put a job in jobs/
if err := os.MkdirAll(filepath.Join(root, "prod", "jobs"), 0o755); err != nil {
t.Fatalf("mkdir jobs: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "prod", "jobs", "j1.md"), []byte("x"), 0o644); err != nil {
t.Fatalf("write job: %v", err)
}
rootCmd.SetArgs([]string{"ns", "delete", "prod"})
err := rootCmd.Execute()
if err == nil {
t.Fatal("expected error deleting non-empty namespace, got nil")
}
if !strings.Contains(err.Error(), "refusing to delete") {
t.Errorf("error = %q, want contains 'refusing to delete'", err.Error())
}
}
func TestNSDeleteMissing(t *testing.T) {
root := t.TempDir()
t.Setenv("ORCA_HOME", root)
resetRootFlags(t)
resetNSFlags()
writeDefaultsNS(t, root)
rootCmd.SetArgs([]string{"ns", "delete", "ghost"})
err := rootCmd.Execute()
if err == nil {
t.Fatal("expected error deleting missing namespace, got nil")
}
}
func TestNSInspectHappy(t *testing.T) {
root := t.TempDir()
t.Setenv("ORCA_HOME", root)
resetRootFlags(t)
resetNSFlags()
writeDefaultsNS(t, root)
writeCustomNS(t, root, "prod", "")
rootCmd.SetArgs([]string{"ns", "inspect", "prod"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("ns inspect: %v", err)
}
}
func TestNSInspectJSON(t *testing.T) {
root := t.TempDir()
t.Setenv("ORCA_HOME", root)
resetRootFlags(t)
resetNSFlags()
writeDefaultsNS(t, root)
writeCustomNS(t, root, "prod", "")
var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetArgs([]string{"ns", "inspect", "prod", "--json"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("ns inspect --json: %v", err)
}
var result map[string]any
if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &result); err != nil {
t.Fatalf("unmarshal: %v\n%s", err, buf.String())
}
if result["name"] != "prod" {
t.Errorf("name = %v, want prod", result["name"])
}
chain, _ := result["chain"].([]any)
if len(chain) < 2 {
t.Errorf("chain too short: %v", chain)
}
}
func TestNSInspectMissingNamespace(t *testing.T) {
root := t.TempDir()
t.Setenv("ORCA_HOME", root)
resetRootFlags(t)
resetNSFlags()
writeDefaultsNS(t, root)
rootCmd.SetArgs([]string{"ns", "inspect", "ghost"})
err := rootCmd.Execute()
if err == nil {
t.Fatal("expected error for missing namespace, got nil")
}
}
func TestNSValidateHappy(t *testing.T) {
root := t.TempDir()
t.Setenv("ORCA_HOME", root)
resetRootFlags(t)
resetNSFlags()
writeDefaultsNS(t, root)
writeCustomNS(t, root, "prod", "")
rootCmd.SetArgs([]string{"ns", "validate", "prod"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("ns validate: %v", err)
}
}
func TestNSValidateCycle(t *testing.T) {
root := t.TempDir()
t.Setenv("ORCA_HOME", root)
resetRootFlags(t)
resetNSFlags()
writeDefaultsNS(t, root)
// a -> b, b -> a (cycle)
writeCustomNS(t, root, "a", "[\"b\"]")
writeCustomNS(t, root, "b", "[\"a\"]")
rootCmd.SetArgs([]string{"ns", "validate", "a"})
err := rootCmd.Execute()
if err == nil {
t.Fatal("expected cycle error, got nil")
}
if !strings.Contains(err.Error(), "cycle") {
t.Errorf("error = %q, want contains 'cycle'", err.Error())
}
}
func TestNSValidateMissingParent(t *testing.T) {
root := t.TempDir()
t.Setenv("ORCA_HOME", root)
resetRootFlags(t)
resetNSFlags()
writeDefaultsNS(t, root)
writeCustomNS(t, root, "x", "[\"ghost\"]")
rootCmd.SetArgs([]string{"ns", "validate", "x"})
err := rootCmd.Execute()
if err == nil {
t.Fatal("expected missing-parent error, got nil")
}
if !strings.Contains(err.Error(), "ghost") || !strings.Contains(err.Error(), "not found") {
t.Errorf("error = %q, want contains ghost + not found", err.Error())
}
}
func TestNSValidateMissingNamespace(t *testing.T) {
root := t.TempDir()
t.Setenv("ORCA_HOME", root)
resetRootFlags(t)
resetNSFlags()
writeDefaultsNS(t, root)
rootCmd.SetArgs([]string{"ns", "validate", "ghost"})
err := rootCmd.Execute()
if err == nil {
t.Fatal("expected error for missing namespace, got nil")
}
}
func TestRenderNSMd(t *testing.T) {
body := renderNSMd("foo", []string{"_defaults"}, true, false)
if !strings.Contains(body, "kind: Namespace") {
t.Errorf("missing kind: %s", body)
}
if !strings.Contains(body, "name: foo") {
t.Errorf("missing name: %s", body)
}
if !strings.Contains(body, "inherits_env: true") {
t.Errorf("missing inherits_env true: %s", body)
}
if !strings.Contains(body, "inherits_secrets: false") {
t.Errorf("missing inherits_secrets false: %s", body)
}
}
func TestNSRootRegistered(t *testing.T) {
found := false
for _, c := range rootCmd.Commands() {
if c.Use == "ns" {
found = true
break
}
}
if !found {
t.Errorf("ns command not registered on root")
}
// ensure subcommands present
sub := map[string]bool{}
for _, c := range rootCmd.Commands() {
if c.Use == "ns" {
for _, sc := range c.Commands() {
sub[sc.Use] = true
}
}
}
for _, want := range []string{"list", "create <name>", "delete <name>", "inspect <name>", "validate <name>"} {
if !sub[want] {
t.Errorf("missing ns subcommand %q", want)
}
}
}
func TestNSListNoORCAHOME(t *testing.T) {
// ORCA_HOME points at a nonexistent dir; list should error.
t.Setenv("ORCA_HOME", filepath.Join(t.TempDir(), "nope"))
resetRootFlags(t)
resetNSFlags()
rootCmd.SetArgs([]string{"ns", "list"})
err := rootCmd.Execute()
if err == nil {
t.Fatal("expected error for missing ORCA_HOME, got nil")
}
}
var _ = paths.DefaultNamespace // keep paths import alive
+63 -2
View File
@@ -3,6 +3,7 @@ package config
import (
"fmt"
"os"
"strings"
"github.com/hashicorp/hcl/v2/hclsimple"
)
@@ -33,22 +34,82 @@ type Flags struct {
type Environ map[string]string
// Load is the config dispatcher (R-014). It tries each path in order,
// skipping missing files, and dispatches to the appropriate loader
// based on file extension: .hcl → LoadHCL (legacy, R-013),
// .md → LoadMarkdown (new Markdown-frontmatter loader), and
// .yaml/.yml → LoadMarkdown with an empty body. The first successfully
// decoded file wins. If no path exists or decodes, a zero Config is
// returned.
//
// The signature is preserved from the v0.8 single-loader API so
// internal/cli/root.go requires no changes yet.
func Load(paths ...string) (*Config, error) {
for _, p := range paths {
if _, err := os.Stat(p); err != nil {
continue
}
cfg, err := loadByExtension(p)
if err != nil {
return nil, err
}
return cfg, nil
}
return &Config{}, nil
}
func loadByExtension(p string) (*Config, error) {
ext := strings.ToLower(filepathExt(p))
switch ext {
case ".hcl":
return LoadHCL(p)
case ".md", ".markdown":
return LoadMarkdown(p)
case ".yaml", ".yml":
return LoadMarkdownYAML(p)
default:
// Unknown extension: hclsimple.Decode rejects non-.hcl
// suffixes, so for backward compat with the v0.8 single-loader
// behavior (which assumed HCL), decode the file content as HCL
// against a synthesized .hcl path.
data, err := os.ReadFile(p)
if err != nil {
return nil, fmt.Errorf("read config %s: %w", p, err)
}
var cfg Config
if err := hclsimple.Decode(p, data, nil, &cfg); err != nil {
if err := hclsimple.Decode(p+".hcl", data, nil, &cfg); err != nil {
return nil, fmt.Errorf("decode config %s: %w", p, err)
}
return &cfg, nil
}
return &Config{}, nil
}
// filepathExt is a thin wrapper around filepath.Ext to keep the import
// localized to the dispatcher. Returns the extension including the dot,
// lowercased by the caller.
func filepathExt(p string) string {
i := strings.LastIndex(p, ".")
if i < 0 {
return ""
}
return p[i:]
}
// LoadHCL decodes a legacy HCL config file (R-013).
//
// Deprecated: use LoadMarkdown or the dispatcher. HCL is legacy per
// R-013. Retained for the v0.9 dual-write window (REQ-090); new
// deployments should author config.md with YAML frontmatter.
func LoadHCL(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config %s: %w", path, err)
}
var cfg Config
if err := hclsimple.Decode(path, data, nil, &cfg); err != nil {
return nil, fmt.Errorf("decode config %s: %w", path, err)
}
return &cfg, nil
}
func (c *Config) MergeOverrides(flags Flags, env Environ) *Config {
+111
View File
@@ -0,0 +1,111 @@
package config
import (
"path/filepath"
"testing"
)
func TestLoad_DispatchHCL(t *testing.T) {
p := writeTestFile(t, t.TempDir(), "config.hcl", exampleHCL)
cfg, err := Load(p)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.DBPath != "/tmp/orca/test.db" {
t.Errorf("DBPath=%q", cfg.DBPath)
}
if cfg.NodeCapacity == nil || cfg.NodeCapacity.CPU != 4 {
t.Errorf("NodeCapacity=%+v", cfg.NodeCapacity)
}
}
func TestLoad_DispatchMarkdown(t *testing.T) {
p := writeTestFile(t, t.TempDir(), "config.md", exampleMarkdown)
cfg, err := Load(p)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.DBPath != "/tmp/orca/test.db" {
t.Errorf("DBPath=%q", cfg.DBPath)
}
if cfg.ListenAddr != "127.0.0.1:9999" {
t.Errorf("ListenAddr=%q", cfg.ListenAddr)
}
if cfg.NodeCapacity == nil || cfg.NodeCapacity.CPU != 4 {
t.Errorf("NodeCapacity=%+v", cfg.NodeCapacity)
}
}
func TestLoad_DispatchYAML(t *testing.T) {
body := "listen_addr: 0.0.0.0:5555\ndb_path: /bare.db\nnode_capacity:\n cpu: 2\n memory_mb: 4096\n"
p := writeTestFile(t, t.TempDir(), "config.yaml", body)
cfg, err := Load(p)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.ListenAddr != "0.0.0.0:5555" {
t.Errorf("ListenAddr=%q", cfg.ListenAddr)
}
if cfg.DBPath != "/bare.db" {
t.Errorf("DBPath=%q", cfg.DBPath)
}
if cfg.NodeCapacity == nil || cfg.NodeCapacity.CPU != 2 {
t.Errorf("NodeCapacity=%+v", cfg.NodeCapacity)
}
}
func TestLoad_DispatchYML(t *testing.T) {
body := "listen_addr: 1.2.3.4:9\n"
p := writeTestFile(t, t.TempDir(), "config.yml", body)
cfg, err := Load(p)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.ListenAddr != "1.2.3.4:9" {
t.Errorf("ListenAddr=%q", cfg.ListenAddr)
}
}
func TestLoad_DispatchFirstExistingWins(t *testing.T) {
dir := t.TempDir()
missing := filepath.Join(dir, "missing.md")
existing := writeTestFile(t, dir, "real.hcl", exampleHCL)
cfg, err := Load(missing, existing)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.DBPath != "/tmp/orca/test.db" {
t.Errorf("DBPath=%q", cfg.DBPath)
}
}
func TestLoad_DispatchMissingReturnsZero(t *testing.T) {
cfg, err := Load(filepath.Join(t.TempDir(), "nope.md"))
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg == nil {
t.Fatal("nil config")
}
if cfg.DBPath != "" || cfg.ListenAddr != "" || cfg.NodeCapacity != nil {
t.Errorf("expected zero config, got %+v", cfg)
}
}
func TestLoad_DispatchHCLMalformed(t *testing.T) {
p := writeTestFile(t, t.TempDir(), "bad.hcl", "db_path = ")
if _, err := Load(p); err == nil {
t.Fatal("expected error for malformed HCL")
}
}
func TestLoad_DispatchUnknownExtFallsBackToHCL(t *testing.T) {
p := writeTestFile(t, t.TempDir(), "config.unknown", exampleHCL)
cfg, err := Load(p)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.DBPath != "/tmp/orca/test.db" {
t.Errorf("DBPath=%q", cfg.DBPath)
}
}
+220
View File
@@ -0,0 +1,220 @@
package config
import (
"fmt"
"os"
"strconv"
"strings"
)
// LoadMarkdown decodes a Markdown config file with YAML frontmatter
// (R-014). The file format is:
//
// ---
// listen_addr: 127.0.0.1:9999
// node_capacity:
// cpu: 4
// memory_mb: 8192
// db_path: /tmp/orca/test.db
// ---
//
// body prose (ignored)
//
// The frontmatter parser is a minimal hand-rolled key:value parser
// (no new dependencies; gopkg.in/yaml.v3 is not in go.mod). It supports
// flat scalar keys and one level of nested mapping (for node_capacity).
// The Markdown body after the closing "---" is ignored.
//
// The returned *Config is the same struct the HCL loader produces, so
// downstream consumers are unchanged.
func LoadMarkdown(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config %s: %w", path, err)
}
return parseFrontmatter(string(data), path)
}
// LoadMarkdownYAML decodes a bare YAML file (no Markdown body) using the
// same minimal frontmatter parser. .yaml/.yml files are routed here by
// the dispatcher.
func LoadMarkdownYAML(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config %s: %w", path, err)
}
// Treat the whole file as the frontmatter block (no surrounding ---).
return parseFrontmatterBlock(string(data), path)
}
func parseFrontmatter(content, path string) (*Config, error) {
block, ok := extractFrontmatter(content)
if !ok {
// No frontmatter delimiters: treat whole file as a bare block.
return parseFrontmatterBlock(content, path)
}
return parseFrontmatterBlock(block, path)
}
// extractFrontmatter returns the YAML block between the first pair of
// "---" delimiters and whether a frontmatter block was present.
func extractFrontmatter(content string) (string, bool) {
trimmed := strings.TrimLeft(content, "\r\n\t ")
if !strings.HasPrefix(trimmed, "---") {
return "", false
}
// Skip the opening delimiter line.
rest := trimmed[3:]
rest = strings.TrimLeft(rest, "\r\n")
// Find the closing delimiter line.
idx := strings.Index(rest, "\n---")
if idx < 0 {
return "", false
}
return rest[:idx], true
}
// parseFrontmatterBlock parses a minimal YAML-ish block into *Config.
// Supported shapes:
//
// key: value
// node_capacity:
// cpu: 4
// memory_mb: 8192
//
// Comments (# ...) and blank lines are ignored. Quoted scalar values
// ("..." or '...') are unwrapped. No flow collections, anchors, or
// multi-line strings are supported — by design, to avoid adding a YAML
// dependency for this small config surface.
func parseFrontmatterBlock(block, path string) (*Config, error) {
cfg := &Config{}
var inCapacity bool
lines := strings.Split(block, "\n")
for lineNo, raw := range lines {
line := stripComment(raw)
if strings.TrimSpace(line) == "" {
continue
}
indent := countIndent(line)
trimmed := strings.TrimSpace(line)
// A top-level key (no leading indent).
if indent == 0 {
inCapacity = false
key, val, ok := splitKV(trimmed)
if !ok {
continue
}
if val == "" {
// key with no value → nested mapping header (e.g. node_capacity:)
if key == "node_capacity" {
cfg.NodeCapacity = &CapacityConfig{}
inCapacity = true
}
continue
}
applyScalar(cfg, key, val, path, lineNo)
continue
}
// Indented line under a nested mapping.
if inCapacity && cfg.NodeCapacity != nil {
key, val, hasVal := splitKV(trimmed)
if !hasVal {
continue
}
switch key {
case "cpu":
if n, err := strconv.Atoi(strings.TrimSpace(val)); err == nil {
cfg.NodeCapacity.CPU = n
}
case "memory_mb":
if n, err := strconv.Atoi(strings.TrimSpace(val)); err == nil {
cfg.NodeCapacity.MemoryMB = n
}
}
}
}
return cfg, nil
}
func applyScalar(cfg *Config, key, val, path string, lineNo int) {
val = strings.TrimSpace(val)
switch key {
case "db_path":
cfg.DBPath = unquote(val)
case "listen_addr":
cfg.ListenAddr = unquote(val)
case "ca_path":
cfg.CAPath = unquote(val)
case "server_cert_path":
cfg.ServerCertPath = unquote(val)
case "server_key_path":
cfg.ServerKeyPath = unquote(val)
}
_ = path
_ = lineNo
}
func splitKV(s string) (key, val string, ok bool) {
idx := strings.Index(s, ":")
if idx < 0 {
return "", "", false
}
key = strings.TrimSpace(s[:idx])
val = strings.TrimSpace(s[idx+1:])
if key == "" {
return "", "", false
}
return key, val, true
}
func countIndent(s string) int {
n := 0
for _, r := range s {
if r == ' ' || r == '\t' {
n++
continue
}
break
}
return n
}
func stripComment(s string) string {
// Strip inline comments not inside quotes. Minimal: only strip
// when the '#' is preceded by whitespace or at line start.
inSingle := false
inDouble := false
for i := 0; i < len(s); i++ {
c := s[i]
switch c {
case '\'':
if !inDouble {
inSingle = !inSingle
}
case '"':
if !inSingle {
inDouble = !inDouble
}
case '#':
if !inSingle && !inDouble {
if i == 0 || s[i-1] == ' ' || s[i-1] == '\t' {
return s[:i]
}
}
}
}
return s
}
func unquote(s string) string {
if len(s) >= 2 {
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
return s[1 : len(s)-1]
}
}
return s
}
+186
View File
@@ -0,0 +1,186 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func writeTestFile(t *testing.T, dir, name, content string) string {
t.Helper()
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte(content), 0644); err != nil {
t.Fatalf("write %s: %v", p, err)
}
return p
}
const exampleMarkdown = `---
listen_addr: "127.0.0.1:9999"
db_path: "/tmp/orca/test.db"
ca_path: "/tmp/orca/ca.crt"
server_cert_path: "/tmp/orca/server.crt"
server_key_path: "/tmp/orca/server.key"
node_capacity:
cpu: 4
memory_mb: 8192
---
# Orca config
This is prose body and is ignored by the loader.
`
func TestLoadMarkdown_Full(t *testing.T) {
p := writeTestFile(t, t.TempDir(), "config.md", exampleMarkdown)
cfg, err := LoadMarkdown(p)
if err != nil {
t.Fatalf("LoadMarkdown: %v", err)
}
if cfg.DBPath != "/tmp/orca/test.db" {
t.Errorf("DBPath=%q", cfg.DBPath)
}
if cfg.ListenAddr != "127.0.0.1:9999" {
t.Errorf("ListenAddr=%q", cfg.ListenAddr)
}
if cfg.CAPath != "/tmp/orca/ca.crt" {
t.Errorf("CAPath=%q", cfg.CAPath)
}
if cfg.ServerCertPath != "/tmp/orca/server.crt" {
t.Errorf("ServerCertPath=%q", cfg.ServerCertPath)
}
if cfg.ServerKeyPath != "/tmp/orca/server.key" {
t.Errorf("ServerKeyPath=%q", cfg.ServerKeyPath)
}
if cfg.NodeCapacity == nil {
t.Fatal("NodeCapacity nil")
}
if cfg.NodeCapacity.CPU != 4 {
t.Errorf("CPU=%d", cfg.NodeCapacity.CPU)
}
if cfg.NodeCapacity.MemoryMB != 8192 {
t.Errorf("MemoryMB=%d", cfg.NodeCapacity.MemoryMB)
}
}
func TestLoadMarkdown_NoFrontmatter(t *testing.T) {
// No delimiters: whole file treated as a bare YAML block.
body := "listen_addr: 0.0.0.0:1234\ndb_path: /x/y.db\n"
p := writeTestFile(t, t.TempDir(), "config.md", body)
cfg, err := LoadMarkdown(p)
if err != nil {
t.Fatalf("LoadMarkdown: %v", err)
}
if cfg.ListenAddr != "0.0.0.0:1234" {
t.Errorf("ListenAddr=%q", cfg.ListenAddr)
}
if cfg.DBPath != "/x/y.db" {
t.Errorf("DBPath=%q", cfg.DBPath)
}
}
func TestLoadMarkdown_OnlyBody(t *testing.T) {
body := `---
---
# Just prose, no keys
`
p := writeTestFile(t, t.TempDir(), "config.md", body)
cfg, err := LoadMarkdown(p)
if err != nil {
t.Fatalf("LoadMarkdown: %v", err)
}
if cfg.DBPath != "" || cfg.ListenAddr != "" || cfg.NodeCapacity != nil {
t.Errorf("expected zero config, got %+v", cfg)
}
}
func TestLoadMarkdown_CommentsAndBlanks(t *testing.T) {
body := `---
# a comment
listen_addr: "127.0.0.1:9999"
db_path: "/tmp/orca/test.db" # inline comment
node_capacity:
cpu: 4 # cores
memory_mb: 8192
---
`
p := writeTestFile(t, t.TempDir(), "config.md", body)
cfg, err := LoadMarkdown(p)
if err != nil {
t.Fatalf("LoadMarkdown: %v", err)
}
if cfg.ListenAddr != "127.0.0.1:9999" {
t.Errorf("ListenAddr=%q", cfg.ListenAddr)
}
if cfg.DBPath != "/tmp/orca/test.db" {
t.Errorf("DBPath=%q", cfg.DBPath)
}
if cfg.NodeCapacity == nil || cfg.NodeCapacity.CPU != 4 || cfg.NodeCapacity.MemoryMB != 8192 {
t.Errorf("NodeCapacity=%+v", cfg.NodeCapacity)
}
}
func TestLoadMarkdownYAML_Bare(t *testing.T) {
body := "listen_addr: 0.0.0.0:5555\ndb_path: /bare.db\nnode_capacity:\n cpu: 2\n memory_mb: 4096\n"
p := writeTestFile(t, t.TempDir(), "config.yaml", body)
cfg, err := LoadMarkdownYAML(p)
if err != nil {
t.Fatalf("LoadMarkdownYAML: %v", err)
}
if cfg.ListenAddr != "0.0.0.0:5555" {
t.Errorf("ListenAddr=%q", cfg.ListenAddr)
}
if cfg.DBPath != "/bare.db" {
t.Errorf("DBPath=%q", cfg.DBPath)
}
if cfg.NodeCapacity == nil || cfg.NodeCapacity.CPU != 2 || cfg.NodeCapacity.MemoryMB != 4096 {
t.Errorf("NodeCapacity=%+v", cfg.NodeCapacity)
}
}
func TestLoadMarkdown_ReadError(t *testing.T) {
missing := filepath.Join(t.TempDir(), "nope.md")
if _, err := LoadMarkdown(missing); err == nil {
t.Fatal("expected error for missing file")
}
}
func TestExtractFrontmatter(t *testing.T) {
cases := []struct {
name string
input string
block string
present bool
}{
{"standard", "---\nkey: val\n---\nbody", "key: val", true},
{"leading-blanks", "\n\n---\nkey: val\n---\n", "key: val", true},
{"no-delimiters", "key: val\n", "key: val", false},
{"only-open", "---\nkey: val\n", "key: val", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
block, ok := extractFrontmatter(tc.input)
if ok != tc.present {
t.Errorf("present=%v want %v", ok, tc.present)
}
if tc.present && block != tc.block {
t.Errorf("block=%q want %q", block, tc.block)
}
})
}
}
func TestUnquote(t *testing.T) {
if got, want := unquote(`"hello"`), "hello"; got != want {
t.Errorf("unquote double = %q want %q", got, want)
}
if got, want := unquote(`'hello'`), "hello"; got != want {
t.Errorf("unquote single = %q want %q", got, want)
}
if got, want := unquote("bare"), "bare"; got != want {
t.Errorf("unquote bare = %q want %q", got, want)
}
}
+308
View File
@@ -0,0 +1,308 @@
package ns
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// ParseNSMd reads an ns.md file, extracts the YAML frontmatter, and
// parses it into a *NSConfig. The body after the closing `---` is
// discarded (namespace declarations do not require body preservation
// like jobspecs do under R-015; we keep the parser minimal and
// consistent with internal/config/markdown.go).
//
// Frontmatter keys (R-014):
//
// kind: Namespace (required; must be "Namespace")
// name: <ns-name> (required)
// parents: ["a", "b"] (optional; default empty)
// inherits_env: true (optional; default true)
// inherits_secrets: true (optional; default true)
// quota: {...} (optional; parsed but not surfaced here)
// acl: {...} (optional; parsed but not surfaced here)
//
// The parser is a minimal hand-rolled YAML-ish key:value reader (no
// new dependencies; gopkg.in/yaml.v3 is intentionally NOT added). It
// supports flat scalar keys and the inline flow-array form
// `["a", "b"]` for `parents`. Nested mappings (quota, acl) are
// recognized as keys but their contents are currently ignored — they
// are reserved for later phases.
func ParseNSMd(path string) (*NSConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
}
content := string(data)
block, ok := extractFrontmatter(content)
if !ok {
return nil, fmt.Errorf("parse %s: missing frontmatter", path)
}
if strings.TrimSpace(block) == "" {
return nil, fmt.Errorf("parse %s: missing frontmatter", path)
}
cfg, err := parseNSFrontmatter(block, path)
if err != nil {
return nil, err
}
if cfg.Name == "" {
return nil, fmt.Errorf("parse %s: missing name", path)
}
return cfg, nil
}
// extractFrontmatter returns the YAML block between the first pair of
// `---` delimiters and whether a frontmatter block was present.
func extractFrontmatter(content string) (string, bool) {
trimmed := strings.TrimLeft(content, "\r\n\t ")
if !strings.HasPrefix(trimmed, "---") {
return "", false
}
rest := trimmed[3:]
rest = strings.TrimLeft(rest, "\r\n")
idx := strings.Index(rest, "\n---")
if idx < 0 {
return "", false
}
return rest[:idx], true
}
// parseNSFrontmatter parses a minimal YAML-ish frontmatter block into
// a *NSConfig. See ParseNSMd for the supported keys.
func parseNSFrontmatter(block, path string) (*NSConfig, error) {
cfg := &NSConfig{
InheritsEnv: true,
InheritsSecrets: true,
}
kind := ""
lines := strings.Split(block, "\n")
for lineNo, raw := range lines {
line := stripNSComment(raw)
if strings.TrimSpace(line) == "" {
continue
}
if countIndent(line) > 0 {
// Indented line under a nested mapping header (quota, acl).
// Recognized but ignored at this phase.
continue
}
key, val, ok := splitKV(strings.TrimSpace(line))
if !ok {
return nil, fmt.Errorf("parse %s: line %d: malformed key:value", path, lineNo+1)
}
switch key {
case "kind":
kind = strings.TrimSpace(unquote(val))
case "name":
cfg.Name = strings.TrimSpace(unquote(val))
case "parents":
parents, err := parseStringArray(val)
if err != nil {
return nil, fmt.Errorf("parse %s: line %d: parents: %w", path, lineNo+1, err)
}
cfg.Parents = parents
case "inherits_env":
cfg.InheritsEnv = parseBool(val)
case "inherits_secrets":
cfg.InheritsSecrets = parseBool(val)
case "quota", "acl":
// Reserved nested-mapping keys; recognized, contents ignored.
default:
// Unknown keys are ignored (forward-compat with future
// frontmatter additions).
}
}
if kind == "" {
return nil, fmt.Errorf("parse %s: missing kind", path)
}
if kind != "Namespace" {
return nil, fmt.Errorf("parse %s: kind %q is not %q", path, kind, "Namespace")
}
return cfg, nil
}
// parseStringArray parses an inline YAML flow-array of scalars, e.g.
// `["a", "b"]` or `['a', 'b']` or `[a, b]`. Returns an error if the
// value is not a flow-array. Empty array `[]` returns nil.
func parseStringArray(val string) ([]string, error) {
val = strings.TrimSpace(val)
if val == "" {
return nil, nil
}
if !strings.HasPrefix(val, "[") || !strings.HasSuffix(val, "]") {
return nil, fmt.Errorf("expected [..] array, got %q", val)
}
inner := strings.TrimSpace(val[1 : len(val)-1])
if inner == "" {
return nil, nil
}
parts := splitFlowItems(inner)
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
out = append(out, unquote(p))
}
return out, nil
}
// splitFlowItems splits a comma-separated flow-array body, respecting
// single and double quotes.
func splitFlowItems(s string) []string {
var out []string
inSingle := false
inDouble := false
start := 0
for i := 0; i < len(s); i++ {
c := s[i]
switch c {
case '\'':
if !inDouble {
inSingle = !inSingle
}
case '"':
if !inSingle {
inDouble = !inDouble
}
case ',':
if !inSingle && !inDouble {
out = append(out, s[start:i])
start = i + 1
}
}
}
out = append(out, s[start:])
return out
}
// parseBool parses a YAML-ish bool (true/false/yes/no), defaulting to
// true for empty (matches the inherits_* defaults).
func parseBool(val string) bool {
switch strings.ToLower(strings.TrimSpace(unquote(val))) {
case "false", "no", "off", "0":
return false
default:
return true
}
}
// ParseNSMdDir walks `<root>/*/ns.md`, parses each, and returns the
// config map keyed by namespace name. The `cluster` directory is
// skipped (it is not a namespace). The `_defaults` namespace MUST
// exist; if missing, an error is returned.
func ParseNSMdDir(root string) (map[string]*NSConfig, error) {
entries, err := os.ReadDir(root)
if err != nil {
return nil, fmt.Errorf("read namespace root %s: %w", root, err)
}
configs := make(map[string]*NSConfig)
var found []string
for _, ent := range entries {
if !ent.IsDir() {
continue
}
if ent.Name() == "cluster" {
continue
}
nsMd := filepath.Join(root, ent.Name(), "ns.md")
info, err := os.Stat(nsMd)
if err != nil || info.IsDir() {
continue
}
cfg, err := ParseNSMd(nsMd)
if err != nil {
return nil, err
}
// The directory name and the frontmatter `name` should match;
// we key by the frontmatter name (canonical) but also accept
// the directory name if frontmatter name is missing (the
// parser already errors on missing name, so this is defensive).
key := cfg.Name
if key == "" {
key = ent.Name()
}
if _, dup := configs[key]; dup {
return nil, fmt.Errorf("duplicate namespace %q (from %s)", key, nsMd)
}
configs[key] = cfg
found = append(found, key)
}
if _, ok := configs[defaultsName]; !ok {
sort.Strings(found)
names := strings.Join(found, ", ")
if names == "" {
names = "(none)"
}
return nil, fmt.Errorf("namespace root %s: implicit root %q not found (found: %s)", root, defaultsName, names)
}
return configs, nil
}
func countIndent(s string) int {
n := 0
for _, r := range s {
if r == ' ' || r == '\t' {
n++
continue
}
break
}
return n
}
func splitKV(s string) (key, val string, ok bool) {
idx := strings.Index(s, ":")
if idx < 0 {
return "", "", false
}
key = strings.TrimSpace(s[:idx])
val = strings.TrimSpace(s[idx+1:])
if key == "" {
return "", "", false
}
return key, val, true
}
func stripNSComment(s string) string {
inSingle := false
inDouble := false
for i := 0; i < len(s); i++ {
c := s[i]
switch c {
case '\'':
if !inDouble {
inSingle = !inSingle
}
case '"':
if !inSingle {
inDouble = !inDouble
}
case '#':
if !inSingle && !inDouble {
if i == 0 || s[i-1] == ' ' || s[i-1] == '\t' {
return s[:i]
}
}
}
}
return s
}
func unquote(s string) string {
if len(s) >= 2 {
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
return s[1 : len(s)-1]
}
}
return s
}
+227
View File
@@ -0,0 +1,227 @@
package ns
import (
"os"
"path/filepath"
"strings"
"testing"
)
func writeNSMd(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
const validNSMd = `---
kind: Namespace
name: prod
parents: ["_defaults"]
inherits_env: true
inherits_secrets: true
quota:
cpu: 4
acl:
admin: ops
---
# Prod namespace
This body is ignored.
`
func TestParseNSMdValid(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "ns.md")
writeNSMd(t, path, validNSMd)
cfg, err := ParseNSMd(path)
if err != nil {
t.Fatalf("ParseNSMd: %v", err)
}
if cfg.Name != "prod" {
t.Errorf("name = %q, want prod", cfg.Name)
}
if !eqSlice(cfg.Parents, []string{"_defaults"}) {
t.Errorf("parents = %v, want [_defaults]", cfg.Parents)
}
if !cfg.InheritsEnv || !cfg.InheritsSecrets {
t.Errorf("inherits_env=%v inherits_secrets=%v, want both true", cfg.InheritsEnv, cfg.InheritsSecrets)
}
}
func TestParseNSMdMissingFrontmatter(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "ns.md")
writeNSMd(t, path, "# just a body, no frontmatter\n")
_, err := ParseNSMd(path)
if err == nil {
t.Fatal("expected missing frontmatter error, got nil")
}
if !strings.Contains(err.Error(), "missing frontmatter") {
t.Errorf("error = %q, want contains 'missing frontmatter'", err.Error())
}
}
func TestParseNSMdEmptyFrontmatter(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "ns.md")
writeNSMd(t, path, "---\n---\nbody\n")
_, err := ParseNSMd(path)
if err == nil {
t.Fatal("expected error for empty frontmatter, got nil")
}
}
func TestParseNSMdWrongKind(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "ns.md")
writeNSMd(t, path, "---\nkind: Job\nname: x\n---\n")
_, err := ParseNSMd(path)
if err == nil {
t.Fatal("expected wrong-kind error, got nil")
}
if !strings.Contains(err.Error(), "not \"Namespace\"") {
t.Errorf("error = %q, want contains 'is not \"Namespace\"'", err.Error())
}
}
func TestParseNSMdMissingKind(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "ns.md")
writeNSMd(t, path, "---\nname: x\n---\n")
_, err := ParseNSMd(path)
if err == nil {
t.Fatal("expected missing kind error, got nil")
}
if !strings.Contains(err.Error(), "missing kind") {
t.Errorf("error = %q, want contains 'missing kind'", err.Error())
}
}
func TestParseNSMdMissingName(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "ns.md")
writeNSMd(t, path, "---\nkind: Namespace\n---\n")
_, err := ParseNSMd(path)
if err == nil {
t.Fatal("expected missing name error, got nil")
}
if !strings.Contains(err.Error(), "missing name") {
t.Errorf("error = %q, want contains 'missing name'", err.Error())
}
}
func TestParseNSMdParentsUnquoted(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "ns.md")
writeNSMd(t, path, "---\nkind: Namespace\nname: x\nparents: [a, b]\n---\n")
cfg, err := ParseNSMd(path)
if err != nil {
t.Fatalf("ParseNSMd: %v", err)
}
if !eqSlice(cfg.Parents, []string{"a", "b"}) {
t.Errorf("parents = %v, want [a b]", cfg.Parents)
}
}
func TestParseNSMdParentsEmpty(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "ns.md")
writeNSMd(t, path, "---\nkind: Namespace\nname: x\nparents: []\n---\n")
cfg, err := ParseNSMd(path)
if err != nil {
t.Fatalf("ParseNSMd: %v", err)
}
if len(cfg.Parents) != 0 {
t.Errorf("parents = %v, want empty", cfg.Parents)
}
}
func TestParseNSMdInheritsFalse(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "ns.md")
writeNSMd(t, path, "---\nkind: Namespace\nname: x\ninherits_env: false\ninherits_secrets: no\n---\n")
cfg, err := ParseNSMd(path)
if err != nil {
t.Fatalf("ParseNSMd: %v", err)
}
if cfg.InheritsEnv {
t.Errorf("inherits_env should be false")
}
if cfg.InheritsSecrets {
t.Errorf("inherits_secrets should be false")
}
}
func TestParseNSMdMissingFile(t *testing.T) {
_, err := ParseNSMd(filepath.Join(t.TempDir(), "nope.md"))
if err == nil {
t.Fatal("expected error for missing file")
}
}
func TestParseNSMdDirHappy(t *testing.T) {
root := t.TempDir()
writeNSMd(t, filepath.Join(root, "_defaults", "ns.md"), "---\nkind: Namespace\nname: _defaults\n---\n")
writeNSMd(t, filepath.Join(root, "prod", "ns.md"), validNSMd)
cfgs, err := ParseNSMdDir(root)
if err != nil {
t.Fatalf("ParseNSMdDir: %v", err)
}
if _, ok := cfgs["_defaults"]; !ok {
t.Errorf("missing _defaults in %v", cfgs)
}
if _, ok := cfgs["prod"]; !ok {
t.Errorf("missing prod in %v", cfgs)
}
}
func TestParseNSMdDirMissingDefaults(t *testing.T) {
root := t.TempDir()
writeNSMd(t, filepath.Join(root, "prod", "ns.md"), validNSMd)
_, err := ParseNSMdDir(root)
if err == nil {
t.Fatal("expected missing _defaults error, got nil")
}
if !strings.Contains(err.Error(), "_defaults") {
t.Errorf("error = %q, want contains _defaults", err.Error())
}
}
func TestParseNSMdDirSkipsCluster(t *testing.T) {
root := t.TempDir()
writeNSMd(t, filepath.Join(root, "_defaults", "ns.md"), "---\nkind: Namespace\nname: _defaults\n---\n")
// cluster/ contains a ns.md-shaped file but must be skipped.
writeNSMd(t, filepath.Join(root, "cluster", "ns.md"), "---\nkind: Namespace\nname: cluster\n---\n")
cfgs, err := ParseNSMdDir(root)
if err != nil {
t.Fatalf("ParseNSMdDir: %v", err)
}
if _, ok := cfgs["cluster"]; ok {
t.Errorf("cluster should be skipped, present in %v", cfgs)
}
}
func TestParseNSMdDirNoFiles(t *testing.T) {
root := t.TempDir()
_, err := ParseNSMdDir(root)
if err == nil {
t.Fatal("expected missing _defaults error on empty dir, got nil")
}
}
func TestParseNSMdDirNotADir(t *testing.T) {
tmp := t.TempDir()
// Create a file with the same name as the expected root dir.
root := filepath.Join(tmp, "notadir")
writeNSMd(t, root, "x")
_, err := ParseNSMdDir(root)
if err == nil {
t.Fatal("expected error for non-dir root")
}
}
+252
View File
@@ -0,0 +1,252 @@
// Package ns implements the namespace inheritance resolver (REQ-082)
// and the ns.md frontmatter parser used by `orca ns` CLI subcommands.
//
// The resolver is a PURE function (no I/O): it takes a map of parsed
// namespace configs keyed by name and returns a map of resolved
// namespaces with merged env and unioned constraints. The inheritance
// model is:
//
// - Each namespace declares zero or more parents in `ns.md`
// frontmatter (`parents: ["ns1", "ns2"]`).
// - The implicit root namespace `_defaults` (R-002 D-159) always
// exists and has no parents; it is ALWAYS appended as the last
// element of the chain (D-185).
// - Opting out of `_defaults` is impossible (D-187): even with
// `parents: []`, `_defaults` still appears at the end of the chain.
// - Merge semantics: child overrides parent for scalars (env keys);
// arrays union (child constraints add to parent constraints, with
// duplicates removed, order: most-specific first).
// - The chain order is most-specific first, `_defaults` last.
// - `_defaults` may be listed explicitly in `parents`; the explicit
// listing is de-duped silently (still appears once, at the end).
// - Misordering (`parents: ["_defaults", "x"]`) is rejected: an
// explicit `_defaults` entry must be the only entry (or omitted).
// - Cycle detection uses DFS with a visited set; a cycle returns an
// error with the cycle path.
// - Missing parents return "parent X not found".
package ns
import (
"fmt"
"sort"
)
const defaultsName = "_defaults"
// NSConfig is a parsed namespace declaration from ns.md frontmatter.
// The resolver consumes this; the parser populates it.
type NSConfig struct {
Name string
Parents []string
Env map[string]string
Constraints []string
InheritsEnv bool
InheritsSecrets bool
}
// ResolvedNS is the output of the resolver: the namespace with its
// fully-merged env and unioned constraints, plus the ordered
// inheritance chain (most-specific first, `_defaults` last).
type ResolvedNS struct {
Name string
Chain []string
Env map[string]string
Constraints []string
}
// Resolve walks the parent chain for each namespace, merges env (child
// wins scalars), unions constraints (child adds to parent, de-duped),
// and detects cycles. It is PURE (no I/O). The empty-configs case
// returns an empty map and no error.
//
// The `_defaults` namespace is ALWAYS the last element of every chain
// (D-185); opting out is impossible (D-187). An explicit `_defaults`
// entry in `parents` is de-duped silently. Misordering (e.g.
// `parents: ["_defaults", "x"]`) is rejected.
func Resolve(configs map[string]*NSConfig) (map[string]*ResolvedNS, error) {
if len(configs) == 0 {
return map[string]*ResolvedNS{}, nil
}
// Validate each config's parents reference exists and the
// _defaults entry (if explicit) is the only entry.
for name, cfg := range configs {
if cfg == nil {
return nil, fmt.Errorf("namespace %q has nil config", name)
}
for _, p := range cfg.Parents {
if p == defaultsName {
// Explicit _defaults must be the only parent.
if len(cfg.Parents) != 1 {
return nil, fmt.Errorf("namespace %q: %s must be the only parent if listed explicitly (misordering rejected)", name, defaultsName)
}
continue
}
if _, ok := configs[p]; !ok {
return nil, fmt.Errorf("namespace %q: parent %q not found", name, p)
}
}
}
// `_defaults` must be present in the configs map (the parser
// enforces this for ParseNSMdDir; Resolve trusts its input but
// still requires _defaults to exist for chain assembly).
if _, ok := configs[defaultsName]; !ok {
return nil, fmt.Errorf("namespace %q not found (implicit root must be present)", defaultsName)
}
resolved := make(map[string]*ResolvedNS, len(configs))
// Resolve in deterministic order for stable error reporting.
names := make([]string, 0, len(configs))
for n := range configs {
names = append(names, n)
}
sort.Strings(names)
for _, name := range names {
r, err := resolveOne(configs, name)
if err != nil {
return nil, err
}
resolved[name] = r
}
return resolved, nil
}
// resolveOne resolves a single namespace. The chain is built by walking
// parents depth-first in POST-order (least-specific first), then
// reversing so the returned chain is most-specific first with
// `_defaults` last (D-185). Cycle detection uses a visiting set.
func resolveOne(configs map[string]*NSConfig, name string) (*ResolvedNS, error) {
post, err := buildChain(configs, name)
if err != nil {
return nil, err
}
// post is least-specific first; reverse to most-specific first.
reverseStrings(post)
chain := post
// Env: child (most-specific) wins. Walk least-specific to
// most-specific (end -> beginning) so later writes override.
env := make(map[string]string)
for i := len(chain) - 1; i >= 0; i-- {
c := configs[chain[i]]
if c == nil {
continue
}
for k, v := range c.Env {
env[k] = v
}
}
// Constraints: union, child (most-specific) first. Walk the chain
// front-to-back (most-specific first) and append unseen items.
constraintsSeen := make(map[string]bool)
var constraints []string
for _, ns := range chain {
c := configs[ns]
if c == nil {
continue
}
for _, con := range c.Constraints {
if !constraintsSeen[con] {
constraintsSeen[con] = true
constraints = append(constraints, con)
}
}
}
return &ResolvedNS{
Name: name,
Chain: chain,
Env: env,
Constraints: constraints,
}, nil
}
// buildChain walks parents depth-first and returns the chain in
// POST-order (least-specific first, `_defaults` first). The caller
// reverses to get most-specific first. Cycle detection uses the
// visiting set: a node currently being walked indicates a back-edge.
func buildChain(configs map[string]*NSConfig, name string) ([]string, error) {
var post []string
seen := make(map[string]bool) // final chain membership (de-dup)
visiting := make(map[string]bool)
if err := dfsChain(configs, name, &post, seen, visiting); err != nil {
return nil, err
}
// `_defaults` is the implicit root: it must be the FIRST element
// in post-order (so it ends up LAST after reversal). If it was not
// reached via parents (no explicit listing and no chain leads to
// it), prepend it.
if !seen[defaultsName] {
post = append([]string{defaultsName}, post...)
seen[defaultsName] = true
}
return post, nil
}
// dfsChain appends each node AFTER its parents (post-order), producing
// least-specific first. Cycle detection uses the visiting set.
func dfsChain(configs map[string]*NSConfig, name string, post *[]string, seen, visiting map[string]bool) error {
if visiting[name] {
return fmt.Errorf("cycle detected: %s", cyclePath(visiting, configs, name))
}
if seen[name] {
return nil
}
visiting[name] = true
cfg := configs[name]
if cfg != nil {
for _, p := range cfg.Parents {
if err := dfsChain(configs, p, post, seen, visiting); err != nil {
return err
}
}
}
delete(visiting, name)
seen[name] = true
*post = append(*post, name)
return nil
}
// cyclePath reconstructs a readable cycle path from the visiting set.
// Since visiting is a set (not ordered), we reconstruct by re-walking
// parents from the offending node until we revisit it.
func cyclePath(visiting map[string]bool, configs map[string]*NSConfig, start string) string {
// Walk parents from start, collecting names until we hit start
// again or run out.
var path []string
cur := start
for i := 0; i < len(visiting)+1; i++ {
path = append(path, cur)
cfg := configs[cur]
if cfg == nil || len(cfg.Parents) == 0 {
break
}
next := cfg.Parents[0]
if next == start {
path = append(path, next)
break
}
cur = next
}
return joinArrows(path)
}
func joinArrows(parts []string) string {
out := ""
for i, p := range parts {
if i > 0 {
out += " -> "
}
out += p
}
return out
}
func reverseStrings(s []string) {
for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
s[i], s[j] = s[j], s[i]
}
}
+231
View File
@@ -0,0 +1,231 @@
package ns
import (
"strings"
"testing"
)
func TestResolveEmptyConfigs(t *testing.T) {
out, err := Resolve(map[string]*NSConfig{})
if err != nil {
t.Fatalf("Resolve empty: unexpected error: %v", err)
}
if len(out) != 0 {
t.Fatalf("Resolve empty: want empty map, got %d entries", len(out))
}
}
func TestResolveSingleNoParents(t *testing.T) {
cfgs := map[string]*NSConfig{
defaultsName: {Name: defaultsName, Env: map[string]string{"A": "1"}},
"x": {Name: "x", Env: map[string]string{"B": "2"}},
}
out, err := Resolve(cfgs)
if err != nil {
t.Fatalf("Resolve: %v", err)
}
r := out["x"]
if r == nil {
t.Fatal("missing resolved x")
}
if !eqSlice(r.Chain, []string{"x", defaultsName}) {
t.Errorf("chain = %v, want [x _defaults]", r.Chain)
}
if r.Env["A"] != "1" || r.Env["B"] != "2" {
t.Errorf("env = %v, want A=1 B=2", r.Env)
}
}
func TestResolveChildOverridesParentScalar(t *testing.T) {
cfgs := map[string]*NSConfig{
defaultsName: {Name: defaultsName, Env: map[string]string{"K": "parent"}},
"child": {Name: "child", Parents: []string{defaultsName}, Env: map[string]string{"K": "child"}},
}
out, err := Resolve(cfgs)
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if got := out["child"].Env["K"]; got != "child" {
t.Errorf("child K = %q, want %q (child overrides parent)", got, "child")
}
}
func TestResolveArraysUnion(t *testing.T) {
cfgs := map[string]*NSConfig{
defaultsName: {Name: defaultsName, Constraints: []string{"a", "b"}},
"x": {Name: "x", Parents: []string{defaultsName}, Constraints: []string{"c", "a"}},
}
out, err := Resolve(cfgs)
if err != nil {
t.Fatalf("Resolve: %v", err)
}
c := out["x"].Constraints
// Union de-duped; most-specific (x) first.
if !eqSlice(c, []string{"c", "a", "b"}) {
t.Errorf("constraints = %v, want [c a b]", c)
}
}
func TestResolveDefaultsImplicitLast(t *testing.T) {
cfgs := map[string]*NSConfig{
defaultsName: {Name: defaultsName},
"mid": {Name: "mid", Parents: []string{defaultsName}},
"top": {Name: "top", Parents: []string{"mid"}},
}
out, err := Resolve(cfgs)
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if !eqSlice(out["top"].Chain, []string{"top", "mid", defaultsName}) {
t.Errorf("top chain = %v, want [top mid _defaults]", out["top"].Chain)
}
if !eqSlice(out["mid"].Chain, []string{"mid", defaultsName}) {
t.Errorf("mid chain = %v, want [mid _defaults]", out["mid"].Chain)
}
}
func TestResolveDefaultsDedupExplicit(t *testing.T) {
cfgs := map[string]*NSConfig{
defaultsName: {Name: defaultsName, Env: map[string]string{"D": "1"}},
"x": {Name: "x", Parents: []string{defaultsName}},
}
out, err := Resolve(cfgs)
if err != nil {
t.Fatalf("Resolve: %v", err)
}
// _defaults appears exactly once.
count := 0
for _, c := range out["x"].Chain {
if c == defaultsName {
count++
}
}
if count != 1 {
t.Errorf("_defaults appears %d times in chain %v, want 1", count, out["x"].Chain)
}
}
func TestResolveMisorderingRejected(t *testing.T) {
cfgs := map[string]*NSConfig{
defaultsName: {Name: defaultsName},
"x": {Name: "x"},
"y": {Name: "y", Parents: []string{defaultsName, "x"}},
}
_, err := Resolve(cfgs)
if err == nil {
t.Fatal("expected misordering error, got nil")
}
if !strings.Contains(err.Error(), "must be the only parent") {
t.Errorf("error = %q, want misordering message", err.Error())
}
}
func TestResolveOptOutImpossible(t *testing.T) {
cfgs := map[string]*NSConfig{
defaultsName: {Name: defaultsName, Env: map[string]string{"ROOT": "1"}},
"x": {Name: "x", Parents: nil},
}
out, err := Resolve(cfgs)
if err != nil {
t.Fatalf("Resolve: %v", err)
}
r := out["x"]
last := r.Chain[len(r.Chain)-1]
if last != defaultsName {
t.Errorf("last chain element = %q, want %q (opt-out impossible)", last, defaultsName)
}
if r.Env["ROOT"] != "1" {
t.Errorf("env should inherit from _defaults: ROOT=%q", r.Env["ROOT"])
}
}
func TestResolveCycleDetection(t *testing.T) {
cfgs := map[string]*NSConfig{
defaultsName: {Name: defaultsName},
"a": {Name: "a", Parents: []string{"b"}},
"b": {Name: "b", Parents: []string{"a"}},
}
_, err := Resolve(cfgs)
if err == nil {
t.Fatal("expected cycle error, got nil")
}
if !strings.Contains(err.Error(), "cycle") {
t.Errorf("error = %q, want cycle message", err.Error())
}
}
func TestResolveMissingParent(t *testing.T) {
cfgs := map[string]*NSConfig{
defaultsName: {Name: defaultsName},
"a": {Name: "a", Parents: []string{"ghost"}},
}
_, err := Resolve(cfgs)
if err == nil {
t.Fatal("expected missing-parent error, got nil")
}
if !strings.Contains(err.Error(), "ghost") || !strings.Contains(err.Error(), "not found") {
t.Errorf("error = %q, want contains 'ghost' and 'not found'", err.Error())
}
}
func TestResolveMissingDefaults(t *testing.T) {
cfgs := map[string]*NSConfig{
"x": {Name: "x"},
}
_, err := Resolve(cfgs)
if err == nil {
t.Fatal("expected missing _defaults error, got nil")
}
if !strings.Contains(err.Error(), defaultsName) {
t.Errorf("error = %q, want contains %q", err.Error(), defaultsName)
}
}
func TestResolveChainOrderWithDiamond(t *testing.T) {
// Diamond: top -> {left, right} -> base; base -> _defaults.
cfgs := map[string]*NSConfig{
defaultsName: {Name: defaultsName, Env: map[string]string{"R": "r"}},
"base": {Name: "base", Parents: []string{defaultsName}, Env: map[string]string{"B": "b"}},
"left": {Name: "left", Parents: []string{"base"}, Env: map[string]string{"L": "l"}},
"right": {Name: "right", Parents: []string{"base"}, Env: map[string]string{"L": "r"}},
"top": {Name: "top", Parents: []string{"left", "right"}, Env: map[string]string{"T": "t"}},
}
out, err := Resolve(cfgs)
if err != nil {
t.Fatalf("Resolve: %v", err)
}
r := out["top"]
if r == nil {
t.Fatal("missing top")
}
// top first, _defaults last.
if r.Chain[0] != "top" || r.Chain[len(r.Chain)-1] != defaultsName {
t.Errorf("chain = %v, want top first and _defaults last", r.Chain)
}
// base appears exactly once (diamond de-duped).
count := 0
for _, c := range r.Chain {
if c == "base" {
count++
}
}
if count != 1 {
t.Errorf("base appears %d times in %v, want 1", count, r.Chain)
}
// top inherits R from _defaults.
if r.Env["R"] != "r" {
t.Errorf("top should inherit R=r, got %q", r.Env["R"])
}
}
func eqSlice(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
+121
View File
@@ -0,0 +1,121 @@
// Package paths resolves on-disk locations for the v0.9 multi-namespace
// filesystem layout (R-002). It is the canonical source of truth for
// cluster-wide, per-namespace, and CLI-cache paths.
//
// The v0.8 internal/certpaths package is preserved as a thin shim that
// returns the legacy flat-layout paths during the v0.9 dual-write window
// (REQ-090). New code should use internal/paths, NOT certpaths.
package paths
import (
"os"
"path/filepath"
)
const (
defaultHomeSubdir = ".orca"
clusterDirName = "cluster"
defaultNamespace = "_defaults"
)
// Root returns the ORCA home directory. It honors $ORCA_HOME for
// testability; otherwise it defaults to ~/.orca. An empty $ORCA_HOME is
// treated as unset.
func Root() string {
if p := os.Getenv("ORCA_HOME"); p != "" {
return p
}
home, _ := os.UserHomeDir()
return filepath.Join(home, defaultHomeSubdir)
}
// ClusterDir returns the cluster-wide directory: Root()/cluster.
// Cluster-wide artifacts (CA, master key, peers, txns, known_hosts, SSH
// keys) live here and are NOT scoped to a workload namespace (R-002).
func ClusterDir() string { return filepath.Join(Root(), clusterDirName) }
// NamespaceDir returns the directory for a namespace: Root()/<ns>.
// Use DefaultNamespace() for the implicit root namespace (R-002 D-159).
func NamespaceDir(ns string) string { return filepath.Join(Root(), ns) }
// NSDb returns the SQLite database path for a namespace:
// NamespaceDir(ns)/db/orca.db.
func NSDb(ns string) string { return filepath.Join(NamespaceDir(ns), "db", "orca.db") }
// NSEnv returns the .env path for a namespace: NamespaceDir(ns)/.env.
func NSEnv(ns string) string { return filepath.Join(NamespaceDir(ns), ".env") }
// NSSecrets returns the encrypted secrets env path for a namespace:
// NamespaceDir(ns)/.env.secrets.
func NSSecrets(ns string) string { return filepath.Join(NamespaceDir(ns), ".env.secrets") }
// NSJobs returns the jobs directory for a namespace: NamespaceDir(ns)/jobs.
func NSJobs(ns string) string { return filepath.Join(NamespaceDir(ns), "jobs") }
// NSAlloc returns the allocation directory for a namespace:
// NamespaceDir(ns)/alloc.
func NSAlloc(ns string) string { return filepath.Join(NamespaceDir(ns), "alloc") }
// NSMd returns the namespace Markdown doc path (R-014):
// NamespaceDir(ns)/ns.md.
func NSMd(ns string) string { return filepath.Join(NamespaceDir(ns), "ns.md") }
// DefaultNamespace returns the implicit root namespace name (R-002 D-159).
func DefaultNamespace() string { return defaultNamespace }
// CACertPath returns the v0.9 cluster CA cert path:
// ClusterDir()/ca.crt (D-101). The v0.8 internal CA still writes to
// Root()/ca.crt; the move happens in v0.10-P14.
func CACertPath() string { return filepath.Join(ClusterDir(), "ca.crt") }
// CAKeyPath returns the v0.9 cluster CA key path:
// ClusterDir()/ca.key.
func CAKeyPath() string { return filepath.Join(ClusterDir(), "ca.key") }
// MasterKeyPath returns the AES-256-GCM root master key path
// (R-011, mode 0600): ClusterDir()/master.key. Not generated until
// v0.10-P03.
func MasterKeyPath() string { return filepath.Join(ClusterDir(), "master.key") }
// CacheDB returns the CLI-side cache database path (R-008):
// Root()/orca_cache.db. Not created until v0.9-P0a2.
func CacheDB() string { return filepath.Join(Root(), "orca_cache.db") }
// TxnDir returns the cluster transaction log directory (R-016):
// ClusterDir()/txns.
func TxnDir() string { return filepath.Join(ClusterDir(), "txns") }
// PeersDir returns the cluster peers directory: ClusterDir()/peers.
func PeersDir() string { return filepath.Join(ClusterDir(), "peers") }
// PeerDir returns the directory for a single peer host:
// PeersDir()/host.
func PeerDir(host string) string { return filepath.Join(PeersDir(), host) }
// KnownHostsPath returns the SSH known_hosts path (D-035):
// ClusterDir()/known_hosts.
func KnownHostsPath() string { return filepath.Join(ClusterDir(), "known_hosts") }
// SSHKeyPath returns the orca SSH private key path:
// ClusterDir()/orca_ssh_key (D-037).
func SSHKeyPath() string { return filepath.Join(ClusterDir(), "orca_ssh_key") }
// SSHPubPath returns the orca SSH public key path:
// ClusterDir()/orca_ssh_key.pub.
func SSHPubPath() string { return filepath.Join(ClusterDir(), "orca_ssh_key.pub") }
// ServerCertPath returns the legacy server cert path (legacy compat):
// ClusterDir()/server.crt. step-ca will replace this in a later phase.
func ServerCertPath() string { return filepath.Join(ClusterDir(), "server.crt") }
// ServerKeyPath returns the legacy server key path (legacy compat):
// ClusterDir()/server.key. step-ca will replace this in a later phase.
func ServerKeyPath() string { return filepath.Join(ClusterDir(), "server.key") }
// ConfigPath returns the new Markdown-frontmatter config path (R-014):
// ClusterDir()/config.md. The legacy HCL path is ClusterDir()/config.hcl.
func ConfigPath() string { return filepath.Join(ClusterDir(), "config.md") }
// LegacyHCLConfigPath returns the legacy HCL config path:
// ClusterDir()/config.hcl.
func LegacyHCLConfigPath() string { return filepath.Join(ClusterDir(), "config.hcl") }
+209
View File
@@ -0,0 +1,209 @@
package paths
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestRoot_HonorsORCAHOME(t *testing.T) {
dir := t.TempDir()
t.Setenv("ORCA_HOME", dir)
if got, want := Root(), dir; got != want {
t.Errorf("Root() = %q, want %q", got, want)
}
}
func TestRoot_EmptyORCAHOMEFallsBack(t *testing.T) {
t.Setenv("ORCA_HOME", "")
home, err := os.UserHomeDir()
if err != nil {
t.Skipf("os.UserHomeDir: %v", err)
}
want := filepath.Join(home, defaultHomeSubdir)
if got := Root(); got != want {
t.Errorf("Root() with empty ORCA_HOME = %q, want %q", got, want)
}
}
func TestRoot_UnsetORCAHOMEFallsBack(t *testing.T) {
os.Unsetenv("ORCA_HOME")
home, err := os.UserHomeDir()
if err != nil {
t.Skipf("os.UserHomeDir: %v", err)
}
want := filepath.Join(home, defaultHomeSubdir)
got := Root()
if got != want {
t.Errorf("Root() default = %q, want %q", got, want)
}
if !strings.HasPrefix(got, home) {
t.Errorf("Root() default %q does not start with home %q", got, home)
}
}
func TestRoot_RelativeORCAHOME(t *testing.T) {
t.Setenv("ORCA_HOME", "relative/orca/home")
if got, want := Root(), "relative/orca/home"; got != want {
t.Errorf("Root() relative = %q, want %q", got, want)
}
}
func TestDefaultNamespace(t *testing.T) {
if got, want := DefaultNamespace(), "_defaults"; got != want {
t.Errorf("DefaultNamespace() = %q, want %q", got, want)
}
}
func TestClusterDir(t *testing.T) {
dir := t.TempDir()
t.Setenv("ORCA_HOME", dir)
got := ClusterDir()
want := filepath.Join(dir, "cluster")
if got != want {
t.Errorf("ClusterDir() = %q, want %q", got, want)
}
if !strings.HasPrefix(got, Root()+string(filepath.Separator)) {
t.Errorf("ClusterDir() %q not under Root() %q", got, Root())
}
}
func TestNamespaceDir(t *testing.T) {
dir := t.TempDir()
t.Setenv("ORCA_HOME", dir)
ns := "prod"
got := NamespaceDir(ns)
want := filepath.Join(dir, ns)
if got != want {
t.Errorf("NamespaceDir(%q) = %q, want %q", ns, got, want)
}
if !strings.HasPrefix(got, Root()+string(filepath.Separator)) {
t.Errorf("NamespaceDir() %q not under Root() %q", got, Root())
}
}
func TestNamespacePaths(t *testing.T) {
dir := t.TempDir()
t.Setenv("ORCA_HOME", dir)
ns := "prod"
nsDir := NamespaceDir(ns)
cases := []struct {
name string
got string
want string
}{
{"NSDb", NSDb(ns), filepath.Join(nsDir, "db", "orca.db")},
{"NSEnv", NSEnv(ns), filepath.Join(nsDir, ".env")},
{"NSSecrets", NSSecrets(ns), filepath.Join(nsDir, ".env.secrets")},
{"NSJobs", NSJobs(ns), filepath.Join(nsDir, "jobs")},
{"NSAlloc", NSAlloc(ns), filepath.Join(nsDir, "alloc")},
{"NSMd", NSMd(ns), filepath.Join(nsDir, "ns.md")},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if tc.got != tc.want {
t.Errorf("%s(%q) = %q, want %q", tc.name, ns, tc.got, tc.want)
}
if !strings.HasPrefix(tc.got, nsDir+string(filepath.Separator)) {
t.Errorf("%s() %q not under NamespaceDir() %q", tc.name, tc.got, nsDir)
}
})
}
}
func TestDefaultNamespacePaths(t *testing.T) {
dir := t.TempDir()
t.Setenv("ORCA_HOME", dir)
ns := DefaultNamespace()
nsDir := NamespaceDir(ns)
if got, want := NSDb(ns), filepath.Join(nsDir, "db", "orca.db"); got != want {
t.Errorf("NSDb(_defaults) = %q, want %q", got, want)
}
if got, want := NSEnv(ns), filepath.Join(nsDir, ".env"); got != want {
t.Errorf("NSEnv(_defaults) = %q, want %q", got, want)
}
}
func TestClusterPaths(t *testing.T) {
dir := t.TempDir()
t.Setenv("ORCA_HOME", dir)
cDir := ClusterDir()
cases := []struct {
name string
got string
want string
}{
{"CACertPath", CACertPath(), filepath.Join(cDir, "ca.crt")},
{"CAKeyPath", CAKeyPath(), filepath.Join(cDir, "ca.key")},
{"MasterKeyPath", MasterKeyPath(), filepath.Join(cDir, "master.key")},
{"KnownHostsPath", KnownHostsPath(), filepath.Join(cDir, "known_hosts")},
{"SSHKeyPath", SSHKeyPath(), filepath.Join(cDir, "orca_ssh_key")},
{"SSHPubPath", SSHPubPath(), filepath.Join(cDir, "orca_ssh_key.pub")},
{"ServerCertPath", ServerCertPath(), filepath.Join(cDir, "server.crt")},
{"ServerKeyPath", ServerKeyPath(), filepath.Join(cDir, "server.key")},
{"ConfigPath", ConfigPath(), filepath.Join(cDir, "config.md")},
{"LegacyHCLConfigPath", LegacyHCLConfigPath(), filepath.Join(cDir, "config.hcl")},
{"TxnDir", TxnDir(), filepath.Join(cDir, "txns")},
{"PeersDir", PeersDir(), filepath.Join(cDir, "peers")},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if tc.got != tc.want {
t.Errorf("%s() = %q, want %q", tc.name, tc.got, tc.want)
}
if !strings.HasPrefix(tc.got, cDir+string(filepath.Separator)) {
t.Errorf("%s() %q not under ClusterDir() %q", tc.name, tc.got, cDir)
}
})
}
}
func TestPeerDir(t *testing.T) {
dir := t.TempDir()
t.Setenv("ORCA_HOME", dir)
host := "node1.example.com"
got := PeerDir(host)
want := filepath.Join(PeersDir(), host)
if got != want {
t.Errorf("PeerDir(%q) = %q, want %q", host, got, want)
}
if !strings.HasPrefix(got, PeersDir()+string(filepath.Separator)) {
t.Errorf("PeerDir() %q not under PeersDir() %q", got, PeersDir())
}
}
func TestCacheDB(t *testing.T) {
dir := t.TempDir()
t.Setenv("ORCA_HOME", dir)
got := CacheDB()
want := filepath.Join(dir, "orca_cache.db")
if got != want {
t.Errorf("CacheDB() = %q, want %q", got, want)
}
if !strings.HasPrefix(got, Root()+string(filepath.Separator)) {
t.Errorf("CacheDB() %q not under Root() %q", got, Root())
}
}
func TestPathSeparatorsOSAppropriate(t *testing.T) {
dir := t.TempDir()
t.Setenv("ORCA_HOME", dir)
// Every returned path must use the OS separator (filepath.Join).
sep := string(filepath.Separator)
for _, p := range []string{
ClusterDir(), NamespaceDir("ns"), NSDb("ns"), NSEnv("ns"),
NSSecrets("ns"), NSJobs("ns"), NSAlloc("ns"), NSMd("ns"),
CACertPath(), CAKeyPath(), MasterKeyPath(), CacheDB(),
TxnDir(), PeersDir(), PeerDir("h"), KnownHostsPath(),
SSHKeyPath(), SSHPubPath(), ServerCertPath(), ServerKeyPath(),
ConfigPath(), LegacyHCLConfigPath(),
} {
if !strings.Contains(p, sep) {
t.Errorf("path %q lacks OS separator %q (not joined?)", p, sep)
}
}
}
+10
View File
@@ -289,6 +289,11 @@ func TOFUHostKeyCallback(addr string, capturedKey *ssh.PublicKey) (ssh.HostKeyCa
if errors.As(err, &keyErr) && len(keyErr.Want) == 0 {
line := knownhosts.Line([]string{knownhosts.Normalize(addr)}, key)
path := certpaths.KnownHostsPath()
release, lockErr := security.Flock(path)
if lockErr != nil {
return fmt.Errorf("tofu lock known_hosts: %w", lockErr)
}
defer release()
existing, readErr := os.ReadFile(path)
if readErr != nil && !os.IsNotExist(readErr) {
return fmt.Errorf("tofu read known_hosts: %w", readErr)
@@ -481,6 +486,11 @@ func ResetHostKey(host string) error {
return fmt.Errorf("ResetHostKey: host is required")
}
path := certpaths.KnownHostsPath()
release, lockErr := security.Flock(path)
if lockErr != nil {
return fmt.Errorf("ResetHostKey: lock known_hosts: %w", lockErr)
}
defer release()
existing, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
+27
View File
@@ -0,0 +1,27 @@
package security
import (
"os"
"syscall"
)
// Flock acquires an exclusive advisory lock on the file at path, creating it
// if missing. Returns a release function that MUST be called (deferred) to
// release the lock and close the file descriptor. Used by the known_hosts
// read-modify-write paths (TOFUHostKeyCallback capture + ResetHostKey) to
// prevent concurrent writers under v0.9's parallel SSH fan-out (REQ-063,
// deferred P1 from REVIEW_v0.8 A2).
func Flock(path string) (release func(), err error) {
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, err
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
f.Close()
return nil, err
}
return func() {
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
_ = f.Close()
}, nil
}
+61
View File
@@ -0,0 +1,61 @@
package security
import (
"os"
"path/filepath"
"testing"
)
func TestFlock_acquireAndRelease(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.lock")
release, err := Flock(path)
if err != nil {
t.Fatalf("Flock: %v", err)
}
if _, statErr := os.Stat(path); statErr != nil {
t.Fatalf("lock file not created: %v", statErr)
}
release()
}
func TestFlock_reentrantAfterRelease(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.lock")
r1, err := Flock(path)
if err != nil {
t.Fatalf("first Flock: %v", err)
}
r1()
r2, err := Flock(path)
if err != nil {
t.Fatalf("second Flock after release: %v", err)
}
r2()
}
func TestFlock_concurrentBlocks(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.lock")
r1, err := Flock(path)
if err != nil {
t.Fatalf("first Flock: %v", err)
}
defer r1()
done := make(chan error, 1)
go func() {
_, err := Flock(path)
done <- err
}()
select {
case <-done:
t.Fatal("second Flock should block while first holds the lock")
default:
}
}