From 7bb31d4c09398739bd5659f2ed59452f66f0321f Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Wed, 5 Aug 2026 16:49:12 +0000 Subject: [PATCH] feat(P0a2): namespace CRUD + inheritance engine (REQ-082) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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--- --- internal/cli/namespace_test.go | 1 + internal/cli/ns.go | 338 +++++++++++++++++++++++++++ internal/cli/ns_test.go | 407 +++++++++++++++++++++++++++++++++ internal/ns/parse.go | 308 +++++++++++++++++++++++++ internal/ns/parse_test.go | 227 ++++++++++++++++++ internal/ns/resolve.go | 252 ++++++++++++++++++++ internal/ns/resolve_test.go | 231 +++++++++++++++++++ 7 files changed, 1764 insertions(+) create mode 100644 internal/cli/ns.go create mode 100644 internal/cli/ns_test.go create mode 100644 internal/ns/parse.go create mode 100644 internal/ns/parse_test.go create mode 100644 internal/ns/resolve.go create mode 100644 internal/ns/resolve_test.go diff --git a/internal/cli/namespace_test.go b/internal/cli/namespace_test.go index c2f48ac..c728d06 100644 --- a/internal/cli/namespace_test.go +++ b/internal/cli/namespace_test.go @@ -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) { diff --git a/internal/cli/ns.go b/internal/cli/ns.go new file mode 100644 index 0000000..d6af45a --- /dev/null +++ b/internal/cli/ns.go @@ -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 — create a namespace dir + ns.md +// orca ns delete — remove an empty namespace dir +// orca ns inspect — print effective chain + merged env +// orca ns validate — 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 ", + 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 ", + 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 ", + 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 ", + 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) +} diff --git a/internal/cli/ns_test.go b/internal/cli/ns_test.go new file mode 100644 index 0000000..73e82e8 --- /dev/null +++ b/internal/cli/ns_test.go @@ -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 ", "delete ", "inspect ", "validate "} { + 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 diff --git a/internal/ns/parse.go b/internal/ns/parse.go new file mode 100644 index 0000000..68504e6 --- /dev/null +++ b/internal/ns/parse.go @@ -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: (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 `/*/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 +} diff --git a/internal/ns/parse_test.go b/internal/ns/parse_test.go new file mode 100644 index 0000000..cb02b8c --- /dev/null +++ b/internal/ns/parse_test.go @@ -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") + } +} diff --git a/internal/ns/resolve.go b/internal/ns/resolve.go new file mode 100644 index 0000000..d9abf2b --- /dev/null +++ b/internal/ns/resolve.go @@ -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] + } +} diff --git a/internal/ns/resolve_test.go b/internal/ns/resolve_test.go new file mode 100644 index 0000000..cc521d1 --- /dev/null +++ b/internal/ns/resolve_test.go @@ -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 +}