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---
309 lines
7.8 KiB
Go
309 lines
7.8 KiB
Go
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
|
|
}
|