Files
orca/internal/jobspec/markdown_test.go
T
Jon Chery c3819dde12 feat(P06): task groups — multi-process services, multiple systemd units per alloc
P06 — Task groups (PRD §9.1: multiple systemd units per alloc).

Parser (internal/jobspec/markdown.go):
- TaskGroupTask type (Name, Runtime, Env, Command). Tasks []TaskGroupTask on
  WorkloadSpec. Parses tasks: frontmatter block (array of task objects).
  Tasks without their own runtime inherit the top-level Runtime as default.
  Backward compat: no tasks -> single-process (existing runtime block).

Systemd emitter (internal/emitter/systemd.go):
- Task group renders one systemd unit per task (orca-v1-alloc-<id>-<task>
  .service) plus a grouping target unit (orca-v1-alloc-<id>.target). Each
  per-task unit carries PartOf=<target> and WantedBy=multi-user.target.
  Single-process case unchanged (backward compat).

Schema (internal/spec/schema/schema.go):
- TaskGroup validation: unique task names, resolvable command (own or
  inherited). JobValidator/ServiceValidator/DaemonSetValidator all accept
  task groups.

Tests: 9 task-group tests in schema_test.go, lifecycle + target-unit tests
in systemd_test.go, parser tests in markdown_test.go. 22 packages pass.

Fix: 3 Service task-group test fixtures missing Count:1 (ServiceValidator
requires count>=1; a task-group Service still has >=1 replica).

---ci---
project: orca
phase: P06
milestone: v0.9
status: execute
---/ci---
2026-08-05 18:20:05 +00:00

853 lines
24 KiB
Go

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)
}
}