Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af2fa59172 | |||
| 667f20a7b3 | |||
| fef03c5b56 | |||
| 7bb31d4c09 | |||
| 7b5193674e |
@@ -1,10 +1 @@
|
||||
{
|
||||
"phase": "P0a1",
|
||||
"stage": "verify",
|
||||
"milestone": "v0.9",
|
||||
"phase_role": "execution",
|
||||
"updated_at": "2026-08-05T03:00:00Z",
|
||||
"milestone_complete": false,
|
||||
"gates_cleared_this_phase": ["C-07"],
|
||||
"verify": { "build": "pass", "go_test": "17/17", "bats": "20/20", "gofmt": "clean", "verify_reqs": "90 consistent" }
|
||||
}
|
||||
{ "phase": "P0b", "stage": "verify", "milestone": "v0.9", "phase_role": "execution", "updated_at": "2026-08-05T03:25:00Z", "milestone_complete": false, "verify": { "build": "pass", "go_test": "18/18", "bats": "20/20", "gofmt": "clean", "verify_reqs": "90 consistent" } }
|
||||
|
||||
+22
-3
@@ -74,7 +74,7 @@ var jobRunCmd = &cobra.Command{
|
||||
peers := engine.NewPeerRegistry()
|
||||
dispatcher := engine.NewDispatcher(newLogger(), store.NewCapacityRepo(db), peers, exec)
|
||||
specBytes, _ := json.Marshal(map[string]any{
|
||||
"name": spec.Job.Name,
|
||||
"name": spec.Name,
|
||||
"command": "/bin/true", // placeholder; full HCL dispatch lands in a later phase
|
||||
})
|
||||
jobID, nodeID, err := dispatcher.Submit(ctx, runTarget, specBytes, runIDKey)
|
||||
@@ -93,11 +93,11 @@ var jobRunCmd = &cobra.Command{
|
||||
|
||||
job := &model.Job{
|
||||
ID: uuid.NewString(),
|
||||
Name: spec.Job.Name,
|
||||
Name: spec.Name,
|
||||
Spec: args[0],
|
||||
Status: model.JobStatusPending,
|
||||
}
|
||||
if err := exec.Run(ctx, job, toTaskSpecs(spec.Tasks)); err != nil {
|
||||
if err := exec.Run(ctx, job, workloadToTaskSpecs(spec)); err != nil {
|
||||
if jsonOutput {
|
||||
_ = printJSON(map[string]any{"id": job.ID, "status": "failed", "error": err.Error()})
|
||||
return err
|
||||
@@ -330,3 +330,22 @@ func toTaskSpecs(in []jobspec.TaskSpec) []engine.TaskSpec {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// workloadToTaskSpecs converts a *WorkloadSpec into the engine.TaskSpec
|
||||
// slice consumed by the executor. For the HCL adapter path the runtime
|
||||
// block carries the legacy task[0].Command; for the Markdown path the
|
||||
// runtime block is the canonical runtime abstraction (P07 will expand
|
||||
// this). When Runtime is nil we emit a single no-op task to preserve
|
||||
// the legacy "at least one task" invariant.
|
||||
func workloadToTaskSpecs(spec *jobspec.WorkloadSpec) []engine.TaskSpec {
|
||||
if spec == nil {
|
||||
return nil
|
||||
}
|
||||
if spec.Runtime == nil {
|
||||
return []engine.TaskSpec{{Name: spec.Name, Command: "/bin/true"}}
|
||||
}
|
||||
return []engine.TaskSpec{{
|
||||
Name: spec.Name,
|
||||
Command: spec.Runtime.Command,
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ func resetCommandFlags() {
|
||||
stopID, runTarget, runIDKey, jobWatch = "", "", "", false
|
||||
capSetCPU, capSetMem, capSetDisk, capNodeID = 0, 0, 0, ""
|
||||
auditLimit = 50
|
||||
resetNSFlags()
|
||||
}
|
||||
|
||||
func TestNamespaceDefaultsToUserHome(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
// Package cli: ns.go implements the `orca ns` subcommand family
|
||||
// (REQ-082, D-176). Subcommands:
|
||||
//
|
||||
// orca ns list — list all namespaces under ORCA_HOME
|
||||
// orca ns create <name> — create a namespace dir + ns.md
|
||||
// orca ns delete <name> — remove an empty namespace dir
|
||||
// orca ns inspect <name> — print effective chain + merged env
|
||||
// orca ns validate <name> — cycle + missing-parent + schema checks
|
||||
//
|
||||
// All subcommands honor $ORCA_HOME via internal/paths. The inheritance
|
||||
// resolver (internal/ns) is a pure function shared by inspect + validate.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/ns"
|
||||
"git.cloudinit.dev/coreci/orca/internal/paths"
|
||||
)
|
||||
|
||||
var nsCmd = &cobra.Command{
|
||||
Use: "ns",
|
||||
Short: "Manage orca namespaces",
|
||||
Long: `Manage orca namespaces under ORCA_HOME (R-002).
|
||||
|
||||
Each namespace is a directory with ns.md, .env, .env.secrets, db/,
|
||||
jobs/, alloc/. The implicit root namespace _defaults always exists
|
||||
(D-159); every namespace inherits from _defaults (D-185) and cannot
|
||||
opt out (D-187).`,
|
||||
}
|
||||
|
||||
var (
|
||||
nsCreateParent string
|
||||
nsCreateInheritsEnv bool
|
||||
nsCreateInheritsSecret bool
|
||||
)
|
||||
|
||||
var nsListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all namespaces under ORCA_HOME",
|
||||
Long: `List all namespaces under ORCA_HOME (directories containing ns.md, plus the implicit _defaults).`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
root := paths.Root()
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read ORCA_HOME %s: %w", root, err)
|
||||
}
|
||||
type nsRow struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Default bool `json:"default"`
|
||||
}
|
||||
var rows []nsRow
|
||||
for _, ent := range entries {
|
||||
if !ent.IsDir() {
|
||||
continue
|
||||
}
|
||||
if ent.Name() == "cluster" {
|
||||
continue
|
||||
}
|
||||
nsMd := filepath.Join(root, ent.Name(), "ns.md")
|
||||
if _, err := os.Stat(nsMd); err != nil {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, nsRow{
|
||||
Name: ent.Name(),
|
||||
Path: filepath.Join(root, ent.Name()),
|
||||
Default: ent.Name() == paths.DefaultNamespace(),
|
||||
})
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if rows[i].Name == paths.DefaultNamespace() {
|
||||
return true
|
||||
}
|
||||
if rows[j].Name == paths.DefaultNamespace() {
|
||||
return false
|
||||
}
|
||||
return rows[i].Name < rows[j].Name
|
||||
})
|
||||
if jsonOutput {
|
||||
return printJSON(rows)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "No namespaces found. Run 'orca init' first.")
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-10s %s\n", "NAME", "DEFAULT", "PATH")
|
||||
for _, r := range rows {
|
||||
def := ""
|
||||
if r.Default {
|
||||
def = "*"
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-10s %s\n", r.Name, def, r.Path)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var nsCreateCmd = &cobra.Command{
|
||||
Use: "create <name>",
|
||||
Short: "Create a namespace directory + ns.md",
|
||||
Long: `Create a namespace under ORCA_HOME. Builds the dir structure
|
||||
(db/, jobs/, alloc/) and writes ns.md frontmatter. --parent may be
|
||||
repeated to declare inheritance; _defaults is always appended last.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
if name == paths.DefaultNamespace() {
|
||||
return fmt.Errorf("cannot create the implicit root namespace %q with `ns create` (it is auto-managed)", name)
|
||||
}
|
||||
if name == "cluster" {
|
||||
return fmt.Errorf("name %q is reserved for the cluster-wide dir", name)
|
||||
}
|
||||
if nsCreateParent == "" {
|
||||
nsCreateParent = paths.DefaultNamespace()
|
||||
}
|
||||
nsDir := paths.NamespaceDir(name)
|
||||
if _, err := os.Stat(nsDir); err == nil {
|
||||
if _, statErr := os.Stat(paths.NSMd(name)); statErr == nil {
|
||||
return fmt.Errorf("namespace %q already exists at %s", name, nsDir)
|
||||
}
|
||||
}
|
||||
for _, sub := range []string{"db", "jobs", "alloc"} {
|
||||
if err := os.MkdirAll(filepath.Join(nsDir, sub), 0o755); err != nil {
|
||||
return fmt.Errorf("create %s/%s: %w", nsDir, sub, err)
|
||||
}
|
||||
}
|
||||
parents := []string{nsCreateParent}
|
||||
if nsCreateParent == paths.DefaultNamespace() {
|
||||
// Explicit _defaults listing is allowed (de-duped silently).
|
||||
}
|
||||
body := renderNSMd(name, parents, nsCreateInheritsEnv, nsCreateInheritsSecret)
|
||||
if err := os.WriteFile(paths.NSMd(name), []byte(body), 0o644); err != nil {
|
||||
return fmt.Errorf("write ns.md: %w", err)
|
||||
}
|
||||
if jsonOutput {
|
||||
return printJSON(map[string]any{
|
||||
"name": name,
|
||||
"path": nsDir,
|
||||
"parents": parents,
|
||||
"ns_md": paths.NSMd(name),
|
||||
"inherits_env": nsCreateInheritsEnv,
|
||||
"inherits_secrets": nsCreateInheritsSecret,
|
||||
})
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ Namespace created: %s (%s)\n", name, nsDir)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var nsDeleteCmd = &cobra.Command{
|
||||
Use: "delete <name>",
|
||||
Short: "Remove an empty namespace directory",
|
||||
Long: `Remove a namespace directory. Refuses if jobs/ or alloc/
|
||||
contain any files (non-empty namespace). The implicit root _defaults
|
||||
cannot be deleted.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
if name == paths.DefaultNamespace() {
|
||||
return fmt.Errorf("cannot delete the implicit root namespace %q", name)
|
||||
}
|
||||
nsDir := paths.NamespaceDir(name)
|
||||
if _, err := os.Stat(nsDir); err != nil {
|
||||
return fmt.Errorf("namespace %q not found: %w", name, err)
|
||||
}
|
||||
for _, sub := range []string{"jobs", "alloc"} {
|
||||
dir := filepath.Join(nsDir, sub)
|
||||
if err := dirNonEmpty(dir); err != nil {
|
||||
return fmt.Errorf("refusing to delete %q: %s is non-empty (%w); clear it first", name, sub, err)
|
||||
}
|
||||
}
|
||||
if err := os.RemoveAll(nsDir); err != nil {
|
||||
return fmt.Errorf("delete %s: %w", nsDir, err)
|
||||
}
|
||||
if jsonOutput {
|
||||
return printJSON(map[string]string{"name": name, "deleted": nsDir})
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ Namespace deleted: %s (%s)\n", name, nsDir)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var nsInspectCmd = &cobra.Command{
|
||||
Use: "inspect <name>",
|
||||
Short: "Print the effective chain, merged env, and constraints",
|
||||
Long: `Resolve a namespace's inheritance chain and print the merged env and unioned constraints (uses the resolver).`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
root := paths.Root()
|
||||
cfgs, err := ns.ParseNSMdDir(root)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load namespaces: %w", err)
|
||||
}
|
||||
if _, ok := cfgs[name]; !ok {
|
||||
return fmt.Errorf("namespace %q not found under %s", name, root)
|
||||
}
|
||||
resolved, err := ns.Resolve(cfgs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve: %w", err)
|
||||
}
|
||||
r := resolved[name]
|
||||
if r == nil {
|
||||
return fmt.Errorf("namespace %q resolved to nil", name)
|
||||
}
|
||||
if jsonOutput {
|
||||
return printJSON(map[string]any{
|
||||
"name": r.Name,
|
||||
"chain": r.Chain,
|
||||
"env": r.Env,
|
||||
"constraints": r.Constraints,
|
||||
})
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Namespace: %s\n", r.Name)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Chain: %s\n", strings.Join(r.Chain, " -> "))
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "Env:")
|
||||
keys := sortedKeys(r.Env)
|
||||
for _, k := range keys {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), " %s = %s\n", k, r.Env[k])
|
||||
}
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "Constraints:")
|
||||
if len(r.Constraints) == 0 {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " (none)")
|
||||
} else {
|
||||
for _, c := range r.Constraints {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), " - %s\n", c)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var nsValidateCmd = &cobra.Command{
|
||||
Use: "validate <name>",
|
||||
Short: "Run cycle + missing-parent + schema checks on a namespace",
|
||||
Long: `Validate a namespace's inheritance chain and ns.md frontmatter.
|
||||
Exits 0 if valid, 1 on error. Runs over ALL namespaces under ORCA_HOME
|
||||
(parsing + resolving validates cycles and missing parents across the
|
||||
set).`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
root := paths.Root()
|
||||
cfgs, err := ns.ParseNSMdDir(root)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load namespaces: %w", err)
|
||||
}
|
||||
if _, ok := cfgs[name]; !ok {
|
||||
return fmt.Errorf("namespace %q not found under %s", name, root)
|
||||
}
|
||||
resolved, err := ns.Resolve(cfgs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate: %w", err)
|
||||
}
|
||||
r := resolved[name]
|
||||
if r == nil {
|
||||
return fmt.Errorf("namespace %q resolved to nil", name)
|
||||
}
|
||||
if jsonOutput {
|
||||
return printJSON(map[string]any{
|
||||
"name": r.Name,
|
||||
"valid": true,
|
||||
"chain": r.Chain,
|
||||
})
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ %s valid\n chain: %s\n", name, strings.Join(r.Chain, " -> "))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// renderNSMd writes a minimal ns.md frontmatter for `orca ns create`.
|
||||
func renderNSMd(name string, parents []string, inheritsEnv, inheritsSecrets bool) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("---\n")
|
||||
b.WriteString("kind: Namespace\n")
|
||||
b.WriteString("name: ")
|
||||
b.WriteString(name)
|
||||
b.WriteString("\n")
|
||||
if len(parents) > 0 {
|
||||
quoted := make([]string, len(parents))
|
||||
for i, p := range parents {
|
||||
quoted[i] = fmt.Sprintf("%q", p)
|
||||
}
|
||||
b.WriteString("parents: [")
|
||||
b.WriteString(strings.Join(quoted, ", "))
|
||||
b.WriteString("]\n")
|
||||
}
|
||||
fmt.Fprintf(&b, "inherits_env: %t\n", inheritsEnv)
|
||||
fmt.Fprintf(&b, "inherits_secrets: %t\n", inheritsSecrets)
|
||||
b.WriteString("---\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// dirNonEmpty returns an error wrapping the offending entry if dir
|
||||
// contains any entries.
|
||||
func dirNonEmpty(dir string) error {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
return fmt.Errorf("contains %s", e.Name())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string]string) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func init() {
|
||||
nsCreateCmd.Flags().StringVar(&nsCreateParent, "parent", "", "parent namespace (default _defaults; the implicit root is always appended last)")
|
||||
nsCreateCmd.Flags().BoolVar(&nsCreateInheritsEnv, "inherits-env", true, "inherit env from parents (default true)")
|
||||
nsCreateCmd.Flags().BoolVar(&nsCreateInheritsSecret, "inherits-secrets", true, "inherit secrets from parents (default true)")
|
||||
|
||||
nsCmd.AddCommand(nsListCmd)
|
||||
nsCmd.AddCommand(nsCreateCmd)
|
||||
nsCmd.AddCommand(nsDeleteCmd)
|
||||
nsCmd.AddCommand(nsInspectCmd)
|
||||
nsCmd.AddCommand(nsValidateCmd)
|
||||
rootCmd.AddCommand(nsCmd)
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/paths"
|
||||
)
|
||||
|
||||
// resetNSFlags zeroes the ns subcommand flag-bound vars so tests don't
|
||||
// leak state.
|
||||
func resetNSFlags() {
|
||||
nsCreateParent = ""
|
||||
nsCreateInheritsEnv = true
|
||||
nsCreateInheritsSecret = true
|
||||
}
|
||||
|
||||
func writeDefaultsNS(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
nsDir := filepath.Join(root, "_defaults")
|
||||
if err := os.MkdirAll(nsDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir _defaults: %v", err)
|
||||
}
|
||||
body := "---\nkind: Namespace\nname: _defaults\ninherits_env: true\ninherits_secrets: true\n---\n# defaults\n"
|
||||
if err := os.WriteFile(filepath.Join(nsDir, "ns.md"), []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write _defaults ns.md: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeCustomNS(t *testing.T, root, name, parentsList string) {
|
||||
t.Helper()
|
||||
nsDir := filepath.Join(root, name)
|
||||
if err := os.MkdirAll(nsDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", name, err)
|
||||
}
|
||||
body := "---\nkind: Namespace\nname: " + name + "\n"
|
||||
if parentsList != "" {
|
||||
body += "parents: " + parentsList + "\n"
|
||||
}
|
||||
body += "inherits_env: true\ninherits_secrets: true\n---\n# " + name + "\n"
|
||||
if err := os.WriteFile(filepath.Join(nsDir, "ns.md"), []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write %s ns.md: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNSListEmpty(t *testing.T) {
|
||||
t.Setenv("ORCA_HOME", t.TempDir())
|
||||
resetRootFlags(t)
|
||||
resetNSFlags()
|
||||
|
||||
rootCmd.SetArgs([]string{"ns", "list"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("ns list: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNSListWithNamespaces(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", root)
|
||||
resetRootFlags(t)
|
||||
resetNSFlags()
|
||||
writeDefaultsNS(t, root)
|
||||
writeCustomNS(t, root, "prod", "")
|
||||
|
||||
rootCmd.SetArgs([]string{"ns", "list"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("ns list: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNSCreateHappy(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", root)
|
||||
resetRootFlags(t)
|
||||
resetNSFlags()
|
||||
|
||||
rootCmd.SetArgs([]string{"ns", "create", "prod"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("ns create: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "prod", "ns.md")); err != nil {
|
||||
t.Fatalf("ns.md not created: %v", err)
|
||||
}
|
||||
for _, sub := range []string{"db", "jobs", "alloc"} {
|
||||
if _, err := os.Stat(filepath.Join(root, "prod", sub)); err != nil {
|
||||
t.Errorf("subdir %s not created: %v", sub, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNSCreateWithParent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", root)
|
||||
resetRootFlags(t)
|
||||
resetNSFlags()
|
||||
writeDefaultsNS(t, root)
|
||||
writeCustomNS(t, root, "base", "")
|
||||
|
||||
rootCmd.SetArgs([]string{"ns", "create", "child", "--parent", "base"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("ns create: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(root, "child", "ns.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read ns.md: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "parents: [") || !strings.Contains(string(data), "\"base\"") {
|
||||
t.Errorf("ns.md missing parents: %s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNSCreateExisting(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", root)
|
||||
resetRootFlags(t)
|
||||
resetNSFlags()
|
||||
writeDefaultsNS(t, root)
|
||||
writeCustomNS(t, root, "prod", "")
|
||||
|
||||
rootCmd.SetArgs([]string{"ns", "create", "prod"})
|
||||
err := rootCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error creating existing namespace, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "already exists") {
|
||||
t.Errorf("error = %q, want contains 'already exists'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNSCreateDefaultsRefused(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", root)
|
||||
resetRootFlags(t)
|
||||
resetNSFlags()
|
||||
|
||||
rootCmd.SetArgs([]string{"ns", "create", "_defaults"})
|
||||
err := rootCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error creating _defaults, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNSCreateClusterRefused(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", root)
|
||||
resetRootFlags(t)
|
||||
resetNSFlags()
|
||||
|
||||
rootCmd.SetArgs([]string{"ns", "create", "cluster"})
|
||||
err := rootCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error creating cluster, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNSDeleteHappy(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", root)
|
||||
resetRootFlags(t)
|
||||
resetNSFlags()
|
||||
writeDefaultsNS(t, root)
|
||||
writeCustomNS(t, root, "prod", "")
|
||||
|
||||
rootCmd.SetArgs([]string{"ns", "delete", "prod"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("ns delete: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "prod")); !os.IsNotExist(err) {
|
||||
t.Errorf("prod dir still exists after delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNSDeleteDefaultsRefused(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", root)
|
||||
resetRootFlags(t)
|
||||
resetNSFlags()
|
||||
writeDefaultsNS(t, root)
|
||||
|
||||
rootCmd.SetArgs([]string{"ns", "delete", "_defaults"})
|
||||
err := rootCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error deleting _defaults, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNSDeleteNonEmpty(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", root)
|
||||
resetRootFlags(t)
|
||||
resetNSFlags()
|
||||
writeDefaultsNS(t, root)
|
||||
writeCustomNS(t, root, "prod", "")
|
||||
// put a job in jobs/
|
||||
if err := os.MkdirAll(filepath.Join(root, "prod", "jobs"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir jobs: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "prod", "jobs", "j1.md"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("write job: %v", err)
|
||||
}
|
||||
|
||||
rootCmd.SetArgs([]string{"ns", "delete", "prod"})
|
||||
err := rootCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error deleting non-empty namespace, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "refusing to delete") {
|
||||
t.Errorf("error = %q, want contains 'refusing to delete'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNSDeleteMissing(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", root)
|
||||
resetRootFlags(t)
|
||||
resetNSFlags()
|
||||
writeDefaultsNS(t, root)
|
||||
|
||||
rootCmd.SetArgs([]string{"ns", "delete", "ghost"})
|
||||
err := rootCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error deleting missing namespace, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNSInspectHappy(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", root)
|
||||
resetRootFlags(t)
|
||||
resetNSFlags()
|
||||
writeDefaultsNS(t, root)
|
||||
writeCustomNS(t, root, "prod", "")
|
||||
|
||||
rootCmd.SetArgs([]string{"ns", "inspect", "prod"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("ns inspect: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNSInspectJSON(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", root)
|
||||
resetRootFlags(t)
|
||||
resetNSFlags()
|
||||
writeDefaultsNS(t, root)
|
||||
writeCustomNS(t, root, "prod", "")
|
||||
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetArgs([]string{"ns", "inspect", "prod", "--json"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("ns inspect --json: %v", err)
|
||||
}
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &result); err != nil {
|
||||
t.Fatalf("unmarshal: %v\n%s", err, buf.String())
|
||||
}
|
||||
if result["name"] != "prod" {
|
||||
t.Errorf("name = %v, want prod", result["name"])
|
||||
}
|
||||
chain, _ := result["chain"].([]any)
|
||||
if len(chain) < 2 {
|
||||
t.Errorf("chain too short: %v", chain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNSInspectMissingNamespace(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", root)
|
||||
resetRootFlags(t)
|
||||
resetNSFlags()
|
||||
writeDefaultsNS(t, root)
|
||||
|
||||
rootCmd.SetArgs([]string{"ns", "inspect", "ghost"})
|
||||
err := rootCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing namespace, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNSValidateHappy(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", root)
|
||||
resetRootFlags(t)
|
||||
resetNSFlags()
|
||||
writeDefaultsNS(t, root)
|
||||
writeCustomNS(t, root, "prod", "")
|
||||
|
||||
rootCmd.SetArgs([]string{"ns", "validate", "prod"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("ns validate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNSValidateCycle(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", root)
|
||||
resetRootFlags(t)
|
||||
resetNSFlags()
|
||||
writeDefaultsNS(t, root)
|
||||
// a -> b, b -> a (cycle)
|
||||
writeCustomNS(t, root, "a", "[\"b\"]")
|
||||
writeCustomNS(t, root, "b", "[\"a\"]")
|
||||
|
||||
rootCmd.SetArgs([]string{"ns", "validate", "a"})
|
||||
err := rootCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected cycle error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cycle") {
|
||||
t.Errorf("error = %q, want contains 'cycle'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNSValidateMissingParent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", root)
|
||||
resetRootFlags(t)
|
||||
resetNSFlags()
|
||||
writeDefaultsNS(t, root)
|
||||
writeCustomNS(t, root, "x", "[\"ghost\"]")
|
||||
|
||||
rootCmd.SetArgs([]string{"ns", "validate", "x"})
|
||||
err := rootCmd.Execute()
|
||||
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 + not found", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNSValidateMissingNamespace(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", root)
|
||||
resetRootFlags(t)
|
||||
resetNSFlags()
|
||||
writeDefaultsNS(t, root)
|
||||
|
||||
rootCmd.SetArgs([]string{"ns", "validate", "ghost"})
|
||||
err := rootCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing namespace, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderNSMd(t *testing.T) {
|
||||
body := renderNSMd("foo", []string{"_defaults"}, true, false)
|
||||
if !strings.Contains(body, "kind: Namespace") {
|
||||
t.Errorf("missing kind: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "name: foo") {
|
||||
t.Errorf("missing name: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "inherits_env: true") {
|
||||
t.Errorf("missing inherits_env true: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "inherits_secrets: false") {
|
||||
t.Errorf("missing inherits_secrets false: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNSRootRegistered(t *testing.T) {
|
||||
found := false
|
||||
for _, c := range rootCmd.Commands() {
|
||||
if c.Use == "ns" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("ns command not registered on root")
|
||||
}
|
||||
// ensure subcommands present
|
||||
sub := map[string]bool{}
|
||||
for _, c := range rootCmd.Commands() {
|
||||
if c.Use == "ns" {
|
||||
for _, sc := range c.Commands() {
|
||||
sub[sc.Use] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"list", "create <name>", "delete <name>", "inspect <name>", "validate <name>"} {
|
||||
if !sub[want] {
|
||||
t.Errorf("missing ns subcommand %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNSListNoORCAHOME(t *testing.T) {
|
||||
// ORCA_HOME points at a nonexistent dir; list should error.
|
||||
t.Setenv("ORCA_HOME", filepath.Join(t.TempDir(), "nope"))
|
||||
resetRootFlags(t)
|
||||
resetNSFlags()
|
||||
|
||||
rootCmd.SetArgs([]string{"ns", "list"})
|
||||
err := rootCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing ORCA_HOME, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
var _ = paths.DefaultNamespace // keep paths import alive
|
||||
@@ -0,0 +1,147 @@
|
||||
package jobspec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseFile reads a jobspec file from disk and dispatches on file
|
||||
// extension (R-013, REQ-064):
|
||||
//
|
||||
// - .md → ParseMarkdown (canonical Markdown+frontmatter, R-014/R-015)
|
||||
// - .yaml/.yml → ParseMarkdown with the whole file treated as
|
||||
// frontmatter and Body = "" (pure YAML, no Markdown body)
|
||||
// - .hcl → ParseHCL (legacy adapter; wraps the existing HCL parser
|
||||
// and converts Spec{Job, Tasks} into *WorkloadSpec with Kind="Job",
|
||||
// REQ-090 migration window)
|
||||
//
|
||||
// Unknown extensions return an error. The dispatcher preserves
|
||||
// `orca job run old-spec.hcl` during the v0.9→v0.10 migration window
|
||||
// (REQ-090).
|
||||
func ParseFile(path string) (*WorkloadSpec, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read spec file: %w", err)
|
||||
}
|
||||
return Dispatch(data, filepath.Base(path))
|
||||
}
|
||||
|
||||
// ParseHCLFile reads an HCL file and parses it via the legacy HCL parser,
|
||||
// returning the legacy *Spec. It is a convenience wrapper retained for
|
||||
// tests and direct HCL consumers that need the raw Spec{Job, Tasks}
|
||||
// shape during the v0.9→v0.10 migration window (REQ-090).
|
||||
//
|
||||
// Deprecated: use ParseFile (dispatcher) for new code. HCL is legacy per
|
||||
// R-013.
|
||||
func ParseHCLFile(path string) (*Spec, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read spec file: %w", err)
|
||||
}
|
||||
return ParseHCLLegacy(data, filepath.Base(path))
|
||||
}
|
||||
|
||||
// Dispatch routes raw jobspec bytes on file extension to the
|
||||
// appropriate parser. filename is used only for HCL (the HCL decoder
|
||||
// needs a filename for error messages and syntax sniffing).
|
||||
func Dispatch(data []byte, filename string) (*WorkloadSpec, error) {
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
switch ext {
|
||||
case ".md":
|
||||
return ParseMarkdown(data)
|
||||
case ".yaml", ".yml":
|
||||
// Pure YAML file: no Markdown body. Treat the whole file as
|
||||
// the frontmatter block. Body is empty (R-015: no body to
|
||||
// preserve).
|
||||
spec, err := parseYAMLFile(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return spec, nil
|
||||
case ".hcl":
|
||||
return ParseHCL(data, filename)
|
||||
default:
|
||||
return nil, fmt.Errorf("parse jobspec: unknown extension %q (want .md, .yaml, .yml, or .hcl)", ext)
|
||||
}
|
||||
}
|
||||
|
||||
// parseYAMLFile treats the whole file as a frontmatter block (no
|
||||
// surrounding `---` delimiters, no Markdown body). This routes .yaml
|
||||
// and .yml files through the same hand-rolled parser as .md.
|
||||
func parseYAMLFile(data []byte) (*WorkloadSpec, error) {
|
||||
block := string(data)
|
||||
if strings.TrimSpace(block) == "" {
|
||||
return nil, fmt.Errorf("parse yaml: empty file")
|
||||
}
|
||||
spec, err := parseFrontmatterBlock(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
spec.Body = ""
|
||||
if err := validateWorkload(spec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// ParseHCL parses a legacy HCL jobspec and adapts it into a *WorkloadSpec
|
||||
// (REQ-064 adapter, REQ-090 migration window). The existing HCL
|
||||
// Spec{Job, Tasks} shape is converted to:
|
||||
//
|
||||
// Kind: "Job"
|
||||
// Name: spec.Job.Name
|
||||
// Runtime: {one_of: "process", command: tasks[0].Command}
|
||||
//
|
||||
// Body is empty (HCL has no Markdown body). The legacy Spec struct and
|
||||
// ParseHCLLegacy are retained for direct HCL consumers that have not yet
|
||||
// migrated.
|
||||
//
|
||||
// Deprecated: use the dispatcher (ParseFile/Dispatch). HCL is legacy
|
||||
// per R-013; the HCL path is retained only for the v0.9→v0.10 migration
|
||||
// window (REQ-090) and will be removed in v1.0.
|
||||
func ParseHCL(data []byte, filename string) (*WorkloadSpec, error) {
|
||||
spec, err := ParseHCLLegacy(data, filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ws := &WorkloadSpec{
|
||||
SpecVersion: "",
|
||||
Kind: "Job",
|
||||
Name: spec.Job.Name,
|
||||
Count: 1,
|
||||
Body: "",
|
||||
}
|
||||
if len(spec.Tasks) > 0 {
|
||||
ws.Runtime = &RuntimeBlock{
|
||||
OneOf: "process",
|
||||
Command: spec.Tasks[0].Command,
|
||||
}
|
||||
}
|
||||
return ws, nil
|
||||
}
|
||||
|
||||
// ParseHCLLegacy is the original HCL-only parser retained for direct
|
||||
// HCL consumers (e.g. the cli/job.go toTaskSpecs path during the
|
||||
// migration window). New code should call ParseHCL (which returns a
|
||||
// *WorkloadSpec) or the dispatcher. Deprecated: HCL is legacy per
|
||||
// R-013; see ParseHCL.
|
||||
func ParseHCLLegacy(data []byte, filename string) (*Spec, error) {
|
||||
var spec Spec
|
||||
if err := hclDecode(filename, data, &spec); err != nil {
|
||||
return nil, fmt.Errorf("decode hcl: %w", err)
|
||||
}
|
||||
if spec.Job.Name == "" {
|
||||
return nil, fmt.Errorf("spec missing job name")
|
||||
}
|
||||
if len(spec.Tasks) == 0 {
|
||||
return nil, fmt.Errorf("spec must have at least one task")
|
||||
}
|
||||
for i, t := range spec.Tasks {
|
||||
if t.Command == "" {
|
||||
return nil, fmt.Errorf("task[%d] (%s) missing command", i, t.Name)
|
||||
}
|
||||
}
|
||||
return &spec, nil
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package jobspec
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDispatch_Markdown(t *testing.T) {
|
||||
input := "---\nkind: Job\nname: md-job\n---\nbody content\n"
|
||||
ws, err := Dispatch([]byte(input), "spec.md")
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch .md: %v", err)
|
||||
}
|
||||
if ws.Kind != "Job" {
|
||||
t.Errorf("Kind = %q, want Job", ws.Kind)
|
||||
}
|
||||
if ws.Name != "md-job" {
|
||||
t.Errorf("Name = %q, want md-job", ws.Name)
|
||||
}
|
||||
if ws.Body != "body content\n" {
|
||||
t.Errorf("Body = %q, want %q (R-015)", ws.Body, "body content\n")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatch_YAML(t *testing.T) {
|
||||
input := "kind: Service\nname: yaml-svc\nports:\n - name: http\n port: 80\n"
|
||||
ws, err := Dispatch([]byte(input), "spec.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch .yaml: %v", err)
|
||||
}
|
||||
if ws.Kind != "Service" {
|
||||
t.Errorf("Kind = %q, want Service", ws.Kind)
|
||||
}
|
||||
if ws.Name != "yaml-svc" {
|
||||
t.Errorf("Name = %q, want yaml-svc", ws.Name)
|
||||
}
|
||||
if ws.Body != "" {
|
||||
t.Errorf("Body = %q, want empty (YAML has no body)", ws.Body)
|
||||
}
|
||||
if len(ws.Ports) != 1 || ws.Ports[0].Name != "http" || ws.Ports[0].Port != 80 {
|
||||
t.Errorf("Ports = %+v, want one http:80", ws.Ports)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatch_YML(t *testing.T) {
|
||||
input := "kind: DaemonSet\nname: yml-ds\n"
|
||||
ws, err := Dispatch([]byte(input), "spec.yml")
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch .yml: %v", err)
|
||||
}
|
||||
if ws.Kind != "DaemonSet" {
|
||||
t.Errorf("Kind = %q, want DaemonSet", ws.Kind)
|
||||
}
|
||||
if ws.Body != "" {
|
||||
t.Errorf("Body = %q, want empty", ws.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatch_HCLAdapter(t *testing.T) {
|
||||
hcl := `job "demo" {}
|
||||
task "build" {
|
||||
command = "/bin/echo"
|
||||
args = ["hello"]
|
||||
}
|
||||
`
|
||||
ws, err := Dispatch([]byte(hcl), "spec.hcl")
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch .hcl: %v", err)
|
||||
}
|
||||
if ws.Kind != "Job" {
|
||||
t.Errorf("Kind = %q, want Job (adapter always sets Job)", ws.Kind)
|
||||
}
|
||||
if ws.Name != "demo" {
|
||||
t.Errorf("Name = %q, want demo (from spec.Job.Name)", ws.Name)
|
||||
}
|
||||
if ws.Runtime == nil {
|
||||
t.Fatal("Runtime is nil; adapter should populate from tasks[0]")
|
||||
}
|
||||
if ws.Runtime.OneOf != "process" {
|
||||
t.Errorf("Runtime.OneOf = %q, want process", ws.Runtime.OneOf)
|
||||
}
|
||||
if ws.Runtime.Command != "/bin/echo" {
|
||||
t.Errorf("Runtime.Command = %q, want /bin/echo (from tasks[0].Command)", ws.Runtime.Command)
|
||||
}
|
||||
if ws.Body != "" {
|
||||
t.Errorf("Body = %q, want empty (HCL has no body)", ws.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatch_HCLAdapterNoTasks(t *testing.T) {
|
||||
hcl := `job "x" {}`
|
||||
_, err := Dispatch([]byte(hcl), "spec.hcl")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for HCL with no tasks")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "at least one task") {
|
||||
t.Errorf("error = %q, want it to contain 'at least one task'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatch_UnknownExtension(t *testing.T) {
|
||||
_, err := Dispatch([]byte("kind: Job\nname: x\n"), "spec.json")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown extension, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown extension") {
|
||||
t.Errorf("error = %q, want it to contain 'unknown extension'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatch_NoExtension(t *testing.T) {
|
||||
_, err := Dispatch([]byte("kind: Job\nname: x\n"), "spec")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for no extension, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatch_EmptyYAML(t *testing.T) {
|
||||
_, err := Dispatch([]byte(""), "spec.yaml")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty YAML, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFile_Markdown(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "spec.md")
|
||||
content := "---\nkind: Job\nname: file-md\n---\nbody\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
ws, err := ParseFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFile .md: %v", err)
|
||||
}
|
||||
if ws.Kind != "Job" || ws.Name != "file-md" {
|
||||
t.Errorf("got Kind=%q Name=%q", ws.Kind, ws.Name)
|
||||
}
|
||||
if ws.Body != "body\n" {
|
||||
t.Errorf("Body = %q, want %q", ws.Body, "body\n")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFile_YAML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "spec.yaml")
|
||||
content := "kind: Service\nname: file-yaml\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
ws, err := ParseFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFile .yaml: %v", err)
|
||||
}
|
||||
if ws.Kind != "Service" || ws.Name != "file-yaml" {
|
||||
t.Errorf("got Kind=%q Name=%q", ws.Kind, ws.Name)
|
||||
}
|
||||
if ws.Body != "" {
|
||||
t.Errorf("Body = %q, want empty", ws.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFile_HCL(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "spec.hcl")
|
||||
content := `job "file-hcl" {}
|
||||
task "t" { command = "/bin/true" }
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
ws, err := ParseFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFile .hcl: %v", err)
|
||||
}
|
||||
if ws.Kind != "Job" || ws.Name != "file-hcl" {
|
||||
t.Errorf("got Kind=%q Name=%q", ws.Kind, ws.Name)
|
||||
}
|
||||
if ws.Runtime == nil || ws.Runtime.Command != "/bin/true" {
|
||||
t.Errorf("Runtime.Command = %v, want /bin/true", ws.Runtime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFile_MissingFile(t *testing.T) {
|
||||
_, err := ParseFile(filepath.Join(t.TempDir(), "nope.md"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "read spec file") {
|
||||
t.Errorf("error = %q, want it to contain 'read spec file'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFile_UnknownExtension(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "spec.txt")
|
||||
if err := os.WriteFile(path, []byte("kind: Job\nname: x\n"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
_, err := ParseFile(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown extension, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown extension") {
|
||||
t.Errorf("error = %q, want 'unknown extension'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHCL_LegacySpec(t *testing.T) {
|
||||
hcl := `job "legacy" {}
|
||||
task "t" { command = "/bin/echo" }
|
||||
`
|
||||
ws, err := ParseHCL([]byte(hcl), "spec.hcl")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseHCL: %v", err)
|
||||
}
|
||||
if ws.Kind != "Job" || ws.Name != "legacy" {
|
||||
t.Errorf("adapter got Kind=%q Name=%q", ws.Kind, ws.Name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
package jobspec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// WorkloadSpec is the unified canonical jobspec populated by both the
|
||||
// Markdown frontmatter parser (canonical path, R-013/R-014) and the HCL
|
||||
// legacy adapter (REQ-064, REQ-090). It is the single shape consumed by
|
||||
// downstream phases (P0c schemas, P01 transport). The Markdown body
|
||||
// after the closing `---` is preserved verbatim in Body (R-015
|
||||
// byte-exact preservation is a load-bearing invariant enforced by the
|
||||
// fuzz harness in markdown_fuzz_test.go).
|
||||
type WorkloadSpec struct {
|
||||
SpecVersion string
|
||||
Kind string
|
||||
Name string
|
||||
Runtime *RuntimeBlock
|
||||
Count int
|
||||
Ports []PortSpec
|
||||
Env map[string]string
|
||||
Secrets []string
|
||||
Volumes []VolumeSpec
|
||||
Body string
|
||||
}
|
||||
|
||||
// RuntimeBlock is a minimal runtime abstraction surface populated by the
|
||||
// Markdown parser. The full runtime abstraction lands in P07; for now
|
||||
// only the one_of/image/command fields are parsed and stored (REQ-064).
|
||||
type RuntimeBlock struct {
|
||||
OneOf string
|
||||
Image string
|
||||
Command string
|
||||
}
|
||||
|
||||
// PortSpec is a minimal port binding entry. HostIP is optional.
|
||||
type PortSpec struct {
|
||||
Name string
|
||||
HostPort int
|
||||
Port int
|
||||
Protocol string
|
||||
HostIP string
|
||||
}
|
||||
|
||||
// VolumeSpec is a minimal volume mount entry. Fields are stored raw
|
||||
// pending the P0c schema work (REQ-074).
|
||||
type VolumeSpec struct {
|
||||
Name string
|
||||
Type string
|
||||
Source string
|
||||
Target string
|
||||
ReadOnly bool
|
||||
}
|
||||
|
||||
// validKinds is the set of workload kinds accepted by the parser per
|
||||
// R-012. Unknown kinds are rejected.
|
||||
var validKinds = map[string]bool{
|
||||
"Job": true,
|
||||
"Service": true,
|
||||
"DaemonSet": true,
|
||||
}
|
||||
|
||||
// ParseMarkdown parses a Markdown jobspec with YAML frontmatter into a
|
||||
// *WorkloadSpec (R-013 canonical format, R-014 frontmatter). The body
|
||||
// after the closing `---` is preserved verbatim in result.Body
|
||||
// (R-015 byte-exact, including trailing newlines, CRLF, and BOM in the
|
||||
// body). The frontmatter parser is a minimal hand-rolled YAML-ish
|
||||
// key:value reader — gopkg.in/yaml.v3 is intentionally not added (same
|
||||
// approach as internal/config/markdown.go and internal/ns/parse.go).
|
||||
//
|
||||
// For .yaml/.yml files (no Markdown body), the dispatcher calls this
|
||||
// with the whole file treated as frontmatter and Body left empty (see
|
||||
// dispatch.go).
|
||||
func ParseMarkdown(data []byte) (*WorkloadSpec, error) {
|
||||
content := string(data)
|
||||
block, body, ok := splitFrontmatter(content)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("parse markdown: missing frontmatter delimiters")
|
||||
}
|
||||
if strings.TrimSpace(block) == "" {
|
||||
return nil, fmt.Errorf("parse markdown: empty frontmatter")
|
||||
}
|
||||
spec, err := parseFrontmatterBlock(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
spec.Body = body
|
||||
if err := validateWorkload(spec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// splitFrontmatter splits the file content into the YAML frontmatter
|
||||
// block and the verbatim body that follows the closing `---`. A leading
|
||||
// UTF-8 BOM is stripped from the frontmatter scan (R-015: BOM is not
|
||||
// preserved in the frontmatter, but a BOM inside the body would be
|
||||
// preserved because the body is verbatim). Returns (block, body, ok).
|
||||
// ok is false when no opening `---` delimiter is present, or no closing
|
||||
// `---` delimiter is found, or the block is empty after the opening
|
||||
// delimiter (handled by caller).
|
||||
func splitFrontmatter(content string) (block, body string, ok bool) {
|
||||
// Strip a leading UTF-8 BOM if present (EF BB BF). Only the
|
||||
// frontmatter scan is BOM-stripped; the body is byte-exact, so a BOM
|
||||
// appearing inside the body is preserved verbatim.
|
||||
stripped := content
|
||||
if strings.HasPrefix(stripped, "\uFEFF") {
|
||||
stripped = stripped[len("\uFEFF"):]
|
||||
}
|
||||
// Trim leading horizontal whitespace and newlines before the
|
||||
// opening delimiter. We do NOT trim trailing — body must be exact.
|
||||
trimmed := strings.TrimLeft(stripped, "\r\n\t ")
|
||||
if !strings.HasPrefix(trimmed, "---") {
|
||||
return "", "", false
|
||||
}
|
||||
// The opening delimiter must be on its own line: `---` optionally
|
||||
// followed by a line terminator.
|
||||
rest := trimmed[3:]
|
||||
// The opening `---` must be followed by a newline or end-of-file
|
||||
// (a `---foo` prefix is not a valid delimiter).
|
||||
if len(rest) > 0 && rest[0] != '\n' && rest[0] != '\r' {
|
||||
return "", "", false
|
||||
}
|
||||
rest = strings.TrimLeft(rest, "\r\n")
|
||||
|
||||
// Find the closing delimiter line. The closing `---` must be on its
|
||||
// own line: preceded by a newline (or at the start of `rest`) and
|
||||
// followed by a newline or end-of-file.
|
||||
idx := findClosingDelimiter(rest)
|
||||
if idx < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
block = rest[:idx]
|
||||
// Body is everything after the closing `---` line. The closing
|
||||
// delimiter line itself (including its trailing newline) is NOT
|
||||
// part of the body. We compute the byte offset in the original
|
||||
// `content` so the body is byte-exact (R-015).
|
||||
afterClose := rest[idx:]
|
||||
// afterClose starts with `---`. Strip the delimiter line.
|
||||
delimLen := 3
|
||||
// Account for an optional trailing `...` or spaces on the delimiter
|
||||
// line — the delimiter is `---` followed by anything up to and
|
||||
// including the line terminator. Body starts after the newline.
|
||||
// Find the end of the delimiter line.
|
||||
newlineIdx := strings.IndexAny(afterClose, "\r\n")
|
||||
var bodyStart int
|
||||
if newlineIdx < 0 {
|
||||
// Closing `---` is the last line: body is empty.
|
||||
bodyStart = len(afterClose)
|
||||
} else {
|
||||
// Consume the delimiter line and its line terminator(s).
|
||||
bodyStart = newlineIdx
|
||||
// Strip a single CRLF or LF.
|
||||
if strings.HasPrefix(afterClose[bodyStart:], "\r\n") {
|
||||
bodyStart += 2
|
||||
} else {
|
||||
bodyStart += 1
|
||||
}
|
||||
}
|
||||
body = afterClose[bodyStart:]
|
||||
_ = delimLen
|
||||
return block, body, true
|
||||
}
|
||||
|
||||
// findClosingDelimiter returns the byte index in `rest` where the
|
||||
// closing `---` delimiter line begins, or -1 if none is found. The
|
||||
// delimiter must be on its own line: either at the start of `rest` or
|
||||
// preceded by a newline, and followed by a newline or end-of-file.
|
||||
func findClosingDelimiter(rest string) int {
|
||||
// Special case: closing delimiter at the very start (frontmatter
|
||||
// block is empty). The opening `---` is immediately followed by the
|
||||
// closing `---`. We require the opening to be its own line, so the
|
||||
// closing at index 0 means the opening had no body — invalid (empty
|
||||
// frontmatter handled by caller). We still report it; caller
|
||||
// rejects empty block.
|
||||
for i := 0; i < len(rest); i++ {
|
||||
if rest[i] != '\n' {
|
||||
continue
|
||||
}
|
||||
// Candidate: line after this newline starts with `---`.
|
||||
j := i + 1
|
||||
if j+3 <= len(rest) && rest[j] == '-' && rest[j+1] == '-' && rest[j+2] == '-' {
|
||||
// Must be followed by newline, CRLF, or end-of-file.
|
||||
end := j + 3
|
||||
if end == len(rest) {
|
||||
return j
|
||||
}
|
||||
if rest[end] == '\n' || rest[end] == '\r' {
|
||||
return j
|
||||
}
|
||||
}
|
||||
}
|
||||
// Final candidate: closing delimiter at the very start of rest
|
||||
// (immediately after the opening delimiter + its newline). This
|
||||
// happens when frontmatter is empty: `---\n---\n`. We already trim
|
||||
// leading newlines off `rest`, so if rest itself starts with `---`
|
||||
// AND it's a closing delimiter (followed by newline/EOF), it is the
|
||||
// empty-frontmatter case.
|
||||
if strings.HasPrefix(rest, "---") {
|
||||
end := 3
|
||||
if end == len(rest) {
|
||||
return 0
|
||||
}
|
||||
if rest[end] == '\n' || rest[end] == '\r' {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// parseFrontmatterBlock parses a minimal YAML-ish frontmatter block into
|
||||
// a *WorkloadSpec (without Body, which is filled by the caller).
|
||||
//
|
||||
// Supported shapes:
|
||||
//
|
||||
// kind: Job
|
||||
// name: my-job
|
||||
// count: 3
|
||||
// runtime:
|
||||
// one_of: process
|
||||
// image: docker.io/nginx:latest
|
||||
// command: /bin/sh -c
|
||||
// ports:
|
||||
// - name: http
|
||||
// port: 8080
|
||||
// host_port: 80
|
||||
// protocol: tcp
|
||||
// env:
|
||||
// FOO: bar
|
||||
// BAR:
|
||||
// from: secret:my-secret
|
||||
// secrets:
|
||||
// - db-password
|
||||
// volumes:
|
||||
// - name: data
|
||||
// type: host
|
||||
// source: /data
|
||||
// target: /data
|
||||
// read_only: true
|
||||
//
|
||||
// Comments (# ...) and blank lines are ignored. Quoted scalar values
|
||||
// ("..." or '...') are unwrapped. No flow collections except the
|
||||
// inline-array form for `secrets`. Multi-line block scalars (|, >) are
|
||||
// not supported — by design, to avoid adding a YAML dependency for this
|
||||
// small surface.
|
||||
func parseFrontmatterBlock(block string) (*WorkloadSpec, error) {
|
||||
spec := &WorkloadSpec{Count: 1}
|
||||
lines := strings.Split(block, "\n")
|
||||
|
||||
type section int
|
||||
const (
|
||||
secNone section = iota
|
||||
secRuntime
|
||||
secPorts
|
||||
secEnv
|
||||
secSecrets
|
||||
secVolumes
|
||||
)
|
||||
cur := secNone
|
||||
var curPort *PortSpec
|
||||
var curVol *VolumeSpec
|
||||
|
||||
flushPort := func() {
|
||||
if curPort != nil {
|
||||
spec.Ports = append(spec.Ports, *curPort)
|
||||
curPort = nil
|
||||
}
|
||||
}
|
||||
flushVol := func() {
|
||||
if curVol != nil {
|
||||
spec.Volumes = append(spec.Volumes, *curVol)
|
||||
curVol = nil
|
||||
}
|
||||
}
|
||||
|
||||
for lineNo, raw := range lines {
|
||||
line := stripComment(raw)
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
indent := countIndent(line)
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
if indent == 0 {
|
||||
// Flush any pending nested entry before switching sections.
|
||||
flushPort()
|
||||
flushVol()
|
||||
cur = secNone
|
||||
|
||||
key, val, ok := splitKV(trimmed)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("parse markdown: line %d: malformed key:value", lineNo+1)
|
||||
}
|
||||
switch key {
|
||||
case "orca-spec-version":
|
||||
spec.SpecVersion = unquote(val)
|
||||
case "kind":
|
||||
spec.Kind = unquote(val)
|
||||
case "name":
|
||||
spec.Name = unquote(val)
|
||||
case "count":
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
|
||||
spec.Count = n
|
||||
} else {
|
||||
return nil, fmt.Errorf("parse markdown: line %d: count: %v", lineNo+1, err)
|
||||
}
|
||||
case "runtime":
|
||||
spec.Runtime = &RuntimeBlock{}
|
||||
if strings.TrimSpace(val) != "" {
|
||||
// Inline value (unusual); ignore — runtime is a block.
|
||||
}
|
||||
cur = secRuntime
|
||||
case "ports":
|
||||
cur = secPorts
|
||||
case "env":
|
||||
spec.Env = map[string]string{}
|
||||
cur = secEnv
|
||||
case "secrets":
|
||||
if strings.TrimSpace(val) != "" {
|
||||
arr, err := parseStringArray(val)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse markdown: line %d: secrets: %w", lineNo+1, err)
|
||||
}
|
||||
spec.Secrets = append(spec.Secrets, arr...)
|
||||
cur = secNone
|
||||
} else {
|
||||
cur = secSecrets
|
||||
}
|
||||
case "volumes":
|
||||
cur = secVolumes
|
||||
default:
|
||||
// Unknown top-level keys are ignored (forward-compat).
|
||||
cur = secNone
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Indented line: a nested entry under the current section.
|
||||
switch cur {
|
||||
case secRuntime:
|
||||
if spec.Runtime == nil {
|
||||
spec.Runtime = &RuntimeBlock{}
|
||||
}
|
||||
key, val, ok := splitKV(trimmed)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "one_of":
|
||||
spec.Runtime.OneOf = unquote(val)
|
||||
case "image":
|
||||
spec.Runtime.Image = unquote(val)
|
||||
case "command":
|
||||
spec.Runtime.Command = unquote(val)
|
||||
}
|
||||
case secPorts:
|
||||
if strings.HasPrefix(trimmed, "- ") || trimmed == "-" {
|
||||
flushPort()
|
||||
p := PortSpec{}
|
||||
curPort = &p
|
||||
rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "-"))
|
||||
if rest != "" {
|
||||
applyPortKV(curPort, rest)
|
||||
}
|
||||
} else if curPort != nil {
|
||||
applyPortKV(curPort, trimmed)
|
||||
}
|
||||
case secEnv:
|
||||
key, val, ok := splitKV(trimmed)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if val == "" {
|
||||
// Nested mapping under env (e.g. `BAR:\n from: ...`).
|
||||
// Store the raw string for now (REQ-064: store raw).
|
||||
spec.Env[key] = ""
|
||||
} else if strings.HasPrefix(val, "{") && strings.HasSuffix(val, "}") {
|
||||
// Inline object form: `BAR: {from: "secret:..."}`.
|
||||
// Store the raw object string for now.
|
||||
spec.Env[key] = val
|
||||
} else {
|
||||
spec.Env[key] = unquote(val)
|
||||
}
|
||||
case secSecrets:
|
||||
if strings.HasPrefix(trimmed, "- ") || trimmed == "-" {
|
||||
item := strings.TrimSpace(strings.TrimPrefix(trimmed, "-"))
|
||||
if item != "" {
|
||||
spec.Secrets = append(spec.Secrets, unquote(item))
|
||||
}
|
||||
}
|
||||
case secVolumes:
|
||||
if strings.HasPrefix(trimmed, "- ") || trimmed == "-" {
|
||||
flushVol()
|
||||
v := VolumeSpec{}
|
||||
curVol = &v
|
||||
rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "-"))
|
||||
if rest != "" {
|
||||
applyVolumeKV(curVol, rest)
|
||||
}
|
||||
} else if curVol != nil {
|
||||
applyVolumeKV(curVol, trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
flushPort()
|
||||
flushVol()
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// applyPortKV applies a `key: value` pair to a PortSpec entry.
|
||||
func applyPortKV(p *PortSpec, s string) {
|
||||
key, val, ok := splitKV(s)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch key {
|
||||
case "name":
|
||||
p.Name = unquote(val)
|
||||
case "host_port":
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
|
||||
p.HostPort = n
|
||||
}
|
||||
case "port":
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
|
||||
p.Port = n
|
||||
}
|
||||
case "protocol":
|
||||
p.Protocol = unquote(val)
|
||||
case "host_ip":
|
||||
p.HostIP = unquote(val)
|
||||
}
|
||||
}
|
||||
|
||||
// applyVolumeKV applies a `key: value` pair to a VolumeSpec entry.
|
||||
func applyVolumeKV(v *VolumeSpec, s string) {
|
||||
key, val, ok := splitKV(s)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch key {
|
||||
case "name":
|
||||
v.Name = unquote(val)
|
||||
case "type":
|
||||
v.Type = unquote(val)
|
||||
case "source":
|
||||
v.Source = unquote(val)
|
||||
case "target":
|
||||
v.Target = unquote(val)
|
||||
case "read_only":
|
||||
switch strings.ToLower(strings.TrimSpace(unquote(val))) {
|
||||
case "true", "yes", "on", "1":
|
||||
v.ReadOnly = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validateWorkload enforces required fields and kind validity (R-012).
|
||||
func validateWorkload(spec *WorkloadSpec) error {
|
||||
if spec.Kind == "" {
|
||||
return fmt.Errorf("parse markdown: missing kind")
|
||||
}
|
||||
if !validKinds[spec.Kind] {
|
||||
return fmt.Errorf("parse markdown: kind %q is not one of Job, Service, DaemonSet", spec.Kind)
|
||||
}
|
||||
if strings.TrimSpace(spec.Name) == "" {
|
||||
return fmt.Errorf("parse markdown: missing name")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseStringArray parses an inline YAML flow-array of scalars, e.g.
|
||||
// `["a", "b"]` or `['a', 'b']` or `[a, b]`. 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
|
||||
}
|
||||
|
||||
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 stripComment(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 {
|
||||
s = strings.TrimSpace(s)
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package jobspec
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// FuzzParseMarkdownRoundTrip is the REQ-067 fuzz harness for R-015
|
||||
// byte-exact body preservation. It generates random frontmatter + body
|
||||
// combinations, runs ParseMarkdown, and asserts that the parsed Body
|
||||
// equals the original body byte-for-byte whenever parsing succeeds.
|
||||
// When parsing fails (bad frontmatter), the iteration passes — the
|
||||
// parser is allowed to reject malformed input.
|
||||
//
|
||||
// The seed corpus (added via f.Add) covers adversarial fixtures: CRLF
|
||||
// body, BOM prefix, no frontmatter, only-closing-separator, body with
|
||||
// `---` inside a code fence, trailing whitespace, empty body. The seed
|
||||
// corpus runs as regular tests under `go test` (CI); random input runs
|
||||
// only under `go test -fuzz=FuzzParseMarkdownRoundTrip` in a dedicated
|
||||
// process.
|
||||
func FuzzParseMarkdownRoundTrip(f *testing.F) {
|
||||
// Seed 1: valid frontmatter + simple body.
|
||||
f.Add([]byte("---\nkind: Job\nname: seed1\n---\n# body\n"))
|
||||
|
||||
// Seed 2: CRLF body.
|
||||
f.Add([]byte("---\r\nkind: Job\r\nname: seed2\r\n---\r\n# body\r\nCRLF\r\n"))
|
||||
|
||||
// Seed 3: BOM prefix.
|
||||
f.Add([]byte("\uFEFF---\nkind: Job\nname: seed3\n---\nbody\n"))
|
||||
|
||||
// Seed 4: no frontmatter (just body) — should fail to parse.
|
||||
f.Add([]byte("# just a body\nno frontmatter\n"))
|
||||
|
||||
// Seed 5: frontmatter with only the closing `---` (no opening).
|
||||
f.Add([]byte("body\n---\nmore body\n"))
|
||||
|
||||
// Seed 6: body containing `---` in a code fence.
|
||||
f.Add([]byte("---\nkind: Job\nname: seed6\n---\n```bash\necho '---'\n```\n"))
|
||||
|
||||
// Seed 7: body with trailing whitespace.
|
||||
f.Add([]byte("---\nkind: Job\nname: seed7\n---\nbody with trailing spaces \n"))
|
||||
|
||||
// Seed 8: empty body.
|
||||
f.Add([]byte("---\nkind: Job\nname: seed8\n---\n"))
|
||||
|
||||
// Seed 9: empty frontmatter (should fail).
|
||||
f.Add([]byte("---\n---\nbody\n"))
|
||||
|
||||
// Seed 10: body with no trailing newline.
|
||||
f.Add([]byte("---\nkind: Job\nname: seed10\n---\nno trailing newline"))
|
||||
|
||||
f.Fuzz(func(t *testing.T, data []byte) {
|
||||
// Reconstruct the body from the input so we can assert
|
||||
// byte-exact round-trip. We do this by re-splitting the
|
||||
// frontmatter using the same logic the parser uses, but only
|
||||
// to extract the expected body. If the input has no valid
|
||||
// frontmatter delimiter pair, ParseMarkdown will return an
|
||||
// error and we pass the iteration.
|
||||
expectedBody := extractExpectedBody(string(data))
|
||||
|
||||
spec, err := ParseMarkdown(data)
|
||||
if err != nil {
|
||||
// Parser rejected the input — acceptable for a fuzz
|
||||
// iteration (the input may be malformed). Pass.
|
||||
return
|
||||
}
|
||||
// R-015: body must be byte-exact.
|
||||
if spec.Body != expectedBody {
|
||||
t.Errorf("R-015 body round-trip mismatch:\n got = %q\nwant = %q", spec.Body, expectedBody)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// extractExpectedBody returns the body portion of a Markdown jobspec
|
||||
// input using the same delimiter-splitting logic as splitFrontmatter,
|
||||
// so the fuzz harness can assert byte-exact preservation independently
|
||||
// of the parser's internal extraction. If the input has no valid
|
||||
// frontmatter, the result is "" (and ParseMarkdown will error).
|
||||
func extractExpectedBody(content string) string {
|
||||
stripped := content
|
||||
if strings.HasPrefix(stripped, "\uFEFF") {
|
||||
stripped = stripped[len("\uFEFF"):]
|
||||
}
|
||||
trimmed := strings.TrimLeft(stripped, "\r\n\t ")
|
||||
if !strings.HasPrefix(trimmed, "---") {
|
||||
return ""
|
||||
}
|
||||
rest := trimmed[3:]
|
||||
if len(rest) > 0 && rest[0] != '\n' && rest[0] != '\r' {
|
||||
return ""
|
||||
}
|
||||
rest = strings.TrimLeft(rest, "\r\n")
|
||||
idx := findClosingDelimiter(rest)
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
afterClose := rest[idx:]
|
||||
newlineIdx := strings.IndexAny(afterClose, "\r\n")
|
||||
if newlineIdx < 0 {
|
||||
return ""
|
||||
}
|
||||
bodyStart := newlineIdx
|
||||
if strings.HasPrefix(afterClose[bodyStart:], "\r\n") {
|
||||
bodyStart += 2
|
||||
} else {
|
||||
bodyStart += 1
|
||||
}
|
||||
return afterClose[bodyStart:]
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
package jobspec
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseMarkdown_FullFrontmatter(t *testing.T) {
|
||||
body := "# Hello\n\nThis is the body.\n\nTrailing newline preserved.\n"
|
||||
input := "---\n" +
|
||||
"orca-spec-version: \"1\"\n" +
|
||||
"kind: Job\n" +
|
||||
"name: my-job\n" +
|
||||
"count: 3\n" +
|
||||
"---\n" +
|
||||
body
|
||||
spec, err := ParseMarkdown([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMarkdown: %v", err)
|
||||
}
|
||||
if spec.SpecVersion != "1" {
|
||||
t.Errorf("SpecVersion = %q, want %q", spec.SpecVersion, "1")
|
||||
}
|
||||
if spec.Kind != "Job" {
|
||||
t.Errorf("Kind = %q, want %q", spec.Kind, "Job")
|
||||
}
|
||||
if spec.Name != "my-job" {
|
||||
t.Errorf("Name = %q, want %q", spec.Name, "my-job")
|
||||
}
|
||||
if spec.Count != 3 {
|
||||
t.Errorf("Count = %d, want 3", spec.Count)
|
||||
}
|
||||
if spec.Body != body {
|
||||
t.Errorf("Body = %q, want %q (byte-exact, R-015)", spec.Body, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdown_BodyByteExactTrailingNewline(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"with_trailing_newline", "# Title\n\nbody\n"},
|
||||
{"with_double_trailing_newline", "# Title\n\nbody\n\n"},
|
||||
{"no_trailing_newline", "# Title\n\nbody"},
|
||||
{"empty_body_with_newline", "\n"},
|
||||
{"only_newlines", "\n\n\n"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
input := "---\nkind: Job\nname: x\n---\n" + tc.body
|
||||
spec, err := ParseMarkdown([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMarkdown: %v", err)
|
||||
}
|
||||
if spec.Body != tc.body {
|
||||
t.Errorf("Body byte-exact mismatch (R-015):\n got = %q\nwant = %q", spec.Body, tc.body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdown_NoFrontmatter(t *testing.T) {
|
||||
input := "# Just a body\n\nNo frontmatter here."
|
||||
_, err := ParseMarkdown([]byte(input))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing frontmatter, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "frontmatter") {
|
||||
t.Errorf("error = %q, want it to contain 'frontmatter'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdown_EmptyFrontmatter(t *testing.T) {
|
||||
input := "---\n---\n\nbody"
|
||||
_, err := ParseMarkdown([]byte(input))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty frontmatter, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "empty frontmatter") {
|
||||
t.Errorf("error = %q, want it to contain 'empty frontmatter'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdown_UnknownKind(t *testing.T) {
|
||||
input := "---\nkind: CronJob\nname: x\n---\nbody\n"
|
||||
_, err := ParseMarkdown([]byte(input))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown kind, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not one of") {
|
||||
t.Errorf("error = %q, want it to contain 'not one of'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdown_MissingName(t *testing.T) {
|
||||
input := "---\nkind: Job\n---\nbody\n"
|
||||
_, err := ParseMarkdown([]byte(input))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing name, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing name") {
|
||||
t.Errorf("error = %q, want it to contain 'missing name'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdown_MissingKind(t *testing.T) {
|
||||
input := "---\nname: x\n---\nbody\n"
|
||||
_, err := ParseMarkdown([]byte(input))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing kind, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing kind") {
|
||||
t.Errorf("error = %q, want it to contain 'missing kind'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdown_EachValidKind(t *testing.T) {
|
||||
cases := []string{"Job", "Service", "DaemonSet"}
|
||||
for _, kind := range cases {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
input := "---\nkind: " + kind + "\nname: x\n---\nbody\n"
|
||||
spec, err := ParseMarkdown([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMarkdown: %v", err)
|
||||
}
|
||||
if spec.Kind != kind {
|
||||
t.Errorf("Kind = %q, want %q", spec.Kind, kind)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdown_EnvScalarAndObject(t *testing.T) {
|
||||
input := "---\n" +
|
||||
"kind: Job\n" +
|
||||
"name: x\n" +
|
||||
"env:\n" +
|
||||
" FOO: bar\n" +
|
||||
" BAZ: \"qux\"\n" +
|
||||
" SECRET_REF:\n" +
|
||||
" from: \"secret:db-password\"\n" +
|
||||
" INLINE: {from: \"secret:token\"}\n" +
|
||||
"---\nbody\n"
|
||||
spec, err := ParseMarkdown([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMarkdown: %v", err)
|
||||
}
|
||||
if got := spec.Env["FOO"]; got != "bar" {
|
||||
t.Errorf("env[FOO] = %q, want %q", got, "bar")
|
||||
}
|
||||
if got := spec.Env["BAZ"]; got != "qux" {
|
||||
t.Errorf("env[BAZ] = %q, want %q", got, "qux")
|
||||
}
|
||||
if got := spec.Env["INLINE"]; got != `{from: "secret:token"}` {
|
||||
t.Errorf("env[INLINE] = %q, want the raw object string", got)
|
||||
}
|
||||
if _, ok := spec.Env["SECRET_REF"]; !ok {
|
||||
t.Errorf("env[SECRET_REF] missing; nested from: stored as empty string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdown_PortsArray(t *testing.T) {
|
||||
input := "---\n" +
|
||||
"kind: Service\n" +
|
||||
"name: web\n" +
|
||||
"ports:\n" +
|
||||
" - name: http\n" +
|
||||
" port: 8080\n" +
|
||||
" host_port: 80\n" +
|
||||
" protocol: tcp\n" +
|
||||
" - name: https\n" +
|
||||
" port: 8443\n" +
|
||||
" host_port: 443\n" +
|
||||
"---\nbody\n"
|
||||
spec, err := ParseMarkdown([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMarkdown: %v", err)
|
||||
}
|
||||
if len(spec.Ports) != 2 {
|
||||
t.Fatalf("Ports = %d, want 2", len(spec.Ports))
|
||||
}
|
||||
if spec.Ports[0].Name != "http" || spec.Ports[0].Port != 8080 || spec.Ports[0].HostPort != 80 || spec.Ports[0].Protocol != "tcp" {
|
||||
t.Errorf("Ports[0] = %+v", spec.Ports[0])
|
||||
}
|
||||
if spec.Ports[1].Name != "https" || spec.Ports[1].Port != 8443 || spec.Ports[1].HostPort != 443 {
|
||||
t.Errorf("Ports[1] = %+v", spec.Ports[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdown_VolumesArray(t *testing.T) {
|
||||
input := "---\n" +
|
||||
"kind: Job\n" +
|
||||
"name: x\n" +
|
||||
"volumes:\n" +
|
||||
" - name: data\n" +
|
||||
" type: host\n" +
|
||||
" source: /data\n" +
|
||||
" target: /data\n" +
|
||||
" read_only: true\n" +
|
||||
"---\nbody\n"
|
||||
spec, err := ParseMarkdown([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMarkdown: %v", err)
|
||||
}
|
||||
if len(spec.Volumes) != 1 {
|
||||
t.Fatalf("Volumes = %d, want 1", len(spec.Volumes))
|
||||
}
|
||||
v := spec.Volumes[0]
|
||||
if v.Name != "data" || v.Type != "host" || v.Source != "/data" || v.Target != "/data" || !v.ReadOnly {
|
||||
t.Errorf("Volumes[0] = %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdown_RuntimeBlock(t *testing.T) {
|
||||
input := "---\n" +
|
||||
"kind: Job\n" +
|
||||
"name: x\n" +
|
||||
"runtime:\n" +
|
||||
" one_of: process\n" +
|
||||
" image: docker.io/nginx:latest\n" +
|
||||
" command: /bin/sh -c 'echo hi'\n" +
|
||||
"---\nbody\n"
|
||||
spec, err := ParseMarkdown([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMarkdown: %v", err)
|
||||
}
|
||||
if spec.Runtime == nil {
|
||||
t.Fatal("Runtime is nil")
|
||||
}
|
||||
if spec.Runtime.OneOf != "process" {
|
||||
t.Errorf("Runtime.OneOf = %q, want %q", spec.Runtime.OneOf, "process")
|
||||
}
|
||||
if spec.Runtime.Image != "docker.io/nginx:latest" {
|
||||
t.Errorf("Runtime.Image = %q, want %q", spec.Runtime.Image, "docker.io/nginx:latest")
|
||||
}
|
||||
if spec.Runtime.Command != "/bin/sh -c 'echo hi'" {
|
||||
t.Errorf("Runtime.Command = %q, want %q", spec.Runtime.Command, "/bin/sh -c 'echo hi'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdown_SecretsInlineArray(t *testing.T) {
|
||||
input := "---\nkind: Job\nname: x\nsecrets: [\"db-password\", \"api-token\"]\n---\nbody\n"
|
||||
spec, err := ParseMarkdown([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMarkdown: %v", err)
|
||||
}
|
||||
if len(spec.Secrets) != 2 {
|
||||
t.Fatalf("Secrets = %d, want 2", len(spec.Secrets))
|
||||
}
|
||||
if spec.Secrets[0] != "db-password" || spec.Secrets[1] != "api-token" {
|
||||
t.Errorf("Secrets = %v, want [db-password api-token]", spec.Secrets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdown_SecretsBlockArray(t *testing.T) {
|
||||
input := "---\n" +
|
||||
"kind: Job\n" +
|
||||
"name: x\n" +
|
||||
"secrets:\n" +
|
||||
" - db-password\n" +
|
||||
" - api-token\n" +
|
||||
"---\nbody\n"
|
||||
spec, err := ParseMarkdown([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMarkdown: %v", err)
|
||||
}
|
||||
if len(spec.Secrets) != 2 {
|
||||
t.Fatalf("Secrets = %d, want 2", len(spec.Secrets))
|
||||
}
|
||||
if spec.Secrets[0] != "db-password" || spec.Secrets[1] != "api-token" {
|
||||
t.Errorf("Secrets = %v, want [db-password api-token]", spec.Secrets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdown_CRLFBodyPreserved(t *testing.T) {
|
||||
body := "# Title\r\n\r\nCRLF body.\r\n"
|
||||
input := "---\r\nkind: Job\r\nname: x\r\n---\r\n" + body
|
||||
spec, err := ParseMarkdown([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMarkdown: %v", err)
|
||||
}
|
||||
if spec.Body != body {
|
||||
t.Errorf("CRLF body not preserved (R-015):\n got = %q\nwant = %q", spec.Body, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdown_BOMStrippedFromFrontmatter(t *testing.T) {
|
||||
body := "# body\n"
|
||||
input := "\uFEFF" + "---\nkind: Job\nname: x\n---\n" + body
|
||||
spec, err := ParseMarkdown([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMarkdown: %v", err)
|
||||
}
|
||||
if spec.Kind != "Job" {
|
||||
t.Errorf("Kind = %q, want Job (BOM should be stripped from frontmatter scan)", spec.Kind)
|
||||
}
|
||||
if spec.Body != body {
|
||||
t.Errorf("Body = %q, want %q", spec.Body, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdown_BodyWithCodeFenceContainingDashes(t *testing.T) {
|
||||
body := "```bash\n" +
|
||||
"echo '---'\n" +
|
||||
"echo '--- end ---'\n" +
|
||||
"```\n"
|
||||
input := "---\nkind: Job\nname: x\n---\n" + body
|
||||
spec, err := ParseMarkdown([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMarkdown: %v", err)
|
||||
}
|
||||
if spec.Body != body {
|
||||
t.Errorf("Body with code-fence --- not preserved (R-015):\n got = %q\nwant = %q", spec.Body, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdown_OnlyClosingSeparator(t *testing.T) {
|
||||
input := "no opening\n---\nbody\n"
|
||||
_, err := ParseMarkdown([]byte(input))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for input with only closing separator, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdown_QuotedValues(t *testing.T) {
|
||||
input := "---\nkind: \"Job\"\nname: 'my-job'\n---\nbody\n"
|
||||
spec, err := ParseMarkdown([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMarkdown: %v", err)
|
||||
}
|
||||
if spec.Kind != "Job" {
|
||||
t.Errorf("Kind = %q, want Job (double-quoted)", spec.Kind)
|
||||
}
|
||||
if spec.Name != "my-job" {
|
||||
t.Errorf("Name = %q, want my-job (single-quoted)", spec.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdown_CountDefault(t *testing.T) {
|
||||
input := "---\nkind: Job\nname: x\n---\nbody\n"
|
||||
spec, err := ParseMarkdown([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMarkdown: %v", err)
|
||||
}
|
||||
if spec.Count != 1 {
|
||||
t.Errorf("Count default = %d, want 1", spec.Count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdown_UnknownKeyIgnored(t *testing.T) {
|
||||
input := "---\nkind: Job\nname: x\nfuture_field: value\n---\nbody\n"
|
||||
_, err := ParseMarkdown([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMarkdown should ignore unknown keys: %v", err)
|
||||
}
|
||||
}
|
||||
+26
-27
@@ -2,7 +2,6 @@ package jobspec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
@@ -10,16 +9,24 @@ import (
|
||||
"github.com/hashicorp/hcl/v2/hclsimple"
|
||||
)
|
||||
|
||||
// Spec is the legacy HCL-only jobspec shape. It is retained for the
|
||||
// v0.9→v0.10 migration window (REQ-090) and is populated by ParseHCLLegacy.
|
||||
//
|
||||
// Deprecated: HCL is legacy per R-013; new code should consume the
|
||||
// unified *WorkloadSpec returned by ParseFile/Dispatch (see
|
||||
// dispatch.go and markdown.go).
|
||||
type Spec struct {
|
||||
Job JobSpec `hcl:"job,block"`
|
||||
Tasks []TaskSpec `hcl:"task,block"`
|
||||
}
|
||||
|
||||
// JobSpec is the legacy HCL job block.
|
||||
type JobSpec struct {
|
||||
Name string `hcl:"name,label"`
|
||||
Type string `hcl:"type,optional"`
|
||||
}
|
||||
|
||||
// TaskSpec is the legacy HCL task block.
|
||||
type TaskSpec struct {
|
||||
Name string `hcl:"name,label"`
|
||||
Command string `hcl:"command"`
|
||||
@@ -27,34 +34,14 @@ type TaskSpec struct {
|
||||
Env []string `hcl:"env,optional"`
|
||||
}
|
||||
|
||||
func ParseFile(path string) (*Spec, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read spec file: %w", err)
|
||||
}
|
||||
return Parse(data, path)
|
||||
}
|
||||
|
||||
func Parse(data []byte, filename string) (*Spec, error) {
|
||||
var spec Spec
|
||||
err := hclsimple.Decode(filename, data, nil, &spec)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode hcl: %w", err)
|
||||
}
|
||||
if spec.Job.Name == "" {
|
||||
return nil, fmt.Errorf("spec missing job name")
|
||||
}
|
||||
if len(spec.Tasks) == 0 {
|
||||
return nil, fmt.Errorf("spec must have at least one task")
|
||||
}
|
||||
for i, t := range spec.Tasks {
|
||||
if t.Command == "" {
|
||||
return nil, fmt.Errorf("task[%d] (%s) missing command", i, t.Name)
|
||||
}
|
||||
}
|
||||
return &spec, nil
|
||||
// hclDecode wraps hclsimple.Decode for testability.
|
||||
func hclDecode(filename string, data []byte, spec *Spec) error {
|
||||
return hclsimple.Decode(filename, data, nil, spec)
|
||||
}
|
||||
|
||||
// Validate is the legacy HCL Spec validator retained for the migration
|
||||
// window (REQ-090). New code should use validateWorkload on a
|
||||
// *WorkloadSpec.
|
||||
func (s *Spec) Validate() error {
|
||||
if strings.TrimSpace(s.Job.Name) == "" {
|
||||
return fmt.Errorf("job name is required")
|
||||
@@ -65,5 +52,17 @@ func (s *Spec) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse is the original HCL-only entry point retained for backward
|
||||
// compatibility with direct HCL callers during the v0.9→v0.10 migration
|
||||
// window (REQ-090). New code should call the dispatcher ParseFile (which
|
||||
// returns *WorkloadSpec) or ParseHCL (which adapts HCL into
|
||||
// *WorkloadSpec).
|
||||
//
|
||||
// Deprecated: use ParseFile (dispatcher) or ParseHCL (adapter). HCL is
|
||||
// legacy per R-013.
|
||||
func Parse(data []byte, filename string) (*Spec, error) {
|
||||
return ParseHCLLegacy(data, filename)
|
||||
}
|
||||
|
||||
var _ = hcl.Diagnostics{}
|
||||
var _ = gohcl.DecodeBody
|
||||
|
||||
@@ -130,9 +130,9 @@ func TestParse_GoldenFiles(t *testing.T) {
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
path := filepath.Join("testdata", tc.file)
|
||||
spec, err := ParseFile(path)
|
||||
spec, err := ParseHCLFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFile(%s): %v", tc.file, err)
|
||||
t.Fatalf("ParseHCLFile(%s): %v", tc.file, err)
|
||||
}
|
||||
if spec.Job.Name != tc.wantJob {
|
||||
t.Errorf("job name = %q, want %q", spec.Job.Name, tc.wantJob)
|
||||
@@ -254,9 +254,9 @@ func TestSpec_Validate(t *testing.T) {
|
||||
|
||||
func TestSpec_Validate_RoundTripFromParse(t *testing.T) {
|
||||
path := filepath.Join("testdata", "valid_single_task.hcl")
|
||||
spec, err := ParseFile(path)
|
||||
spec, err := ParseHCLFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFile: %v", err)
|
||||
t.Fatalf("ParseHCLFile: %v", err)
|
||||
}
|
||||
if err := spec.Validate(); err != nil {
|
||||
t.Errorf("Validate on parsed spec: %v", err)
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package ns
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeNSMd(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
const validNSMd = `---
|
||||
kind: Namespace
|
||||
name: prod
|
||||
parents: ["_defaults"]
|
||||
inherits_env: true
|
||||
inherits_secrets: true
|
||||
quota:
|
||||
cpu: 4
|
||||
acl:
|
||||
admin: ops
|
||||
---
|
||||
# Prod namespace
|
||||
|
||||
This body is ignored.
|
||||
`
|
||||
|
||||
func TestParseNSMdValid(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
path := filepath.Join(tmp, "ns.md")
|
||||
writeNSMd(t, path, validNSMd)
|
||||
cfg, err := ParseNSMd(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseNSMd: %v", err)
|
||||
}
|
||||
if cfg.Name != "prod" {
|
||||
t.Errorf("name = %q, want prod", cfg.Name)
|
||||
}
|
||||
if !eqSlice(cfg.Parents, []string{"_defaults"}) {
|
||||
t.Errorf("parents = %v, want [_defaults]", cfg.Parents)
|
||||
}
|
||||
if !cfg.InheritsEnv || !cfg.InheritsSecrets {
|
||||
t.Errorf("inherits_env=%v inherits_secrets=%v, want both true", cfg.InheritsEnv, cfg.InheritsSecrets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNSMdMissingFrontmatter(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
path := filepath.Join(tmp, "ns.md")
|
||||
writeNSMd(t, path, "# just a body, no frontmatter\n")
|
||||
_, err := ParseNSMd(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected missing frontmatter error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing frontmatter") {
|
||||
t.Errorf("error = %q, want contains 'missing frontmatter'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNSMdEmptyFrontmatter(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
path := filepath.Join(tmp, "ns.md")
|
||||
writeNSMd(t, path, "---\n---\nbody\n")
|
||||
_, err := ParseNSMd(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty frontmatter, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNSMdWrongKind(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
path := filepath.Join(tmp, "ns.md")
|
||||
writeNSMd(t, path, "---\nkind: Job\nname: x\n---\n")
|
||||
_, err := ParseNSMd(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected wrong-kind error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not \"Namespace\"") {
|
||||
t.Errorf("error = %q, want contains 'is not \"Namespace\"'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNSMdMissingKind(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
path := filepath.Join(tmp, "ns.md")
|
||||
writeNSMd(t, path, "---\nname: x\n---\n")
|
||||
_, err := ParseNSMd(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected missing kind error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing kind") {
|
||||
t.Errorf("error = %q, want contains 'missing kind'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNSMdMissingName(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
path := filepath.Join(tmp, "ns.md")
|
||||
writeNSMd(t, path, "---\nkind: Namespace\n---\n")
|
||||
_, err := ParseNSMd(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected missing name error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing name") {
|
||||
t.Errorf("error = %q, want contains 'missing name'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNSMdParentsUnquoted(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
path := filepath.Join(tmp, "ns.md")
|
||||
writeNSMd(t, path, "---\nkind: Namespace\nname: x\nparents: [a, b]\n---\n")
|
||||
cfg, err := ParseNSMd(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseNSMd: %v", err)
|
||||
}
|
||||
if !eqSlice(cfg.Parents, []string{"a", "b"}) {
|
||||
t.Errorf("parents = %v, want [a b]", cfg.Parents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNSMdParentsEmpty(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
path := filepath.Join(tmp, "ns.md")
|
||||
writeNSMd(t, path, "---\nkind: Namespace\nname: x\nparents: []\n---\n")
|
||||
cfg, err := ParseNSMd(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseNSMd: %v", err)
|
||||
}
|
||||
if len(cfg.Parents) != 0 {
|
||||
t.Errorf("parents = %v, want empty", cfg.Parents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNSMdInheritsFalse(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
path := filepath.Join(tmp, "ns.md")
|
||||
writeNSMd(t, path, "---\nkind: Namespace\nname: x\ninherits_env: false\ninherits_secrets: no\n---\n")
|
||||
cfg, err := ParseNSMd(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseNSMd: %v", err)
|
||||
}
|
||||
if cfg.InheritsEnv {
|
||||
t.Errorf("inherits_env should be false")
|
||||
}
|
||||
if cfg.InheritsSecrets {
|
||||
t.Errorf("inherits_secrets should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNSMdMissingFile(t *testing.T) {
|
||||
_, err := ParseNSMd(filepath.Join(t.TempDir(), "nope.md"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNSMdDirHappy(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeNSMd(t, filepath.Join(root, "_defaults", "ns.md"), "---\nkind: Namespace\nname: _defaults\n---\n")
|
||||
writeNSMd(t, filepath.Join(root, "prod", "ns.md"), validNSMd)
|
||||
|
||||
cfgs, err := ParseNSMdDir(root)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseNSMdDir: %v", err)
|
||||
}
|
||||
if _, ok := cfgs["_defaults"]; !ok {
|
||||
t.Errorf("missing _defaults in %v", cfgs)
|
||||
}
|
||||
if _, ok := cfgs["prod"]; !ok {
|
||||
t.Errorf("missing prod in %v", cfgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNSMdDirMissingDefaults(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeNSMd(t, filepath.Join(root, "prod", "ns.md"), validNSMd)
|
||||
_, err := ParseNSMdDir(root)
|
||||
if err == nil {
|
||||
t.Fatal("expected missing _defaults error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "_defaults") {
|
||||
t.Errorf("error = %q, want contains _defaults", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNSMdDirSkipsCluster(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeNSMd(t, filepath.Join(root, "_defaults", "ns.md"), "---\nkind: Namespace\nname: _defaults\n---\n")
|
||||
// cluster/ contains a ns.md-shaped file but must be skipped.
|
||||
writeNSMd(t, filepath.Join(root, "cluster", "ns.md"), "---\nkind: Namespace\nname: cluster\n---\n")
|
||||
|
||||
cfgs, err := ParseNSMdDir(root)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseNSMdDir: %v", err)
|
||||
}
|
||||
if _, ok := cfgs["cluster"]; ok {
|
||||
t.Errorf("cluster should be skipped, present in %v", cfgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNSMdDirNoFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
_, err := ParseNSMdDir(root)
|
||||
if err == nil {
|
||||
t.Fatal("expected missing _defaults error on empty dir, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNSMdDirNotADir(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
// Create a file with the same name as the expected root dir.
|
||||
root := filepath.Join(tmp, "notadir")
|
||||
writeNSMd(t, root, "x")
|
||||
_, err := ParseNSMdDir(root)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-dir root")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
// 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]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user