667f20a7b3
P0b — Canonical Markdown+frontmatter jobspec parser (R-013/R-014).
Parser (internal/jobspec/markdown.go, REQ-064):
- WorkloadSpec/RuntimeBlock/PortSpec/VolumeSpec types. ParseMarkdown
hand-rolled YAML frontmatter (no yaml.v3 dep). Kind validation (Job/
Service/DaemonSet per R-012). BOM-stripped frontmatter, byte-exact body
preservation (R-015) via the fuzz harness.
Dispatcher (internal/jobspec/dispatch.go, REQ-064):
- ParseFile/Dispatch routes on extension: .md->Markdown, .yaml/.yml->
Markdown-with-empty-body, .hcl->ParseHCL adapter. HCL adapter converts
Spec{Job,Tasks} to *WorkloadSpec (Kind=Job, Runtime.one_of=process).
Backward compat preserved (REQ-090) — orca job run old-spec.hcl works.
- Legacy Parse renamed ParseHCLLegacy, marked // Deprecated per R-013.
Fuzz harness (internal/jobspec/markdown_fuzz_test.go, REQ-067, R-015):
- FuzzParseMarkdownRoundTrip with 10 seed corpus entries (CRLF, BOM,
no-frontmatter, only-closing-separator, code-fence ---, trailing
whitespace, empty body, etc). Asserts byte-exact body round-trip.
Tests: markdown_test.go (19 tests), dispatch_test.go (17 tests), fuzz
(10 seeds). jobspec package 89.2% coverage. cli 81.8% (no regression).
18 packages pass, 20 bats pass, gofmt clean, verify-reqs 90 consistent.
---ci---
project: orca
phase: P0b
milestone: v0.9
status: execute
---/ci---
148 lines
4.6 KiB
Go
148 lines
4.6 KiB
Go
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
|
|
}
|