Files
orca/internal/ns/parse.go
T
Jon Chery 97b88a703c feat(P13): ns subcommands (inherit, set-constraint) + deprecation warnings (REQ-068)
orca ns inherit <name> --parent (cycle detection), ns set-constraint
<key>=value>. Deprecation warnings on orca cert ca-init/gen/renew
(step-ca replaces) and .hcl jobspec (R-013). --no-deprecation-warnings
suppresses all.

---ci---
project: orca
phase: 13
milestone: v0.11
status: execute
---/ci---
2026-08-07 07:47:28 +00:00

365 lines
9.4 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)
// constraints: ["k=v"] (optional; parsed into cfg.Constraints)
// 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
}
// ParseNSMdWithBody is like ParseNSMd but also returns the markdown
// body (the content after the closing `---` delimiter). Used by the
// ns inherit / set-constraint editors that rewrite frontmatter while
// preserving the body.
func ParseNSMdWithBody(path string) (*NSConfig, string, 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)
}
body := bodyAfterFrontmatter(content)
return cfg, body, nil
}
// bodyAfterFrontmatter returns the content after the closing frontmatter
// delimiter of an ns.md file. If no frontmatter is present, the whole
// input is returned unchanged.
func bodyAfterFrontmatter(content string) string {
trimmed := strings.TrimLeft(content, "\r\n\t ")
if !strings.HasPrefix(trimmed, "---") {
return content
}
rest := trimmed[3:]
rest = strings.TrimLeft(rest, "\r\n")
idx := strings.Index(rest, "\n---")
if idx < 0 {
return content
}
after := rest[idx+4:]
after = strings.TrimLeft(after, "\r\n")
return after
}
// 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 "constraints":
cons, err := parseStringArray(val)
if err != nil {
return nil, fmt.Errorf("parse %s: line %d: constraints: %w", path, lineNo+1, err)
}
cfg.Constraints = cons
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
}