Files
orca/internal/jobspec/markdown.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

1138 lines
31 KiB
Go

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
// Kind-specific blocks consumed by the P0c schema validators
// (internal/spec/schema). P02 populates Restart, Update, Service,
// Health, Constraints, Affinity, Lifecycle from the Markdown
// frontmatter (the rest are still populated by later phases).
// Restart is the restart policy block. Required for Service and
// DaemonSet; optional for Job (defaults to never/on-failure).
// P02 populates it from the `restart:` frontmatter block.
Restart *RestartBlock
// Schedule is the schedule block. For Job it carries an optional
// cron string; for DaemonSet it carries the placement mode
// (every-node/matching/mandatory). Populated by P05 (scheduler
// skeleton) and the DaemonSet phase.
Schedule *ScheduleBlock
// Update is the rolling/canary update stanza. Required for
// Service. P02 populates it from the `update:` frontmatter block;
// the rolling/canary semantics land in P03.
Update *UpdateBlock
// Service is the service block (Traefik route definition). For
// Service kind it is implied; Job and DaemonSet do not carry a
// Traefik route by default (D-175). P02 populates it from the
// `service:` frontmatter block.
Service *ServiceBlock
// Health is the health-check block. Required for Service (Traefik
// routing depends on it). P02 populates it from the `health:`
// frontmatter block (R-012).
Health *HealthBlock
// Constraints is the CEL expression list for placement. P02
// populates it from the `constraints:` frontmatter array; P05
// consumes it for the CLI-side scheduler (REQ-083).
Constraints []string
// Affinity is the affinity rule list for placement. P02 populates
// it from the `affinity:` frontmatter array; P05 consumes it.
Affinity []AffinityRule
// Lifecycle is the lifecycle hook block (pre_stop, post_start).
// P02 populates it from the `lifecycle:` frontmatter block; P04
// wires it into the systemd unit (ExecStop / ExecStartPost).
Lifecycle *LifecycleBlock
// Timeout is an optional execution timeout (duration string) for
// Job. Populated by P04.
Timeout string
// Tasks is the task-group list for multi-process services (P06,
// PRD §9.1). When non-empty, the alloc runs one systemd unit per
// task (`orca-v1-alloc-<alloc-id>-<task-name>.service`) all
// grouped under a single `<alloc-id>.target`. When nil/empty,
// the alloc is a single-process alloc driven by the top-level
// Runtime block (backward compat). Tasks that omit their own
// runtime inherit the top-level Runtime as the per-group default.
Tasks []TaskGroupTask
}
// TaskGroupTask is a single task within a task group (P06, PRD §9.1).
// Each task has its own runtime (a wasm task + a process sidecar is
// allowed), its own command, and an optional env overlay. When
// Runtime is nil, the task inherits the top-level
// WorkloadSpec.Runtime (the per-group default).
type TaskGroupTask struct {
Name string
Runtime *RuntimeBlock
Env map[string]string
Command 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
}
// RestartBlock is the restart policy block. Mode is one of never,
// on-failure, service (REQ-074 schema validators). P02 populates it from
// the frontmatter `restart:` block.
type RestartBlock struct {
Mode string
MaxRetries int
Delay string
}
// ScheduleBlock is the scheduling block. For Job, Cron is an optional
// cron expression. For DaemonSet, Mode is one of every-node, matching,
// mandatory (REQ-074). Populated by P05 and the DaemonSet phase.
type ScheduleBlock struct {
Mode string
Cron string
}
// UpdateBlock is the rolling/canary update stanza. Required for Service.
// P02 populates it from the frontmatter `update:` block; the
// rolling/canary/blue-green semantics land in P03.
type UpdateBlock struct {
Strategy string
MaxSurge int
MaxParallel int
MinHealthyTime string
HealthyDeadline string
Canary string
AutoPromote bool
}
// ServiceBlock is the Traefik route definition. For Service it is
// implied (Traefik route YES); Job and DaemonSet do not carry one by
// default (D-175). P02 populates it from the frontmatter `service:`
// block.
type ServiceBlock struct {
Host string
RouteID string
Name string
Port int
Bind string
}
// HealthBlock is the health-check block. P02 populates it from the
// frontmatter `health:` block (R-012). The Traefik emitter (REQ-077)
// renders it as the service's health-check stanza; ServiceValidator
// requires it for Traefik routing.
type HealthBlock struct {
CheckType string
Interval string
Timeout string
UnhealthyThreshold int
}
// AffinityRule is a single affinity entry: target CEL expression +
// integer weight. P02 populates it from the `affinity:` frontmatter
// array; P05 consumes it for the CLI-side scheduler (REQ-083).
type AffinityRule struct {
Target string
Weight int
}
// LifecycleBlock is the lifecycle hook block. PreStop and PostStart
// are command lists run before stop / after start. P02 populates it
// from the `lifecycle:` frontmatter block; P04 wires it into the
// systemd unit (ExecStop / ExecStartPost).
type LifecycleBlock struct {
PreStop []string
PostStart []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
secRestart
secUpdate
secService
secHealth
secLifecycle
secAffinity
secConstraints
secTasks
secTaskEnv
secTaskRuntime
)
cur := secNone
var curPort *PortSpec
var curVol *VolumeSpec
var curAffinity *AffinityRule
var lifecycleCur string
var curTask *TaskGroupTask
var taskIndent int
var taskFieldIndent int
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
}
}
flushAffinity := func() {
if curAffinity != nil {
spec.Affinity = append(spec.Affinity, *curAffinity)
curAffinity = nil
}
}
flushTask := func() {
if curTask != nil {
spec.Tasks = append(spec.Tasks, *curTask)
curTask = nil
}
}
// taskSubBlock returns the sub-section to switch to when the
// given `key: value` line opens a nested block under a task
// (`env:` → secTaskEnv, `runtime:` → secTaskRuntime). Returns
// secTasks for non-block keys (no switch).
taskSubBlock := func(kvLine string) section {
key, _, ok := splitKV(kvLine)
if !ok {
return secTasks
}
switch key {
case "env":
return secTaskEnv
case "runtime":
return secTaskRuntime
}
return secTasks
}
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()
flushAffinity()
flushTask()
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
case "restart":
spec.Restart = &RestartBlock{}
cur = secRestart
case "update":
spec.Update = &UpdateBlock{}
cur = secUpdate
case "service":
spec.Service = &ServiceBlock{}
cur = secService
case "health":
spec.Health = &HealthBlock{}
cur = secHealth
case "lifecycle":
spec.Lifecycle = &LifecycleBlock{}
cur = secLifecycle
case "constraints":
if strings.TrimSpace(val) != "" {
arr, err := parseStringArray(val)
if err != nil {
return nil, fmt.Errorf("parse markdown: line %d: constraints: %w", lineNo+1, err)
}
spec.Constraints = append(spec.Constraints, arr...)
cur = secNone
} else {
cur = secConstraints
}
case "affinity":
if strings.TrimSpace(val) != "" {
// Inline form not supported for affinity objects;
// require the block form. Ignore inline values.
cur = secNone
} else {
cur = secAffinity
}
case "tasks":
cur = secTasks
taskIndent = -1
taskFieldIndent = -1
default:
// Unknown top-level key 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)
}
case secRestart:
if spec.Restart == nil {
spec.Restart = &RestartBlock{}
}
key, val, ok := splitKV(trimmed)
if !ok {
continue
}
switch key {
case "mode":
spec.Restart.Mode = unquote(val)
case "attempts", "max_retries":
if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
spec.Restart.MaxRetries = n
}
case "delay":
spec.Restart.Delay = unquote(val)
}
case secUpdate:
if spec.Update == nil {
spec.Update = &UpdateBlock{}
}
key, val, ok := splitKV(trimmed)
if !ok {
continue
}
switch key {
case "strategy":
spec.Update.Strategy = unquote(val)
case "max_parallel":
if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
spec.Update.MaxParallel = n
}
case "max_surge":
if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
spec.Update.MaxSurge = n
}
case "min_healthy_time":
spec.Update.MinHealthyTime = unquote(val)
case "healthy_deadline":
spec.Update.HealthyDeadline = unquote(val)
case "canary":
spec.Update.Canary = unquote(val)
case "auto_promote":
spec.Update.AutoPromote = parseBool(val)
}
case secService:
if spec.Service == nil {
spec.Service = &ServiceBlock{}
}
key, val, ok := splitKV(trimmed)
if !ok {
continue
}
switch key {
case "name":
spec.Service.Name = unquote(val)
case "port":
if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
spec.Service.Port = n
}
case "bind":
spec.Service.Bind = unquote(val)
case "host":
spec.Service.Host = unquote(val)
case "route_id":
spec.Service.RouteID = unquote(val)
}
case secHealth:
if spec.Health == nil {
spec.Health = &HealthBlock{}
}
key, val, ok := splitKV(trimmed)
if !ok {
continue
}
switch key {
case "check_type":
spec.Health.CheckType = unquote(val)
case "interval":
spec.Health.Interval = unquote(val)
case "timeout":
spec.Health.Timeout = unquote(val)
case "unhealthy_threshold":
if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
spec.Health.UnhealthyThreshold = n
}
}
case secLifecycle:
if spec.Lifecycle == nil {
spec.Lifecycle = &LifecycleBlock{}
}
// pre_stop / post_start are string arrays. The block form
// is:
// lifecycle:
// pre_stop:
// - cmd1
// - cmd2
// post_start:
// - cmd3
// We track which sub-list we are appending to via a local
// cursor that is reset on every top-level section change.
key, val, ok := splitKV(trimmed)
if !ok {
// Could be a list item under pre_stop/post_start.
if strings.HasPrefix(trimmed, "- ") || trimmed == "-" {
item := strings.TrimSpace(strings.TrimPrefix(trimmed, "-"))
if item != "" && lifecycleCur != "" {
appendLifecycleCmd(spec.Lifecycle, lifecycleCur, unquote(item))
}
}
continue
}
switch key {
case "pre_stop", "post_start":
lifecycleCur = key
if strings.TrimSpace(val) != "" {
// Inline list form: `pre_stop: [cmd1, cmd2]`.
arr, err := parseStringArray(val)
if err == nil {
for _, s := range arr {
appendLifecycleCmd(spec.Lifecycle, key, s)
}
}
lifecycleCur = ""
}
default:
lifecycleCur = ""
}
case secAffinity:
if strings.HasPrefix(trimmed, "- ") || trimmed == "-" {
flushAffinity()
r := AffinityRule{}
curAffinity = &r
rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "-"))
if rest != "" {
applyAffinityKV(curAffinity, rest)
}
} else if curAffinity != nil {
applyAffinityKV(curAffinity, trimmed)
}
case secConstraints:
if strings.HasPrefix(trimmed, "- ") || trimmed == "-" {
item := strings.TrimSpace(strings.TrimPrefix(trimmed, "-"))
if item != "" {
spec.Constraints = append(spec.Constraints, unquote(item))
}
}
case secTasks:
// Tasks is a list of task objects. A `- ` at the list
// indent opens a new task; deeper-indented lines belong
// to the current task's fields (name, command) or
// nested sub-blocks (runtime, env).
if strings.HasPrefix(trimmed, "- ") || trimmed == "-" {
if taskIndent < 0 {
taskIndent = indent
taskFieldIndent = indent + 2
}
if indent == taskIndent {
flushTask()
t := TaskGroupTask{}
curTask = &t
rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "-"))
if rest != "" {
if applyTaskKV(curTask, rest) {
cur = taskSubBlock(rest)
}
}
continue
}
}
if curTask != nil {
if applyTaskKV(curTask, trimmed) {
cur = taskSubBlock(trimmed)
}
}
case secTaskEnv:
if curTask == nil {
cur = secTasks
continue
}
// Pop back to the task field level when the indent
// returns to taskFieldIndent (the next sibling
// field or a new `- ` list item). The line is then
// reprocessed as a task field.
if taskFieldIndent > 0 && indent <= taskFieldIndent {
cur = secTasks
if indent == taskIndent && (strings.HasPrefix(trimmed, "- ") || trimmed == "-") {
flushTask()
t := TaskGroupTask{}
curTask = &t
rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "-"))
if rest != "" {
if applyTaskKV(curTask, rest) {
cur = taskSubBlock(rest)
}
}
continue
}
if applyTaskKV(curTask, trimmed) {
cur = taskSubBlock(trimmed)
}
continue
}
if curTask.Env == nil {
curTask.Env = map[string]string{}
}
key, val, ok := splitKV(trimmed)
if !ok {
continue
}
if val == "" {
curTask.Env[key] = ""
} else if strings.HasPrefix(val, "{") && strings.HasSuffix(val, "}") {
curTask.Env[key] = val
} else {
curTask.Env[key] = unquote(val)
}
case secTaskRuntime:
if curTask == nil || curTask.Runtime == nil {
cur = secTasks
continue
}
// Pop back to the task field level (see secTaskEnv).
if taskFieldIndent > 0 && indent <= taskFieldIndent {
cur = secTasks
if indent == taskIndent && (strings.HasPrefix(trimmed, "- ") || trimmed == "-") {
flushTask()
t := TaskGroupTask{}
curTask = &t
rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "-"))
if rest != "" {
if applyTaskKV(curTask, rest) {
cur = taskSubBlock(rest)
}
}
continue
}
if applyTaskKV(curTask, trimmed) {
cur = taskSubBlock(trimmed)
}
continue
}
key, val, ok := splitKV(trimmed)
if !ok {
continue
}
switch key {
case "one_of":
curTask.Runtime.OneOf = unquote(val)
case "image":
curTask.Runtime.Image = unquote(val)
case "command":
curTask.Runtime.Command = unquote(val)
}
}
}
flushPort()
flushVol()
flushAffinity()
flushTask()
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
}
}
}
// applyAffinityKV applies a `key: value` pair to an AffinityRule entry.
func applyAffinityKV(r *AffinityRule, s string) {
key, val, ok := splitKV(s)
if !ok {
return
}
switch key {
case "target":
r.Target = unquote(val)
case "weight":
if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
r.Weight = n
}
}
}
// applyTaskKV applies a `key: value` pair to the current TaskGroupTask.
// The returned bool reports whether the key opened a nested sub-block
// (`env` or `runtime`); when true the caller switches the parser
// section to the corresponding sub-block handler.
func applyTaskKV(t *TaskGroupTask, s string) (openedSubBlock bool) {
key, val, ok := splitKV(s)
if !ok {
return false
}
switch key {
case "name":
t.Name = unquote(val)
case "command":
t.Command = unquote(val)
case "env":
if t.Env == nil {
t.Env = map[string]string{}
}
return true
case "runtime":
if t.Runtime == nil {
t.Runtime = &RuntimeBlock{}
}
return true
}
return false
}
// appendLifecycleCmd appends a command to the named lifecycle hook list
// (pre_stop or post_start) on the given LifecycleBlock.
func appendLifecycleCmd(lb *LifecycleBlock, name, cmd string) {
if lb == nil || cmd == "" {
return
}
switch name {
case "pre_stop":
lb.PreStop = append(lb.PreStop, cmd)
case "post_start":
lb.PostStart = append(lb.PostStart, cmd)
}
}
// parseBool parses a YAML-ish boolean value (true/yes/on/1 → true). The
// comparison is case-insensitive. Empty and unrecognized values return
// false (forward-compatible with future strict-mode validation).
func parseBool(s string) bool {
switch strings.ToLower(strings.TrimSpace(unquote(s))) {
case "true", "yes", "on", "1":
return true
}
return false
}
// 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
}