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---
284 lines
7.0 KiB
Go
284 lines
7.0 KiB
Go
package jobspec
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestParseValid(t *testing.T) {
|
|
hcl := `
|
|
job "demo" {
|
|
}
|
|
|
|
task "build" {
|
|
command = "/bin/echo"
|
|
args = ["hello", "world"]
|
|
}
|
|
`
|
|
spec, err := Parse([]byte(hcl), "test.hcl")
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if spec.Job.Name != "demo" {
|
|
t.Errorf("expected job name 'demo', got %q", spec.Job.Name)
|
|
}
|
|
if len(spec.Tasks) != 1 {
|
|
t.Fatalf("expected 1 task, got %d", len(spec.Tasks))
|
|
}
|
|
if spec.Tasks[0].Command != "/bin/echo" {
|
|
t.Errorf("expected command '/bin/echo', got %q", spec.Tasks[0].Command)
|
|
}
|
|
if len(spec.Tasks[0].Args) != 2 {
|
|
t.Errorf("expected 2 args, got %d", len(spec.Tasks[0].Args))
|
|
}
|
|
}
|
|
|
|
func TestParseMissingJob(t *testing.T) {
|
|
hcl := `task "x" { command = "/bin/echo" }`
|
|
_, err := Parse([]byte(hcl), "test.hcl")
|
|
if err == nil {
|
|
t.Fatal("expected error for missing job name")
|
|
}
|
|
}
|
|
|
|
func TestParseNoTasks(t *testing.T) {
|
|
hcl := `job "empty" {}`
|
|
_, err := Parse([]byte(hcl), "test.hcl")
|
|
if err == nil {
|
|
t.Fatal("expected error for no tasks")
|
|
}
|
|
}
|
|
|
|
func TestParseTaskMissingCommand(t *testing.T) {
|
|
hcl := `
|
|
job "x" {}
|
|
task "no-cmd" {}
|
|
`
|
|
_, err := Parse([]byte(hcl), "test.hcl")
|
|
if err == nil {
|
|
t.Fatal("expected error for missing command")
|
|
}
|
|
}
|
|
|
|
func TestParse_GoldenFiles(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
file string
|
|
wantJob string
|
|
wantJobType string
|
|
wantTasks int
|
|
checkTask func(t *testing.T, s *Spec)
|
|
}{
|
|
{
|
|
name: "single_task",
|
|
file: "valid_single_task.hcl",
|
|
wantJob: "single",
|
|
wantTasks: 1,
|
|
wantJobType: "",
|
|
checkTask: func(t *testing.T, s *Spec) {
|
|
if s.Tasks[0].Name != "solo" {
|
|
t.Errorf("task name = %q, want solo", s.Tasks[0].Name)
|
|
}
|
|
if s.Tasks[0].Command != "/bin/true" {
|
|
t.Errorf("command = %q, want /bin/true", s.Tasks[0].Command)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "multi_task",
|
|
file: "valid_multi_task.hcl",
|
|
wantJob: "multi",
|
|
wantJobType: "batch",
|
|
wantTasks: 3,
|
|
checkTask: func(t *testing.T, s *Spec) {
|
|
byName := map[string]TaskSpec{}
|
|
for _, tk := range s.Tasks {
|
|
byName[tk.Name] = tk
|
|
}
|
|
if _, ok := byName["build"]; !ok {
|
|
t.Errorf("missing task 'build'")
|
|
}
|
|
if _, ok := byName["test"]; !ok {
|
|
t.Errorf("missing task 'test'")
|
|
}
|
|
if len(byName["test"].Env) != 2 {
|
|
t.Errorf("test env count = %d, want 2", len(byName["test"].Env))
|
|
}
|
|
if _, ok := byName["deploy"]; !ok {
|
|
t.Errorf("missing task 'deploy'")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "env_vars",
|
|
file: "valid_env_vars.hcl",
|
|
wantJob: "envvars",
|
|
wantTasks: 1,
|
|
checkTask: func(t *testing.T, s *Spec) {
|
|
if len(s.Tasks[0].Env) != 3 {
|
|
t.Errorf("env count = %d, want 3", len(s.Tasks[0].Env))
|
|
}
|
|
want := "FOO=bar"
|
|
if s.Tasks[0].Env[0] != want {
|
|
t.Errorf("env[0] = %q, want %q", s.Tasks[0].Env[0], want)
|
|
}
|
|
},
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
path := filepath.Join("testdata", tc.file)
|
|
spec, err := ParseHCLFile(path)
|
|
if err != nil {
|
|
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)
|
|
}
|
|
if tc.wantJobType != "" && spec.Job.Type != tc.wantJobType {
|
|
t.Errorf("job type = %q, want %q", spec.Job.Type, tc.wantJobType)
|
|
}
|
|
if len(spec.Tasks) != tc.wantTasks {
|
|
t.Fatalf("tasks = %d, want %d", len(spec.Tasks), tc.wantTasks)
|
|
}
|
|
if tc.checkTask != nil {
|
|
tc.checkTask(t, spec)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParse_ErrorPaths(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
file string
|
|
wantErr string
|
|
useParse bool
|
|
hcl string
|
|
}{
|
|
{name: "no_tasks", file: "err_no_tasks.hcl", wantErr: "at least one task"},
|
|
{name: "missing_command", file: "err_missing_command.hcl", wantErr: "required"},
|
|
{name: "malformed", file: "err_malformed.hcl", wantErr: "decode hcl"},
|
|
{name: "missing_job", file: "err_missing_job.hcl", wantErr: "Missing job block"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
path := filepath.Join("testdata", tc.file)
|
|
_, err := ParseFile(path)
|
|
if err == nil {
|
|
t.Fatalf("expected error containing %q, got nil", tc.wantErr)
|
|
}
|
|
if !strings.Contains(err.Error(), tc.wantErr) {
|
|
t.Errorf("error = %q, want it to contain %q", err.Error(), tc.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParse_EmptyFile(t *testing.T) {
|
|
_, err := Parse([]byte(""), "empty.hcl")
|
|
if err == nil {
|
|
t.Fatal("expected error for empty file")
|
|
}
|
|
}
|
|
|
|
func TestParse_MalformedHCL(t *testing.T) {
|
|
_, err := Parse([]byte("job = "), "bad.hcl")
|
|
if err == nil {
|
|
t.Fatal("expected error for malformed HCL")
|
|
}
|
|
if !strings.Contains(err.Error(), "decode hcl") {
|
|
t.Errorf("error = %q, want it to contain 'decode hcl'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestParseFile_Nonexistent(t *testing.T) {
|
|
_, err := ParseFile(filepath.Join("testdata", "does_not_exist.hcl"))
|
|
if err == nil {
|
|
t.Fatal("expected error for nonexistent file")
|
|
}
|
|
if !strings.Contains(err.Error(), "read spec file") {
|
|
t.Errorf("error = %q, want it to contain 'read spec file'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestParseFile_ReadError(t *testing.T) {
|
|
// Directory exists but is not readable as a file.
|
|
_, err := ParseFile("testdata")
|
|
if err == nil {
|
|
t.Fatal("expected error when ParseFile target is a directory")
|
|
}
|
|
}
|
|
|
|
func TestSpec_Validate(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
spec *Spec
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "empty_job_name",
|
|
spec: &Spec{Job: JobSpec{Name: " "}, Tasks: []TaskSpec{{Name: "t", Command: "/bin/echo"}}},
|
|
wantErr: "job name is required",
|
|
},
|
|
{
|
|
name: "no_tasks",
|
|
spec: &Spec{Job: JobSpec{Name: "x"}},
|
|
wantErr: "at least one task is required",
|
|
},
|
|
{
|
|
name: "valid",
|
|
spec: &Spec{Job: JobSpec{Name: "x"}, Tasks: []TaskSpec{{Name: "t", Command: "/bin/echo"}}},
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
err := tc.spec.Validate()
|
|
if tc.wantErr == "" {
|
|
if err != nil {
|
|
t.Errorf("Validate: got %v, want nil", err)
|
|
}
|
|
return
|
|
}
|
|
if err == nil {
|
|
t.Fatalf("expected error containing %q, got nil", tc.wantErr)
|
|
}
|
|
if !strings.Contains(err.Error(), tc.wantErr) {
|
|
t.Errorf("error = %q, want it to contain %q", err.Error(), tc.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSpec_Validate_RoundTripFromParse(t *testing.T) {
|
|
path := filepath.Join("testdata", "valid_single_task.hcl")
|
|
spec, err := ParseHCLFile(path)
|
|
if err != nil {
|
|
t.Fatalf("ParseHCLFile: %v", err)
|
|
}
|
|
if err := spec.Validate(); err != nil {
|
|
t.Errorf("Validate on parsed spec: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestParseFile_GoldenFilesExist(t *testing.T) {
|
|
// Guard against accidentally removing testdata fixtures.
|
|
files := []string{
|
|
"valid_single_task.hcl",
|
|
"valid_multi_task.hcl",
|
|
"valid_env_vars.hcl",
|
|
"err_no_tasks.hcl",
|
|
"err_missing_command.hcl",
|
|
"err_malformed.hcl",
|
|
"err_missing_job.hcl",
|
|
}
|
|
for _, f := range files {
|
|
path := filepath.Join("testdata", f)
|
|
if _, err := os.Stat(path); err != nil {
|
|
t.Errorf("missing testdata fixture %s: %v", f, err)
|
|
}
|
|
}
|
|
}
|