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 }