c3819dde12
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---
853 lines
24 KiB
Go
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)
|
|
}
|
|
}
|