diff --git a/internal/cli/job.go b/internal/cli/job.go index 86ce40d..ccb0e3c 100644 --- a/internal/cli/job.go +++ b/internal/cli/job.go @@ -74,7 +74,7 @@ var jobRunCmd = &cobra.Command{ peers := engine.NewPeerRegistry() dispatcher := engine.NewDispatcher(newLogger(), store.NewCapacityRepo(db), peers, exec) specBytes, _ := json.Marshal(map[string]any{ - "name": spec.Job.Name, + "name": spec.Name, "command": "/bin/true", // placeholder; full HCL dispatch lands in a later phase }) jobID, nodeID, err := dispatcher.Submit(ctx, runTarget, specBytes, runIDKey) @@ -93,11 +93,11 @@ var jobRunCmd = &cobra.Command{ job := &model.Job{ ID: uuid.NewString(), - Name: spec.Job.Name, + Name: spec.Name, Spec: args[0], Status: model.JobStatusPending, } - if err := exec.Run(ctx, job, toTaskSpecs(spec.Tasks)); err != nil { + if err := exec.Run(ctx, job, workloadToTaskSpecs(spec)); err != nil { if jsonOutput { _ = printJSON(map[string]any{"id": job.ID, "status": "failed", "error": err.Error()}) return err @@ -330,3 +330,22 @@ func toTaskSpecs(in []jobspec.TaskSpec) []engine.TaskSpec { } return out } + +// workloadToTaskSpecs converts a *WorkloadSpec into the engine.TaskSpec +// slice consumed by the executor. For the HCL adapter path the runtime +// block carries the legacy task[0].Command; for the Markdown path the +// runtime block is the canonical runtime abstraction (P07 will expand +// this). When Runtime is nil we emit a single no-op task to preserve +// the legacy "at least one task" invariant. +func workloadToTaskSpecs(spec *jobspec.WorkloadSpec) []engine.TaskSpec { + if spec == nil { + return nil + } + if spec.Runtime == nil { + return []engine.TaskSpec{{Name: spec.Name, Command: "/bin/true"}} + } + return []engine.TaskSpec{{ + Name: spec.Name, + Command: spec.Runtime.Command, + }} +} diff --git a/internal/jobspec/dispatch.go b/internal/jobspec/dispatch.go new file mode 100644 index 0000000..e025b9c --- /dev/null +++ b/internal/jobspec/dispatch.go @@ -0,0 +1,147 @@ +package jobspec + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// ParseFile reads a jobspec file from disk and dispatches on file +// extension (R-013, REQ-064): +// +// - .md → ParseMarkdown (canonical Markdown+frontmatter, R-014/R-015) +// - .yaml/.yml → ParseMarkdown with the whole file treated as +// frontmatter and Body = "" (pure YAML, no Markdown body) +// - .hcl → ParseHCL (legacy adapter; wraps the existing HCL parser +// and converts Spec{Job, Tasks} into *WorkloadSpec with Kind="Job", +// REQ-090 migration window) +// +// Unknown extensions return an error. The dispatcher preserves +// `orca job run old-spec.hcl` during the v0.9→v0.10 migration window +// (REQ-090). +func ParseFile(path string) (*WorkloadSpec, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read spec file: %w", err) + } + return Dispatch(data, filepath.Base(path)) +} + +// ParseHCLFile reads an HCL file and parses it via the legacy HCL parser, +// returning the legacy *Spec. It is a convenience wrapper retained for +// tests and direct HCL consumers that need the raw Spec{Job, Tasks} +// shape during the v0.9→v0.10 migration window (REQ-090). +// +// Deprecated: use ParseFile (dispatcher) for new code. HCL is legacy per +// R-013. +func ParseHCLFile(path string) (*Spec, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read spec file: %w", err) + } + return ParseHCLLegacy(data, filepath.Base(path)) +} + +// Dispatch routes raw jobspec bytes on file extension to the +// appropriate parser. filename is used only for HCL (the HCL decoder +// needs a filename for error messages and syntax sniffing). +func Dispatch(data []byte, filename string) (*WorkloadSpec, error) { + ext := strings.ToLower(filepath.Ext(filename)) + switch ext { + case ".md": + return ParseMarkdown(data) + case ".yaml", ".yml": + // Pure YAML file: no Markdown body. Treat the whole file as + // the frontmatter block. Body is empty (R-015: no body to + // preserve). + spec, err := parseYAMLFile(data) + if err != nil { + return nil, err + } + return spec, nil + case ".hcl": + return ParseHCL(data, filename) + default: + return nil, fmt.Errorf("parse jobspec: unknown extension %q (want .md, .yaml, .yml, or .hcl)", ext) + } +} + +// parseYAMLFile treats the whole file as a frontmatter block (no +// surrounding `---` delimiters, no Markdown body). This routes .yaml +// and .yml files through the same hand-rolled parser as .md. +func parseYAMLFile(data []byte) (*WorkloadSpec, error) { + block := string(data) + if strings.TrimSpace(block) == "" { + return nil, fmt.Errorf("parse yaml: empty file") + } + spec, err := parseFrontmatterBlock(block) + if err != nil { + return nil, err + } + spec.Body = "" + if err := validateWorkload(spec); err != nil { + return nil, err + } + return spec, nil +} + +// ParseHCL parses a legacy HCL jobspec and adapts it into a *WorkloadSpec +// (REQ-064 adapter, REQ-090 migration window). The existing HCL +// Spec{Job, Tasks} shape is converted to: +// +// Kind: "Job" +// Name: spec.Job.Name +// Runtime: {one_of: "process", command: tasks[0].Command} +// +// Body is empty (HCL has no Markdown body). The legacy Spec struct and +// ParseHCLLegacy are retained for direct HCL consumers that have not yet +// migrated. +// +// Deprecated: use the dispatcher (ParseFile/Dispatch). HCL is legacy +// per R-013; the HCL path is retained only for the v0.9→v0.10 migration +// window (REQ-090) and will be removed in v1.0. +func ParseHCL(data []byte, filename string) (*WorkloadSpec, error) { + spec, err := ParseHCLLegacy(data, filename) + if err != nil { + return nil, err + } + ws := &WorkloadSpec{ + SpecVersion: "", + Kind: "Job", + Name: spec.Job.Name, + Count: 1, + Body: "", + } + if len(spec.Tasks) > 0 { + ws.Runtime = &RuntimeBlock{ + OneOf: "process", + Command: spec.Tasks[0].Command, + } + } + return ws, nil +} + +// ParseHCLLegacy is the original HCL-only parser retained for direct +// HCL consumers (e.g. the cli/job.go toTaskSpecs path during the +// migration window). New code should call ParseHCL (which returns a +// *WorkloadSpec) or the dispatcher. Deprecated: HCL is legacy per +// R-013; see ParseHCL. +func ParseHCLLegacy(data []byte, filename string) (*Spec, error) { + var spec Spec + if err := hclDecode(filename, data, &spec); err != nil { + return nil, fmt.Errorf("decode hcl: %w", err) + } + if spec.Job.Name == "" { + return nil, fmt.Errorf("spec missing job name") + } + if len(spec.Tasks) == 0 { + return nil, fmt.Errorf("spec must have at least one task") + } + for i, t := range spec.Tasks { + if t.Command == "" { + return nil, fmt.Errorf("task[%d] (%s) missing command", i, t.Name) + } + } + return &spec, nil +} diff --git a/internal/jobspec/dispatch_test.go b/internal/jobspec/dispatch_test.go new file mode 100644 index 0000000..c7da74d --- /dev/null +++ b/internal/jobspec/dispatch_test.go @@ -0,0 +1,222 @@ +package jobspec + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDispatch_Markdown(t *testing.T) { + input := "---\nkind: Job\nname: md-job\n---\nbody content\n" + ws, err := Dispatch([]byte(input), "spec.md") + if err != nil { + t.Fatalf("Dispatch .md: %v", err) + } + if ws.Kind != "Job" { + t.Errorf("Kind = %q, want Job", ws.Kind) + } + if ws.Name != "md-job" { + t.Errorf("Name = %q, want md-job", ws.Name) + } + if ws.Body != "body content\n" { + t.Errorf("Body = %q, want %q (R-015)", ws.Body, "body content\n") + } +} + +func TestDispatch_YAML(t *testing.T) { + input := "kind: Service\nname: yaml-svc\nports:\n - name: http\n port: 80\n" + ws, err := Dispatch([]byte(input), "spec.yaml") + if err != nil { + t.Fatalf("Dispatch .yaml: %v", err) + } + if ws.Kind != "Service" { + t.Errorf("Kind = %q, want Service", ws.Kind) + } + if ws.Name != "yaml-svc" { + t.Errorf("Name = %q, want yaml-svc", ws.Name) + } + if ws.Body != "" { + t.Errorf("Body = %q, want empty (YAML has no body)", ws.Body) + } + if len(ws.Ports) != 1 || ws.Ports[0].Name != "http" || ws.Ports[0].Port != 80 { + t.Errorf("Ports = %+v, want one http:80", ws.Ports) + } +} + +func TestDispatch_YML(t *testing.T) { + input := "kind: DaemonSet\nname: yml-ds\n" + ws, err := Dispatch([]byte(input), "spec.yml") + if err != nil { + t.Fatalf("Dispatch .yml: %v", err) + } + if ws.Kind != "DaemonSet" { + t.Errorf("Kind = %q, want DaemonSet", ws.Kind) + } + if ws.Body != "" { + t.Errorf("Body = %q, want empty", ws.Body) + } +} + +func TestDispatch_HCLAdapter(t *testing.T) { + hcl := `job "demo" {} +task "build" { + command = "/bin/echo" + args = ["hello"] +} +` + ws, err := Dispatch([]byte(hcl), "spec.hcl") + if err != nil { + t.Fatalf("Dispatch .hcl: %v", err) + } + if ws.Kind != "Job" { + t.Errorf("Kind = %q, want Job (adapter always sets Job)", ws.Kind) + } + if ws.Name != "demo" { + t.Errorf("Name = %q, want demo (from spec.Job.Name)", ws.Name) + } + if ws.Runtime == nil { + t.Fatal("Runtime is nil; adapter should populate from tasks[0]") + } + if ws.Runtime.OneOf != "process" { + t.Errorf("Runtime.OneOf = %q, want process", ws.Runtime.OneOf) + } + if ws.Runtime.Command != "/bin/echo" { + t.Errorf("Runtime.Command = %q, want /bin/echo (from tasks[0].Command)", ws.Runtime.Command) + } + if ws.Body != "" { + t.Errorf("Body = %q, want empty (HCL has no body)", ws.Body) + } +} + +func TestDispatch_HCLAdapterNoTasks(t *testing.T) { + hcl := `job "x" {}` + _, err := Dispatch([]byte(hcl), "spec.hcl") + if err == nil { + t.Fatal("expected error for HCL with no tasks") + } + if !strings.Contains(err.Error(), "at least one task") { + t.Errorf("error = %q, want it to contain 'at least one task'", err.Error()) + } +} + +func TestDispatch_UnknownExtension(t *testing.T) { + _, err := Dispatch([]byte("kind: Job\nname: x\n"), "spec.json") + if err == nil { + t.Fatal("expected error for unknown extension, got nil") + } + if !strings.Contains(err.Error(), "unknown extension") { + t.Errorf("error = %q, want it to contain 'unknown extension'", err.Error()) + } +} + +func TestDispatch_NoExtension(t *testing.T) { + _, err := Dispatch([]byte("kind: Job\nname: x\n"), "spec") + if err == nil { + t.Fatal("expected error for no extension, got nil") + } +} + +func TestDispatch_EmptyYAML(t *testing.T) { + _, err := Dispatch([]byte(""), "spec.yaml") + if err == nil { + t.Fatal("expected error for empty YAML, got nil") + } +} + +func TestParseFile_Markdown(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "spec.md") + content := "---\nkind: Job\nname: file-md\n---\nbody\n" + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + ws, err := ParseFile(path) + if err != nil { + t.Fatalf("ParseFile .md: %v", err) + } + if ws.Kind != "Job" || ws.Name != "file-md" { + t.Errorf("got Kind=%q Name=%q", ws.Kind, ws.Name) + } + if ws.Body != "body\n" { + t.Errorf("Body = %q, want %q", ws.Body, "body\n") + } +} + +func TestParseFile_YAML(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "spec.yaml") + content := "kind: Service\nname: file-yaml\n" + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + ws, err := ParseFile(path) + if err != nil { + t.Fatalf("ParseFile .yaml: %v", err) + } + if ws.Kind != "Service" || ws.Name != "file-yaml" { + t.Errorf("got Kind=%q Name=%q", ws.Kind, ws.Name) + } + if ws.Body != "" { + t.Errorf("Body = %q, want empty", ws.Body) + } +} + +func TestParseFile_HCL(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "spec.hcl") + content := `job "file-hcl" {} +task "t" { command = "/bin/true" } +` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + ws, err := ParseFile(path) + if err != nil { + t.Fatalf("ParseFile .hcl: %v", err) + } + if ws.Kind != "Job" || ws.Name != "file-hcl" { + t.Errorf("got Kind=%q Name=%q", ws.Kind, ws.Name) + } + if ws.Runtime == nil || ws.Runtime.Command != "/bin/true" { + t.Errorf("Runtime.Command = %v, want /bin/true", ws.Runtime) + } +} + +func TestParseFile_MissingFile(t *testing.T) { + _, err := ParseFile(filepath.Join(t.TempDir(), "nope.md")) + if err == nil { + t.Fatal("expected error for missing file, got nil") + } + if !strings.Contains(err.Error(), "read spec file") { + t.Errorf("error = %q, want it to contain 'read spec file'", err.Error()) + } +} + +func TestParseFile_UnknownExtension(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "spec.txt") + if err := os.WriteFile(path, []byte("kind: Job\nname: x\n"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + _, err := ParseFile(path) + if err == nil { + t.Fatal("expected error for unknown extension, got nil") + } + if !strings.Contains(err.Error(), "unknown extension") { + t.Errorf("error = %q, want 'unknown extension'", err.Error()) + } +} + +func TestParseHCL_LegacySpec(t *testing.T) { + hcl := `job "legacy" {} +task "t" { command = "/bin/echo" } +` + ws, err := ParseHCL([]byte(hcl), "spec.hcl") + if err != nil { + t.Fatalf("ParseHCL: %v", err) + } + if ws.Kind != "Job" || ws.Name != "legacy" { + t.Errorf("adapter got Kind=%q Name=%q", ws.Kind, ws.Name) + } +} diff --git a/internal/jobspec/markdown.go b/internal/jobspec/markdown.go new file mode 100644 index 0000000..e1f346e --- /dev/null +++ b/internal/jobspec/markdown.go @@ -0,0 +1,586 @@ +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 +} + +// 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 +} + +// 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 + ) + cur := secNone + var curPort *PortSpec + var curVol *VolumeSpec + + 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 + } + } + + 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() + 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 + default: + // Unknown top-level keys 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) + } + } + } + flushPort() + flushVol() + 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 + } + } +} + +// 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 +} diff --git a/internal/jobspec/markdown_fuzz_test.go b/internal/jobspec/markdown_fuzz_test.go new file mode 100644 index 0000000..71a991b --- /dev/null +++ b/internal/jobspec/markdown_fuzz_test.go @@ -0,0 +1,109 @@ +package jobspec + +import ( + "strings" + "testing" +) + +// FuzzParseMarkdownRoundTrip is the REQ-067 fuzz harness for R-015 +// byte-exact body preservation. It generates random frontmatter + body +// combinations, runs ParseMarkdown, and asserts that the parsed Body +// equals the original body byte-for-byte whenever parsing succeeds. +// When parsing fails (bad frontmatter), the iteration passes — the +// parser is allowed to reject malformed input. +// +// The seed corpus (added via f.Add) covers adversarial fixtures: CRLF +// body, BOM prefix, no frontmatter, only-closing-separator, body with +// `---` inside a code fence, trailing whitespace, empty body. The seed +// corpus runs as regular tests under `go test` (CI); random input runs +// only under `go test -fuzz=FuzzParseMarkdownRoundTrip` in a dedicated +// process. +func FuzzParseMarkdownRoundTrip(f *testing.F) { + // Seed 1: valid frontmatter + simple body. + f.Add([]byte("---\nkind: Job\nname: seed1\n---\n# body\n")) + + // Seed 2: CRLF body. + f.Add([]byte("---\r\nkind: Job\r\nname: seed2\r\n---\r\n# body\r\nCRLF\r\n")) + + // Seed 3: BOM prefix. + f.Add([]byte("\uFEFF---\nkind: Job\nname: seed3\n---\nbody\n")) + + // Seed 4: no frontmatter (just body) — should fail to parse. + f.Add([]byte("# just a body\nno frontmatter\n")) + + // Seed 5: frontmatter with only the closing `---` (no opening). + f.Add([]byte("body\n---\nmore body\n")) + + // Seed 6: body containing `---` in a code fence. + f.Add([]byte("---\nkind: Job\nname: seed6\n---\n```bash\necho '---'\n```\n")) + + // Seed 7: body with trailing whitespace. + f.Add([]byte("---\nkind: Job\nname: seed7\n---\nbody with trailing spaces \n")) + + // Seed 8: empty body. + f.Add([]byte("---\nkind: Job\nname: seed8\n---\n")) + + // Seed 9: empty frontmatter (should fail). + f.Add([]byte("---\n---\nbody\n")) + + // Seed 10: body with no trailing newline. + f.Add([]byte("---\nkind: Job\nname: seed10\n---\nno trailing newline")) + + f.Fuzz(func(t *testing.T, data []byte) { + // Reconstruct the body from the input so we can assert + // byte-exact round-trip. We do this by re-splitting the + // frontmatter using the same logic the parser uses, but only + // to extract the expected body. If the input has no valid + // frontmatter delimiter pair, ParseMarkdown will return an + // error and we pass the iteration. + expectedBody := extractExpectedBody(string(data)) + + spec, err := ParseMarkdown(data) + if err != nil { + // Parser rejected the input — acceptable for a fuzz + // iteration (the input may be malformed). Pass. + return + } + // R-015: body must be byte-exact. + if spec.Body != expectedBody { + t.Errorf("R-015 body round-trip mismatch:\n got = %q\nwant = %q", spec.Body, expectedBody) + } + }) +} + +// extractExpectedBody returns the body portion of a Markdown jobspec +// input using the same delimiter-splitting logic as splitFrontmatter, +// so the fuzz harness can assert byte-exact preservation independently +// of the parser's internal extraction. If the input has no valid +// frontmatter, the result is "" (and ParseMarkdown will error). +func extractExpectedBody(content string) string { + stripped := content + if strings.HasPrefix(stripped, "\uFEFF") { + stripped = stripped[len("\uFEFF"):] + } + trimmed := strings.TrimLeft(stripped, "\r\n\t ") + if !strings.HasPrefix(trimmed, "---") { + return "" + } + rest := trimmed[3:] + if len(rest) > 0 && rest[0] != '\n' && rest[0] != '\r' { + return "" + } + rest = strings.TrimLeft(rest, "\r\n") + idx := findClosingDelimiter(rest) + if idx < 0 { + return "" + } + afterClose := rest[idx:] + newlineIdx := strings.IndexAny(afterClose, "\r\n") + if newlineIdx < 0 { + return "" + } + bodyStart := newlineIdx + if strings.HasPrefix(afterClose[bodyStart:], "\r\n") { + bodyStart += 2 + } else { + bodyStart += 1 + } + return afterClose[bodyStart:] +} diff --git a/internal/jobspec/markdown_test.go b/internal/jobspec/markdown_test.go new file mode 100644 index 0000000..5d29ace --- /dev/null +++ b/internal/jobspec/markdown_test.go @@ -0,0 +1,357 @@ +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) + } +} diff --git a/internal/jobspec/spec.go b/internal/jobspec/spec.go index 4cfa80e..be0bb35 100644 --- a/internal/jobspec/spec.go +++ b/internal/jobspec/spec.go @@ -2,7 +2,6 @@ package jobspec import ( "fmt" - "os" "strings" "github.com/hashicorp/hcl/v2" @@ -10,16 +9,24 @@ import ( "github.com/hashicorp/hcl/v2/hclsimple" ) +// Spec is the legacy HCL-only jobspec shape. It is retained for the +// v0.9→v0.10 migration window (REQ-090) and is populated by ParseHCLLegacy. +// +// Deprecated: HCL is legacy per R-013; new code should consume the +// unified *WorkloadSpec returned by ParseFile/Dispatch (see +// dispatch.go and markdown.go). type Spec struct { Job JobSpec `hcl:"job,block"` Tasks []TaskSpec `hcl:"task,block"` } +// JobSpec is the legacy HCL job block. type JobSpec struct { Name string `hcl:"name,label"` Type string `hcl:"type,optional"` } +// TaskSpec is the legacy HCL task block. type TaskSpec struct { Name string `hcl:"name,label"` Command string `hcl:"command"` @@ -27,34 +34,14 @@ type TaskSpec struct { Env []string `hcl:"env,optional"` } -func ParseFile(path string) (*Spec, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read spec file: %w", err) - } - return Parse(data, path) -} - -func Parse(data []byte, filename string) (*Spec, error) { - var spec Spec - err := hclsimple.Decode(filename, data, nil, &spec) - if err != nil { - return nil, fmt.Errorf("decode hcl: %w", err) - } - if spec.Job.Name == "" { - return nil, fmt.Errorf("spec missing job name") - } - if len(spec.Tasks) == 0 { - return nil, fmt.Errorf("spec must have at least one task") - } - for i, t := range spec.Tasks { - if t.Command == "" { - return nil, fmt.Errorf("task[%d] (%s) missing command", i, t.Name) - } - } - return &spec, nil +// hclDecode wraps hclsimple.Decode for testability. +func hclDecode(filename string, data []byte, spec *Spec) error { + return hclsimple.Decode(filename, data, nil, spec) } +// Validate is the legacy HCL Spec validator retained for the migration +// window (REQ-090). New code should use validateWorkload on a +// *WorkloadSpec. func (s *Spec) Validate() error { if strings.TrimSpace(s.Job.Name) == "" { return fmt.Errorf("job name is required") @@ -65,5 +52,17 @@ func (s *Spec) Validate() error { return nil } +// Parse is the original HCL-only entry point retained for backward +// compatibility with direct HCL callers during the v0.9→v0.10 migration +// window (REQ-090). New code should call the dispatcher ParseFile (which +// returns *WorkloadSpec) or ParseHCL (which adapts HCL into +// *WorkloadSpec). +// +// Deprecated: use ParseFile (dispatcher) or ParseHCL (adapter). HCL is +// legacy per R-013. +func Parse(data []byte, filename string) (*Spec, error) { + return ParseHCLLegacy(data, filename) +} + var _ = hcl.Diagnostics{} var _ = gohcl.DecodeBody diff --git a/internal/jobspec/spec_test.go b/internal/jobspec/spec_test.go index d1f0a3e..9502997 100644 --- a/internal/jobspec/spec_test.go +++ b/internal/jobspec/spec_test.go @@ -130,9 +130,9 @@ func TestParse_GoldenFiles(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { path := filepath.Join("testdata", tc.file) - spec, err := ParseFile(path) + spec, err := ParseHCLFile(path) if err != nil { - t.Fatalf("ParseFile(%s): %v", tc.file, err) + t.Fatalf("ParseHCLFile(%s): %v", tc.file, err) } if spec.Job.Name != tc.wantJob { t.Errorf("job name = %q, want %q", spec.Job.Name, tc.wantJob) @@ -254,9 +254,9 @@ func TestSpec_Validate(t *testing.T) { func TestSpec_Validate_RoundTripFromParse(t *testing.T) { path := filepath.Join("testdata", "valid_single_task.hcl") - spec, err := ParseFile(path) + spec, err := ParseHCLFile(path) if err != nil { - t.Fatalf("ParseFile: %v", err) + t.Fatalf("ParseHCLFile: %v", err) } if err := spec.Validate(); err != nil { t.Errorf("Validate on parsed spec: %v", err)