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) } } func TestParseMarkdown_RestartBlock(t *testing.T) { input := "---\n" + "kind: Service\n" + "name: web\n" + "restart:\n" + " mode: service\n" + " attempts: 5\n" + " delay: 3s\n" + "---\nbody\n" spec, err := ParseMarkdown([]byte(input)) if err != nil { t.Fatalf("ParseMarkdown: %v", err) } if spec.Restart == nil { t.Fatal("Restart is nil") } if spec.Restart.Mode != "service" { t.Errorf("Restart.Mode = %q, want service", spec.Restart.Mode) } if spec.Restart.MaxRetries != 5 { t.Errorf("Restart.MaxRetries = %d, want 5", spec.Restart.MaxRetries) } if spec.Restart.Delay != "3s" { t.Errorf("Restart.Delay = %q, want 3s", spec.Restart.Delay) } } func TestParseMarkdown_RestartBlockMaxRetriesAlias(t *testing.T) { // max_retries is the canonical key; attempts is an accepted alias. input := "---\n" + "kind: Service\n" + "name: web\n" + "restart:\n" + " mode: on-failure\n" + " max_retries: 3\n" + " delay: 1s\n" + "---\nbody\n" spec, err := ParseMarkdown([]byte(input)) if err != nil { t.Fatalf("ParseMarkdown: %v", err) } if spec.Restart == nil || spec.Restart.MaxRetries != 3 { t.Fatalf("Restart.MaxRetries = %d, want 3 (max_retries alias)", spec.Restart.MaxRetries) } } func TestParseMarkdown_UpdateBlock(t *testing.T) { input := "---\n" + "kind: Service\n" + "name: web\n" + "update:\n" + " strategy: canary\n" + " max_parallel: 2\n" + " min_healthy_time: 30s\n" + " healthy_deadline: 5m\n" + " canary: 10%\n" + " auto_promote: true\n" + "---\nbody\n" spec, err := ParseMarkdown([]byte(input)) if err != nil { t.Fatalf("ParseMarkdown: %v", err) } if spec.Update == nil { t.Fatal("Update is nil") } if spec.Update.Strategy != "canary" { t.Errorf("Update.Strategy = %q, want canary", spec.Update.Strategy) } if spec.Update.MaxParallel != 2 { t.Errorf("Update.MaxParallel = %d, want 2", spec.Update.MaxParallel) } if spec.Update.MinHealthyTime != "30s" { t.Errorf("Update.MinHealthyTime = %q, want 30s", spec.Update.MinHealthyTime) } if spec.Update.HealthyDeadline != "5m" { t.Errorf("Update.HealthyDeadline = %q, want 5m", spec.Update.HealthyDeadline) } if spec.Update.Canary != "10%" { t.Errorf("Update.Canary = %q, want 10%%", spec.Update.Canary) } if !spec.Update.AutoPromote { t.Errorf("Update.AutoPromote = false, want true") } } func TestParseMarkdown_ServiceBlock(t *testing.T) { input := "---\n" + "kind: Service\n" + "name: web\n" + "service:\n" + " name: web\n" + " port: 8080\n" + " bind: 127.0.0.1\n" + "---\nbody\n" spec, err := ParseMarkdown([]byte(input)) if err != nil { t.Fatalf("ParseMarkdown: %v", err) } if spec.Service == nil { t.Fatal("Service is nil") } if spec.Service.Name != "web" { t.Errorf("Service.Name = %q, want web", spec.Service.Name) } if spec.Service.Port != 8080 { t.Errorf("Service.Port = %d, want 8080", spec.Service.Port) } if spec.Service.Bind != "127.0.0.1" { t.Errorf("Service.Bind = %q, want 127.0.0.1", spec.Service.Bind) } } func TestParseMarkdown_HealthBlock(t *testing.T) { input := "---\n" + "kind: Service\n" + "name: web\n" + "health:\n" + " check_type: http\n" + " interval: 10s\n" + " timeout: 2s\n" + " unhealthy_threshold: 3\n" + "---\nbody\n" spec, err := ParseMarkdown([]byte(input)) if err != nil { t.Fatalf("ParseMarkdown: %v", err) } if spec.Health == nil { t.Fatal("Health is nil") } if spec.Health.CheckType != "http" { t.Errorf("Health.CheckType = %q, want http", spec.Health.CheckType) } if spec.Health.Interval != "10s" { t.Errorf("Health.Interval = %q, want 10s", spec.Health.Interval) } if spec.Health.Timeout != "2s" { t.Errorf("Health.Timeout = %q, want 2s", spec.Health.Timeout) } if spec.Health.UnhealthyThreshold != 3 { t.Errorf("Health.UnhealthyThreshold = %d, want 3", spec.Health.UnhealthyThreshold) } } func TestParseMarkdown_ConstraintsInlineArray(t *testing.T) { // Inline flow-array form: the parser does NOT unescape YAML // escapes (consistent with the secrets inline parser). Use // single-quoted scalars inside the flow array so the CEL strings // are preserved verbatim. input := "---\nkind: Service\nname: web\nconstraints: ['node.role == \"web\"', 'region == \"us\"']\n---\nbody\n" spec, err := ParseMarkdown([]byte(input)) if err != nil { t.Fatalf("ParseMarkdown: %v", err) } if len(spec.Constraints) != 2 { t.Fatalf("Constraints = %d, want 2", len(spec.Constraints)) } if spec.Constraints[0] != `node.role == "web"` { t.Errorf("Constraints[0] = %q", spec.Constraints[0]) } if spec.Constraints[1] != `region == "us"` { t.Errorf("Constraints[1] = %q", spec.Constraints[1]) } } func TestParseMarkdown_ConstraintsBlockArray(t *testing.T) { input := "---\n" + "kind: Service\n" + "name: web\n" + "constraints:\n" + " - node.role == \"web\"\n" + " - region == \"us\"\n" + "---\nbody\n" spec, err := ParseMarkdown([]byte(input)) if err != nil { t.Fatalf("ParseMarkdown: %v", err) } if len(spec.Constraints) != 2 { t.Fatalf("Constraints = %d, want 2", len(spec.Constraints)) } if spec.Constraints[0] != `node.role == "web"` { t.Errorf("Constraints[0] = %q", spec.Constraints[0]) } if spec.Constraints[1] != `region == "us"` { t.Errorf("Constraints[1] = %q", spec.Constraints[1]) } } func TestParseMarkdown_AffinityBlock(t *testing.T) { input := "---\n" + "kind: Service\n" + "name: web\n" + "affinity:\n" + " - target: node.role == \"web\"\n" + " weight: 100\n" + " - target: region == \"us\"\n" + " weight: 50\n" + "---\nbody\n" spec, err := ParseMarkdown([]byte(input)) if err != nil { t.Fatalf("ParseMarkdown: %v", err) } if len(spec.Affinity) != 2 { t.Fatalf("Affinity = %d, want 2", len(spec.Affinity)) } if spec.Affinity[0].Target != `node.role == "web"` { t.Errorf("Affinity[0].Target = %q", spec.Affinity[0].Target) } if spec.Affinity[0].Weight != 100 { t.Errorf("Affinity[0].Weight = %d, want 100", spec.Affinity[0].Weight) } if spec.Affinity[1].Target != `region == "us"` { t.Errorf("Affinity[1].Target = %q", spec.Affinity[1].Target) } if spec.Affinity[1].Weight != 50 { t.Errorf("Affinity[1].Weight = %d, want 50", spec.Affinity[1].Weight) } } func TestParseMarkdown_LifecycleBlock(t *testing.T) { input := "---\n" + "kind: Service\n" + "name: web\n" + "lifecycle:\n" + " pre_stop:\n" + " - /bin/sh -c 'sleep 5'\n" + " - /usr/local/bin/drain.sh\n" + " post_start:\n" + " - /usr/local/bin/warm-cache.sh\n" + "---\nbody\n" spec, err := ParseMarkdown([]byte(input)) if err != nil { t.Fatalf("ParseMarkdown: %v", err) } if spec.Lifecycle == nil { t.Fatal("Lifecycle is nil") } if len(spec.Lifecycle.PreStop) != 2 { t.Fatalf("PreStop = %d, want 2", len(spec.Lifecycle.PreStop)) } if spec.Lifecycle.PreStop[0] != "/bin/sh -c 'sleep 5'" { t.Errorf("PreStop[0] = %q", spec.Lifecycle.PreStop[0]) } if spec.Lifecycle.PreStop[1] != "/usr/local/bin/drain.sh" { t.Errorf("PreStop[1] = %q", spec.Lifecycle.PreStop[1]) } if len(spec.Lifecycle.PostStart) != 1 { t.Fatalf("PostStart = %d, want 1", len(spec.Lifecycle.PostStart)) } if spec.Lifecycle.PostStart[0] != "/usr/local/bin/warm-cache.sh" { t.Errorf("PostStart[0] = %q", spec.Lifecycle.PostStart[0]) } } func TestParseMarkdown_LifecycleInlineArray(t *testing.T) { input := "---\n" + "kind: Service\n" + "name: web\n" + "lifecycle:\n" + " pre_stop: [\"/bin/true\"]\n" + " post_start: [\"/bin/warmup\", \"/bin/check\"]\n" + "---\nbody\n" spec, err := ParseMarkdown([]byte(input)) if err != nil { t.Fatalf("ParseMarkdown: %v", err) } if spec.Lifecycle == nil { t.Fatal("Lifecycle is nil") } if len(spec.Lifecycle.PreStop) != 1 || spec.Lifecycle.PreStop[0] != "/bin/true" { t.Errorf("PreStop = %v, want [/bin/true]", spec.Lifecycle.PreStop) } if len(spec.Lifecycle.PostStart) != 2 { t.Fatalf("PostStart = %v, want 2 entries", spec.Lifecycle.PostStart) } if spec.Lifecycle.PostStart[0] != "/bin/warmup" || spec.Lifecycle.PostStart[1] != "/bin/check" { t.Errorf("PostStart = %v, want [/bin/warmup /bin/check]", spec.Lifecycle.PostStart) } } func TestParseMarkdown_FullServiceSpec(t *testing.T) { // A complete Service spec exercising every P02-parsed block together. input := "---\n" + "kind: Service\n" + "name: web\n" + "count: 3\n" + "runtime:\n" + " one_of: process\n" + " command: /usr/bin/httpd\n" + "ports:\n" + " - name: http\n" + " port: 8080\n" + "restart:\n" + " mode: service\n" + " attempts: 5\n" + " delay: 2s\n" + "update:\n" + " strategy: rolling\n" + " max_parallel: 1\n" + " auto_promote: false\n" + "service:\n" + " name: web\n" + " port: 8080\n" + "health:\n" + " check_type: http\n" + " interval: 5s\n" + " timeout: 1s\n" + " unhealthy_threshold: 2\n" + "constraints:\n" + " - node.role == \"web\"\n" + "affinity:\n" + " - target: zone == \"a\"\n" + " weight: 80\n" + "lifecycle:\n" + " post_start:\n" + " - /bin/ready.sh\n" + "---\n# body\n" spec, err := ParseMarkdown([]byte(input)) if err != nil { t.Fatalf("ParseMarkdown: %v", err) } if spec.Restart == nil || spec.Restart.Mode != "service" { t.Errorf("Restart not parsed: %+v", spec.Restart) } if spec.Update == nil || spec.Update.Strategy != "rolling" { t.Errorf("Update not parsed: %+v", spec.Update) } if spec.Service == nil || spec.Service.Port != 8080 { t.Errorf("Service not parsed: %+v", spec.Service) } if spec.Health == nil || spec.Health.CheckType != "http" { t.Errorf("Health not parsed: %+v", spec.Health) } if len(spec.Constraints) != 1 { t.Errorf("Constraints = %v", spec.Constraints) } if len(spec.Affinity) != 1 || spec.Affinity[0].Weight != 80 { t.Errorf("Affinity = %v", spec.Affinity) } if spec.Lifecycle == nil || len(spec.Lifecycle.PostStart) != 1 { t.Errorf("Lifecycle not parsed: %+v", spec.Lifecycle) } if spec.Body != "# body\n" { t.Errorf("Body = %q, want %q (R-015)", spec.Body, "# body\n") } } func TestParseMarkdown_TasksBlock(t *testing.T) { // P06: a task group with two tasks, each carrying its own runtime // and command. The parser must populate spec.Tasks with two // entries preserving name, runtime (one_of/image/command), and // the task-level command. input := "---\n" + "kind: Service\n" + "name: web\n" + "tasks:\n" + " - name: app\n" + " runtime:\n" + " one_of: process\n" + " image: docker.io/nginx:latest\n" + " command: /usr/bin/httpd -f\n" + " command: /usr/bin/httpd -f\n" + " - name: sidecar\n" + " runtime:\n" + " one_of: wasm\n" + " command: /bin/wasm-runner sidecar.wasm\n" + " command: /bin/wasm-runner sidecar.wasm\n" + "---\nbody\n" spec, err := ParseMarkdown([]byte(input)) if err != nil { t.Fatalf("ParseMarkdown: %v", err) } if len(spec.Tasks) != 2 { t.Fatalf("Tasks = %d, want 2", len(spec.Tasks)) } app := spec.Tasks[0] if app.Name != "app" { t.Errorf("Tasks[0].Name = %q, want app", app.Name) } if app.Runtime == nil { t.Fatal("Tasks[0].Runtime is nil") } if app.Runtime.OneOf != "process" { t.Errorf("Tasks[0].Runtime.OneOf = %q, want process", app.Runtime.OneOf) } if app.Runtime.Image != "docker.io/nginx:latest" { t.Errorf("Tasks[0].Runtime.Image = %q", app.Runtime.Image) } if app.Runtime.Command != "/usr/bin/httpd -f" { t.Errorf("Tasks[0].Runtime.Command = %q", app.Runtime.Command) } if app.Command != "/usr/bin/httpd -f" { t.Errorf("Tasks[0].Command = %q", app.Command) } side := spec.Tasks[1] if side.Name != "sidecar" { t.Errorf("Tasks[1].Name = %q, want sidecar", side.Name) } if side.Runtime == nil || side.Runtime.OneOf != "wasm" { t.Errorf("Tasks[1].Runtime = %+v, want one_of=wasm", side.Runtime) } if side.Command != "/bin/wasm-runner sidecar.wasm" { t.Errorf("Tasks[1].Command = %q", side.Command) } } func TestParseMarkdown_TasksBlockWithEnv(t *testing.T) { // P06: a task group task carrying an env overlay. input := "---\n" + "kind: Service\n" + "name: web\n" + "tasks:\n" + " - name: app\n" + " command: /usr/bin/httpd\n" + " env:\n" + " LOG_LEVEL: debug\n" + " REGION: us\n" + "---\nbody\n" spec, err := ParseMarkdown([]byte(input)) if err != nil { t.Fatalf("ParseMarkdown: %v", err) } if len(spec.Tasks) != 1 { t.Fatalf("Tasks = %d, want 1", len(spec.Tasks)) } task := spec.Tasks[0] if task.Env == nil { t.Fatal("Tasks[0].Env is nil") } if got := task.Env["LOG_LEVEL"]; got != "debug" { t.Errorf("Env[LOG_LEVEL] = %q, want debug", got) } if got := task.Env["REGION"]; got != "us" { t.Errorf("Env[REGION] = %q, want us", got) } } func TestParseMarkdown_TasksBlockInheritsTopLevelRuntime(t *testing.T) { // P06: when a task omits its own runtime, the top-level runtime // is the per-group default. The parser must NOT create a task // runtime when the task block lacks a `runtime:` sub-block; the // emitter/validator resolve the default from spec.Runtime. input := "---\n" + "kind: Service\n" + "name: web\n" + "runtime:\n" + " one_of: process\n" + " command: /bin/default\n" + "tasks:\n" + " - name: app\n" + " command: /bin/app\n" + " - name: sidecar\n" + " command: /bin/sidecar\n" + "---\nbody\n" spec, err := ParseMarkdown([]byte(input)) if err != nil { t.Fatalf("ParseMarkdown: %v", err) } if spec.Runtime == nil || spec.Runtime.OneOf != "process" { t.Fatalf("top-level runtime not parsed: %+v", spec.Runtime) } if len(spec.Tasks) != 2 { t.Fatalf("Tasks = %d, want 2", len(spec.Tasks)) } for i, task := range spec.Tasks { if task.Runtime != nil { t.Errorf("Tasks[%d].Runtime should be nil (inherit top-level), got %+v", i, task.Runtime) } } if spec.Tasks[0].Name != "app" || spec.Tasks[1].Name != "sidecar" { t.Errorf("task names = %q, %q", spec.Tasks[0].Name, spec.Tasks[1].Name) } } func TestParseMarkdown_NoTasksBackwardCompat(t *testing.T) { // Backward compat: a spec with no `tasks:` block parses as a // single-process alloc; spec.Tasks must be empty/nil. input := "---\n" + "kind: Job\n" + "name: backup\n" + "runtime:\n" + " one_of: process\n" + " command: /bin/rsync\n" + "---\nbody\n" spec, err := ParseMarkdown([]byte(input)) if err != nil { t.Fatalf("ParseMarkdown: %v", err) } if len(spec.Tasks) != 0 { t.Fatalf("Tasks = %d, want 0 (backward compat)", len(spec.Tasks)) } if spec.Runtime == nil || spec.Runtime.Command != "/bin/rsync" { t.Errorf("Runtime = %+v, want command=/bin/rsync", spec.Runtime) } }