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---
253 lines
7.9 KiB
Go
253 lines
7.9 KiB
Go
// 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]
|
|
}
|
|
}
|