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--.service`) all // grouped under a single `.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 }