7bb31d4c09
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---
232 lines
6.4 KiB
Go
232 lines
6.4 KiB
Go
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
|
|
}
|