From 4ad187ccf3fd83c71279cb71517e7279967701ee Mon Sep 17 00:00:00 2001 From: stackedsax Date: Sat, 18 Jul 2026 13:32:32 -0700 Subject: [PATCH 1/4] Generate a JSON Schema for SPLAT from the Go types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add internal/splat/schema, which reflects splat.Job into a JSON Schema (draft 2020-12) with the opaque scalar wrappers (Quantity, Duration) mapped to strings. The committed schema/splat.schema.json is regenerated with `make schema` (go test ./internal/splat/schema -update); a drift test runs under `go test ./...` so the schema can never silently fall out of sync with the types. Depends on github.com/invopop/jsonschema (kept out of the core splat package via the reflector's type Mapper). Note: validating the current examples/ against this schema fails because they use snake_case field names while the types/schema use camelCase — a pre-existing docs/impl mismatch tracked separately; the schema here reflects what the tool actually accepts. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016PnTz6Zxqa4jHocK8kCbyx --- Makefile | 6 +- README.md | 1 + go.mod | 5 + go.sum | 14 +- internal/splat/schema/schema.go | 60 ++ internal/splat/schema/schema_test.go | 76 +++ schema/splat.schema.json | 784 +++++++++++++++++++++++++++ 7 files changed, 943 insertions(+), 3 deletions(-) create mode 100644 internal/splat/schema/schema.go create mode 100644 internal/splat/schema/schema_test.go create mode 100644 schema/splat.schema.json diff --git a/Makefile b/Makefile index afe46c8..036ed5c 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test lint tidy vuln check install clean help validate-schemas corpus dryrun-k8s dryrun-slurm +.PHONY: build test lint tidy vuln check install clean help validate-schemas schema corpus dryrun-k8s dryrun-slurm BINARY := bammm CMD := ./cmd/bammm @@ -39,6 +39,10 @@ vuln: validate-schemas: ./scripts/validate-schemas.sh +## schema: regenerate schema/splat.schema.json from the Go types +schema: + go test ./internal/splat/schema -update + ## corpus: scrape a scheduler corpus from GitHub (usage: make corpus SCHED=slurm; SCHED=pairs hunts cross-scheduler pairs) corpus: uv run scripts/corpus/fetch_corpus.py $(SCHED) diff --git a/README.md b/README.md index 9f61f4d..f947dfd 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,7 @@ make test # go test -race ./... make lint # golangci-lint make check # lint + test make validate-schemas # kubeconform Tier 2 schema validation +make schema # regenerate schema/splat.schema.json from the Go types make dryrun-k8s # Tier 3: kubectl --dry-run=server (needs a cluster + operators) make dryrun-slurm # Tier 3: sbatch --test-only (needs slurmctld) ``` diff --git a/go.mod b/go.mod index 55a6a4f..7f8ce22 100644 --- a/go.mod +++ b/go.mod @@ -11,11 +11,14 @@ require ( ) require ( + github.com/invopop/jsonschema v0.14.0 k8s.io/api v0.31.3 k8s.io/apimachinery v0.31.3 ) require ( + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.2 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect @@ -24,8 +27,10 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pb33f/ordered-map/v2 v2.3.1 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect golang.org/x/net v0.26.0 // indirect golang.org/x/text v0.16.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/go.sum b/go.sum index 3bdd2ba..ba480e7 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= +github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -17,6 +21,8 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg= +github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -30,6 +36,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY= +github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -42,12 +50,14 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= +go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= diff --git a/internal/splat/schema/schema.go b/internal/splat/schema/schema.go new file mode 100644 index 0000000..e6870ab --- /dev/null +++ b/internal/splat/schema/schema.go @@ -0,0 +1,60 @@ +// Package schema generates the JSON Schema for a SPLAT Job directly from the Go +// types in internal/splat, so the published schema/splat.schema.json can never +// silently drift from the code. Regenerate with: +// +// go test ./internal/splat/schema -update +package schema + +import ( + "bytes" + "encoding/json" + "reflect" + + "github.com/invopop/jsonschema" + + "github.com/InsightSoftmax/BAMMM/internal/splat" +) + +// SchemaID is the canonical $id of the SPLAT JSON Schema. +const SchemaID = "https://bammm.io/schemas/splat/v1alpha1.json" + +// Generate reflects splat.Job into an indented JSON Schema document. +func Generate() ([]byte, error) { + r := &jsonschema.Reflector{ + // The opaque scalar wrappers marshal to strings; reflection alone would + // emit empty objects for them (they have only unexported fields). + Mapper: func(t reflect.Type) *jsonschema.Schema { + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + switch t { + case reflect.TypeOf(splat.Quantity{}): + return &jsonschema.Schema{ + Type: "string", + Description: `Resource quantity, e.g. "4Gi", "4G", "4096M", "4096" (bare = MB), or "500m" (CPU millicores).`, + } + case reflect.TypeOf(splat.Duration{}): + return &jsonschema.Schema{ + Type: "string", + Description: `Duration as ISO 8601 (e.g. PT2H30M), HH:MM:SS, or a plain number of seconds.`, + } + } + return nil + }, + } + + s := r.Reflect(&splat.Job{}) + s.ID = jsonschema.ID(SchemaID) + s.Title = "SPLAT Job" + s.Description = "Scheduler-Portable Language for Abstracting Tasks — the BAMMM interchange job spec " + + "(apiVersion bammm.io/v1alpha1). Generated from the Go types in internal/splat; see SPEC.md." + + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + if err := enc.Encode(s); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/internal/splat/schema/schema_test.go b/internal/splat/schema/schema_test.go new file mode 100644 index 0000000..bba186c --- /dev/null +++ b/internal/splat/schema/schema_test.go @@ -0,0 +1,76 @@ +package schema + +import ( + "bytes" + "encoding/json" + "flag" + "os" + "testing" +) + +var update = flag.Bool("update", false, "regenerate schema/splat.schema.json") + +// schemaPath is the committed schema, relative to this package directory. +const schemaPath = "../../../schema/splat.schema.json" + +// TestSchemaMatchesTypes regenerates the schema from the Go types and, unless +// -update is set, fails if the committed file is out of date — the CI drift gate. +func TestSchemaMatchesTypes(t *testing.T) { + got, err := Generate() + if err != nil { + t.Fatalf("Generate: %v", err) + } + + if *update { + if err := os.MkdirAll("../../../schema", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(schemaPath, got, 0o644); err != nil { + t.Fatal(err) + } + t.Logf("wrote %s", schemaPath) + return + } + + want, err := os.ReadFile(schemaPath) + if err != nil { + t.Fatalf("read %s (generate it with `go test ./internal/splat/schema -update`): %v", schemaPath, err) + } + if !bytes.Equal(got, want) { + t.Errorf("schema/splat.schema.json is stale — regenerate with `go test ./internal/splat/schema -update`") + } +} + +// TestSchemaShape sanity-checks that reflection produced a usable schema. +func TestSchemaShape(t *testing.T) { + data, err := Generate() + if err != nil { + t.Fatalf("Generate: %v", err) + } + var doc map[string]any + if err := json.Unmarshal(data, &doc); err != nil { + t.Fatalf("generated schema is not valid JSON: %v", err) + } + if doc["$id"] != SchemaID { + t.Errorf("$id: got %v want %q", doc["$id"], SchemaID) + } + // The Job type reflects to a $ref into $defs; ensure the definitions exist + // and carry the top-level SPLAT fields. + defs, ok := doc["$defs"].(map[string]any) + if !ok { + t.Fatal("no $defs in generated schema") + } + job, ok := defs["Job"].(map[string]any) + if !ok { + t.Fatal("no Job definition in $defs") + } + props, ok := job["properties"].(map[string]any) + if !ok { + t.Fatal("Job has no properties") + } + for _, field := range []string{"apiVersion", "kind", "metadata", "spec"} { + if _, ok := props[field]; !ok { + t.Errorf("Job schema missing property %q", field) + } + } +} diff --git a/schema/splat.schema.json b/schema/splat.schema.json new file mode 100644 index 0000000..db9b5e9 --- /dev/null +++ b/schema/splat.schema.json @@ -0,0 +1,784 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://bammm.io/schemas/splat/v1alpha1.json", + "$ref": "#/$defs/Job", + "$defs": { + "Array": { + "properties": { + "indices": { + "type": "string" + }, + "maxConcurrent": { + "type": "integer" + } + }, + "additionalProperties": false, + "type": "object" + }, + "ContainerExecution": { + "properties": { + "image": { + "type": "string" + }, + "imagePullPolicy": { + "type": "string" + }, + "imagePullSecrets": { + "items": { + "type": "string" + }, + "type": "array" + }, + "command": { + "items": { + "type": "string" + }, + "type": "array" + }, + "args": { + "items": { + "type": "string" + }, + "type": "array" + }, + "environment": { + "$ref": "#/$defs/Environment" + } + }, + "additionalProperties": false, + "type": "object" + }, + "Dependency": { + "properties": { + "scheme": { + "type": "string" + }, + "value": { + "type": "string" + }, + "count": { + "type": "integer" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "scheme" + ] + }, + "EmbeddedFile": { + "properties": { + "name": { + "type": "string" + }, + "content": { + "type": "string" + }, + "encoding": { + "type": "string" + }, + "permissions": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name", + "content" + ] + }, + "EnvFromConfigMap": { + "properties": { + "name": { + "type": "string" + }, + "configMapName": { + "type": "string" + }, + "configMapKey": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name", + "configMapName", + "configMapKey" + ] + }, + "EnvFromSecret": { + "properties": { + "name": { + "type": "string" + }, + "secretName": { + "type": "string" + }, + "secretKey": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name", + "secretName", + "secretKey" + ] + }, + "Environment": { + "properties": { + "vars": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "secrets": { + "items": { + "$ref": "#/$defs/EnvFromSecret" + }, + "type": "array" + }, + "configMaps": { + "items": { + "$ref": "#/$defs/EnvFromConfigMap" + }, + "type": "array" + }, + "inheritFromSubmitter": { + "type": "boolean" + } + }, + "additionalProperties": false, + "type": "object" + }, + "EventPolicy": { + "properties": { + "event": { + "type": "string" + }, + "events": { + "items": { + "type": "string" + }, + "type": "array" + }, + "action": { + "type": "string" + }, + "timeout": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object" + }, + "Execution": { + "properties": { + "container": { + "$ref": "#/$defs/ContainerExecution" + }, + "script": { + "type": "string" + }, + "executable": { + "type": "string" + }, + "arguments": { + "type": "string" + }, + "shell": { + "type": "string" + }, + "workingDir": { + "type": "string" + }, + "environment": { + "$ref": "#/$defs/Environment" + }, + "stdin": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object" + }, + "Extensions": { + "properties": { + "slurm": { + "type": "object" + }, + "pbs": { + "type": "object" + }, + "lsf": { + "type": "object" + }, + "htcondor": { + "type": "object" + }, + "flux": { + "type": "object" + }, + "armada": { + "type": "object" + }, + "volcano": { + "type": "object" + }, + "kueue": { + "type": "object" + }, + "yunikorn": { + "type": "object" + }, + "runai": { + "type": "object" + } + }, + "additionalProperties": false, + "type": "object" + }, + "FileStaging": { + "properties": { + "inputs": { + "items": { + "$ref": "#/$defs/FileTransfer" + }, + "type": "array" + }, + "outputs": { + "items": { + "$ref": "#/$defs/FileTransfer" + }, + "type": "array" + }, + "embeddedFiles": { + "items": { + "$ref": "#/$defs/EmbeddedFile" + }, + "type": "array" + }, + "transferPolicy": { + "type": "string" + }, + "checkpointFiles": { + "items": { + "type": "string" + }, + "type": "array" + }, + "checkpointExitCode": { + "type": "integer" + } + }, + "additionalProperties": false, + "type": "object" + }, + "FileTransfer": { + "properties": { + "src": { + "type": "string" + }, + "dst": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "src", + "dst" + ] + }, + "GPURequest": { + "properties": { + "count": { + "type": "number" + }, + "type": { + "type": "string" + }, + "memory": { + "type": "string" + }, + "fraction": { + "type": "number" + }, + "migProfile": { + "type": "string" + }, + "exclusive": { + "type": "boolean" + } + }, + "additionalProperties": false, + "type": "object" + }, + "Gang": { + "properties": { + "minAvailable": { + "type": "integer" + }, + "style": { + "type": "string" + }, + "timeout": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object" + }, + "Job": { + "properties": { + "apiVersion": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "metadata": { + "$ref": "#/$defs/Metadata" + }, + "spec": { + "$ref": "#/$defs/Spec" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "apiVersion", + "kind", + "metadata", + "spec" + ] + }, + "Lifecycle": { + "properties": { + "maxRetries": { + "type": "integer" + }, + "requeueOnFailure": { + "type": "boolean" + }, + "successExitCodes": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "ttlAfterFinished": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object" + }, + "Metadata": { + "properties": { + "name": { + "type": "string" + }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "annotations": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "clientId": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name" + ] + }, + "NFSVol": { + "properties": { + "server": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "server", + "path" + ] + }, + "Notifications": { + "properties": { + "email": { + "type": "string" + }, + "events": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "additionalProperties": false, + "type": "object" + }, + "Output": { + "properties": { + "stdout": { + "type": "string" + }, + "stderr": { + "type": "string" + }, + "mergeStderr": { + "type": "boolean" + }, + "openMode": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object" + }, + "Placement": { + "properties": { + "nodeSelector": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "tolerations": { + "items": true, + "type": "array" + }, + "affinity": true, + "topologySpread": { + "items": true, + "type": "array" + }, + "topology": { + "type": "string" + }, + "groupBy": { + "type": "string" + }, + "constraint": { + "type": "string" + }, + "prefer": { + "type": "string" + }, + "exclusive": { + "type": "boolean" + }, + "nodePools": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "additionalProperties": false, + "type": "object" + }, + "ResourceLimits": { + "properties": { + "cpusPerTask": { + "type": "integer" + }, + "memoryPerTask": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object" + }, + "Resources": { + "properties": { + "nodes": { + "type": "integer" + }, + "tasks": { + "type": "integer" + }, + "tasksPerNode": { + "type": "integer" + }, + "tasksPerSocket": { + "type": "integer" + }, + "tasksPerCore": { + "type": "integer" + }, + "cpusPerTask": { + "type": "integer" + }, + "memoryPerTask": { + "type": "string" + }, + "memoryPerCpu": { + "type": "string" + }, + "gpu": { + "$ref": "#/$defs/GPURequest" + }, + "diskPerTask": { + "type": "string" + }, + "genericResources": { + "additionalProperties": { + "type": "integer" + }, + "type": "object" + }, + "limits": { + "$ref": "#/$defs/ResourceLimits" + } + }, + "additionalProperties": false, + "type": "object" + }, + "Schedule": { + "properties": { + "queue": { + "type": "string" + }, + "partition": { + "type": "string" + }, + "priorityClass": { + "type": "string" + }, + "priority": { + "type": "integer" + }, + "account": { + "type": "string" + }, + "project": { + "type": "string" + }, + "bank": { + "type": "string" + }, + "qos": { + "type": "string" + }, + "walltime": { + "type": "string" + }, + "walltimeMin": { + "type": "string" + }, + "beginAfter": { + "type": "string", + "format": "date-time" + }, + "deadline": { + "type": "string", + "format": "date-time" + }, + "signalBeforeEnd": { + "type": "string" + }, + "hold": { + "type": "boolean" + }, + "reservation": { + "type": "string" + }, + "exclusive": { + "type": "boolean" + }, + "exclusiveMode": { + "type": "string" + }, + "oversubscribe": { + "type": "boolean" + } + }, + "additionalProperties": false, + "type": "object" + }, + "Spec": { + "properties": { + "schedule": { + "$ref": "#/$defs/Schedule" + }, + "resources": { + "$ref": "#/$defs/Resources" + }, + "execution": { + "$ref": "#/$defs/Execution" + }, + "tasks": { + "items": { + "$ref": "#/$defs/Task" + }, + "type": "array" + }, + "gang": { + "$ref": "#/$defs/Gang" + }, + "array": { + "$ref": "#/$defs/Array" + }, + "dependencies": { + "items": { + "$ref": "#/$defs/Dependency" + }, + "type": "array" + }, + "lifecycle": { + "$ref": "#/$defs/Lifecycle" + }, + "placement": { + "$ref": "#/$defs/Placement" + }, + "output": { + "$ref": "#/$defs/Output" + }, + "notifications": { + "$ref": "#/$defs/Notifications" + }, + "fileStaging": { + "$ref": "#/$defs/FileStaging" + }, + "volumes": { + "items": { + "$ref": "#/$defs/Volume" + }, + "type": "array" + }, + "workloadType": { + "type": "string" + }, + "distributedFramework": { + "type": "string" + }, + "extensions": { + "$ref": "#/$defs/Extensions" + } + }, + "additionalProperties": false, + "type": "object" + }, + "Task": { + "properties": { + "name": { + "type": "string" + }, + "replicas": { + "type": "integer" + }, + "minReplicas": { + "type": "integer" + }, + "resources": { + "$ref": "#/$defs/Resources" + }, + "execution": { + "$ref": "#/$defs/Execution" + }, + "lifecycle": { + "$ref": "#/$defs/TaskLifecycle" + }, + "placement": { + "$ref": "#/$defs/Placement" + }, + "dependsOn": { + "items": { + "$ref": "#/$defs/TaskDep" + }, + "type": "array" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name" + ] + }, + "TaskDep": { + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name" + ] + }, + "TaskLifecycle": { + "properties": { + "maxRetries": { + "type": "integer" + }, + "policies": { + "items": { + "$ref": "#/$defs/EventPolicy" + }, + "type": "array" + } + }, + "additionalProperties": false, + "type": "object" + }, + "Volume": { + "properties": { + "name": { + "type": "string" + }, + "mountPath": { + "type": "string" + }, + "readOnly": { + "type": "boolean" + }, + "pvc": { + "type": "string" + }, + "configMap": { + "type": "string" + }, + "secret": { + "type": "string" + }, + "hostPath": { + "type": "string" + }, + "emptyDir": { + "type": "boolean" + }, + "nfs": { + "$ref": "#/$defs/NFSVol" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name", + "mountPath" + ] + } + }, + "title": "SPLAT Job", + "description": "Scheduler-Portable Language for Abstracting Tasks — the BAMMM interchange job spec (apiVersion bammm.io/v1alpha1). Generated from the Go types in internal/splat; see SPEC.md." +} From 07fb42027edc7fb8bfd3c2f302a56412a1bbf16c Mon Sep 17 00:00:00 2001 From: stackedsax Date: Sat, 18 Jul 2026 14:04:02 -0700 Subject: [PATCH 2/4] Fix SPLAT field casing in docs and examples to camelCase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BAMMM parses SPLAT via sigs.k8s.io/yaml, which honors the json tags — so the tool only accepts camelCase field names (cpusPerTask, memoryPerTask, ...). But SPEC.md's field reference and every examples/*.yaml used snake_case, so those fields were silently dropped when actually parsed. Rewrite the first-class SPLAT fields to camelCase across SPEC.md and examples/ (scheduler-specific extensions.* keys are left as-is — they are freeform passthrough matching what each parser writes). Also drop the aspirational placement.mpi_mapping block from full-reference.yaml (not a real field). All examples now validate against schema/splat.schema.json and, more importantly, actually round-trip through `bammm convert` without dropping fields. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016PnTz6Zxqa4jHocK8kCbyx --- SPEC.md | 112 ++++++++++++------------ examples/array-job.yaml | 10 +-- examples/aws-batch-job.yaml | 6 +- examples/container-hpc-bridge.yaml | 24 ++--- examples/full-reference.yaml | 136 ++++++++++++++--------------- examples/hpc-mpi-job.yaml | 20 ++--- examples/ml-training-job.yaml | 38 ++++---- examples/multi-task-job.yaml | 20 ++--- examples/simple-job.yaml | 4 +- examples/slurm-job.yaml | 14 +-- 10 files changed, 190 insertions(+), 194 deletions(-) diff --git a/SPEC.md b/SPEC.md index bd45706..5b3de34 100644 --- a/SPEC.md +++ b/SPEC.md @@ -61,9 +61,9 @@ spec: placement: # node selection, topology, exclusivity output: # stdout/stderr paths notifications:# email alerts - file_staging: # data movement before/after job + fileStaging: # data movement before/after job volumes: # K8s PVC/ConfigMap/hostPath mounts - workload_type: # training | interactive | inference | batch + workloadType: # training | interactive | inference | batch extensions: # scheduler-specific passthrough blocks ``` @@ -82,7 +82,7 @@ metadata: key: value annotations: # opaque key-value annotations (not interpreted by BAMMM) key: value - client_id: string # idempotency key; safe to resubmit on network failure + clientId: string # idempotency key; safe to resubmit on network failure # Armada: client_id; others: deduplicated by BAMMM ``` @@ -105,7 +105,7 @@ spec: # Armada 0–1000. nice/fair-share style values (lower = higher) invert. # Slurm: --priority; PBS: -p; HTCondor: priority; LSF: -sp; Flux: --urgency # Armada/Volcano: priorityClassName is preferred; use priority_class - priority_class: string # K8s PriorityClass name (K8s-based schedulers only) + priorityClass: string # K8s PriorityClass name (K8s-based schedulers only) # Takes precedence over `priority` for K8s schedulers account: string # billing account / allocation # Slurm: --account; PBS: -A; LSF: -P; Flux: --bank @@ -124,16 +124,16 @@ For K8s schedulers, "task" maps to a container / pod. ```yaml spec: resources: - cpus_per_task: integer # CPUs per task + cpusPerTask: integer # CPUs per task # Slurm: --cpus-per-task; K8s: resources.requests.cpu # PBS: ncpus per chunk; LSF: -n / ptile-derived - memory_per_task: quantity # memory per task; accepts "4Gi", "4G", "4096M", "4096"(MB) + memoryPerTask: quantity # memory per task; accepts "4Gi", "4G", "4096M", "4096"(MB) # Canonical unit: Gi/Mi; HPC formats converted on output tasks: integer # total number of tasks / MPI ranks / pods # Slurm: --ntasks; PBS: select×mpiprocs; K8s: replicas sum nodes: integer # node count (HPC convenience; K8s: derived from tasks) # Slurm: --nodes; PBS: select count; LSF: -n / span-derived - tasks_per_node: integer # Slurm: --ntasks-per-node; PBS: mpiprocs per chunk + tasksPerNode: integer # Slurm: --ntasks-per-node; PBS: mpiprocs per chunk gpu: count: number # GPUs per task; float for fractions (e.g., 0.5) # Slurm: --gpus-per-task; K8s: nvidia.com/gpu request @@ -142,17 +142,17 @@ spec: # Slurm: --gres=gpu::N; K8s: nodeSelector memory: quantity # GPU VRAM request (Run.ai: gpuMemory; K8s: advisory) fraction: float # 0 < x ≤ 1; Run.ai fractional GPU; else rounds up to 1 - mig_profile: string # NVIDIA MIG partition e.g. "1g.10gb" + migProfile: string # NVIDIA MIG partition e.g. "1g.10gb" # Run.ai: migProfile; K8s: nvidia.com/mig- - disk_per_task: quantity # local scratch disk per task + diskPerTask: quantity # local scratch disk per task # Slurm: (no direct; hint for tmpfs); K8s: ephemeral-storage # HTCondor: request_disk; PBS: scratch per chunk - generic_resources: # arbitrary consumable resources + genericResources: # arbitrary consumable resources : integer # Slurm: --gres=:; HTCondor: request_ # PBS: custom chunk resource; LSF: rusage[=N] limits: # K8s resource limits (optional; defaults to = requests) - cpus_per_task: integer - memory_per_task: quantity + cpusPerTask: integer + memoryPerTask: quantity ``` ### `spec.execution` @@ -163,8 +163,8 @@ spec: # ── Container mode (K8s-based schedulers) ──────────────────────────── container: image: string # OCI image reference (required for K8s schedulers) - image_pull_policy: Always | IfNotPresent | Never - image_pull_secrets: [string] + imagePullPolicy: Always | IfNotPresent | Never + imagePullSecrets: [string] command: [string] # entrypoint override (K8s: command; Docker: ENTRYPOINT) args: [string] # arguments (K8s: args; Docker: CMD) @@ -177,7 +177,7 @@ spec: shell: string # interpreter for script (PBS: -S; default: /bin/bash) # ── Common to both ──────────────────────────────────────────────────── - working_dir: string # working directory + workingDir: string # working directory # Slurm: --chdir; PBS: derived; Flux: attributes.system.cwd # K8s: container.workingDir @@ -186,13 +186,13 @@ spec: KEY: value # Slurm: export KEY=val; K8s: env[]; Flux: attributes.system.environment secrets: # from K8s Secrets (K8s schedulers only) - name: ENV_VAR_NAME - secret_name: my-secret - secret_key: secret-field - config_maps: # from K8s ConfigMaps (K8s schedulers only) + secretName: my-secret + secretKey: secret-field + configMaps: # from K8s ConfigMaps (K8s schedulers only) - name: ENV_VAR_NAME - config_map_name: my-cm - config_map_key: cm-field - inherit_from_submitter: boolean + configMapName: my-cm + configMapKey: cm-field + inheritFromSubmitter: boolean # PBS: -V; HTCondor: getenv=True; LSF: -env all # K8s: not applicable (always isolated) @@ -211,8 +211,8 @@ spec: # LSF: %J→{job_id}, %I→{array_index} # HTCondor: $(Cluster)→{job_id}, $(Process)→{array_index} stderr: string # path template for stderr - merge_stderr: boolean # combine stdout and stderr (Slurm: no -e; PBS: -j oe) - open_mode: truncate | append # Slurm: --open-mode; default: truncate + mergeStderr: boolean # combine stdout and stderr (Slurm: no -e; PBS: -j oe) + openMode: truncate | append # Slurm: --open-mode; default: truncate ``` --- @@ -230,13 +230,13 @@ spec: # Slurm: --hold; PBS: -h; LSF: -H; HTCondor: hold=True reservation: string # named advance reservation # Slurm: --reservation; LSF: -U; PBS: -l advres= - begin_after: datetime # defer start until this ISO 8601 datetime + beginAfter: datetime # defer start until this ISO 8601 datetime # Slurm: --begin; PBS: -a; LSF: -b; HTCondor: +DeferralTime deadline: datetime # hard kill-by time # Slurm: --deadline; LSF: -t - walltime_min: duration # minimum acceptable runtime (backfill hint) + walltimeMin: duration # minimum acceptable runtime (backfill hint) # Slurm: --time-min; others: ignored (advisory) - signal_before_end: string # "SIGNAL@SECONDS" — send signal before walltime expires + signalBeforeEnd: string # "SIGNAL@SECONDS" — send signal before walltime expires # Slurm: --signal; Flux: via --signal option; others: N/A ``` @@ -245,7 +245,7 @@ spec: ```yaml spec: gang: - min_available: integer # minimum tasks that must start simultaneously + minAvailable: integer # minimum tasks that must start simultaneously # Volcano: spec.minAvailable; Armada: PodGroup annotation # YuniKorn: sum of task group minMember # Slurm: implied by --nodes (all nodes or none) @@ -268,7 +268,7 @@ spec: # "1,3,7-12" (explicit), "0-99%10" (max concurrent) # Slurm: --array; PBS: -J; LSF: -J "name[...]"; HTCondor: queue N # Flux: --cc (copies); HTCondor: queue N from list - max_concurrent: integer # max simultaneously running array elements + maxConcurrent: integer # max simultaneously running array elements # Slurm: % suffix; PBS: % suffix; LSF: % suffix # Environment variable injected per element (normalized name): # BAMMM_ARRAY_INDEX → Slurm: SLURM_ARRAY_TASK_ID @@ -313,15 +313,15 @@ spec: ```yaml spec: lifecycle: - max_retries: integer # retry count on failure + maxRetries: integer # retry count on failure # Volcano: spec.maxRetry; Run.ai: backoffLimit # K8s: .spec.backoffLimit; Slurm: (requeue + scontrol requeue) # HTCondor: max_retries; Flux: --requeue-count - requeue_on_failure: boolean # auto-requeue on transient failures + requeueOnFailure: boolean # auto-requeue on transient failures # Slurm: --requeue; PBS: -r y; LSF: -r; HTCondor: via periodic_release - success_exit_codes: [integer] # non-zero exit codes treated as success + successExitCodes: [integer] # non-zero exit codes treated as success # HTCondor: success_exit_code; Slurm: (script-level only) - ttl_after_finished: duration # delete/clean up job record after completion + ttlAfterFinished: duration # delete/clean up job record after completion # Volcano: ttlSecondsAfterFinished; K8s: ttlSecondsAfterFinished ``` @@ -331,19 +331,19 @@ spec: spec: placement: # ── K8s-native placement ─────────────────────────────────────────────── - node_selector: # require nodes with these labels + nodeSelector: # require nodes with these labels key: value # K8s: nodeSelector; YuniKorn: inherits from PodSpec # Slurm: --constraint (BAMMM maps to feature flags) tolerations: [object] # K8s tolerations (pass-through; K8s schedulers only) affinity: object # K8s NodeAffinity/PodAffinity (pass-through; K8s only) - topology_spread: [object] # K8s topologySpreadConstraints (pass-through; K8s only) + topologySpread: [object] # K8s topologySpreadConstraints (pass-through; K8s only) # ── HPC placement ───────────────────────────────────────────────────── topology: scatter | pack | free # scatter: one task per node (PBS: place=scatter; Slurm: --spread-job) # pack: all tasks on one node (PBS: place=pack; Slurm: --nodes=1) # free: scheduler decides (default) - group_by: string # group tasks within same network/rack domain + groupBy: string # group tasks within same network/rack domain # PBS: place=group=; Slurm: --switches (approximation) constraint: string # hardware feature constraint string # Slurm: --constraint (e.g., "avx512&infiniband") @@ -355,7 +355,7 @@ spec: # K8s: approximated via taints; not natively supported # ── Ordered node pool preference (Run.ai, Kueue flavors) ────────────── - node_pools: [string] # ordered preference list of node pools / resource flavors + nodePools: [string] # ordered preference list of node pools / resource flavors # Run.ai: nodePools; Kueue: resourceFlavor preference (advisory) ``` @@ -382,7 +382,7 @@ spec: ```yaml spec: - file_staging: + fileStaging: inputs: # copy files TO the compute node before job starts - src: string # source: local path, s3://..., gs://..., host:/path dst: string # destination path on compute node @@ -392,14 +392,14 @@ spec: - src: string # source path on compute node dst: string # destination: path, s3://..., gs://... # PBS: stageout= ; LSF: -f "src < dst"; HTCondor: transfer_output_files - embedded_files: # inline files in the jobspec (Flux-native; others: sidecar copy) + embeddedFiles: # inline files in the jobspec (Flux-native; others: sidecar copy) - name: string # filename relative to working_dir content: string # file content encoding: utf-8 | base64 permissions: string # octal string, e.g., "0755" - transfer_policy: always | if_needed | never + transferPolicy: always | if_needed | never # HTCondor: should_transfer_files; others: always assumed - checkpoint_files: [string] # files to preserve across retries / checkpoint cycles + checkpointFiles: [string] # files to preserve across retries / checkpoint cycles # HTCondor: transfer_checkpoint_files; others: user-managed ``` @@ -409,14 +409,14 @@ spec: spec: volumes: - name: string - mount_path: string - read_only: boolean + mountPath: string + readOnly: boolean # Exactly one source: pvc: string # PVC claim name - config_map: string # ConfigMap name + configMap: string # ConfigMap name secret: string # Secret name - host_path: string # host directory (requires hostPath permission) - empty_dir: boolean # ephemeral tmpfs scratch + hostPath: string # host directory (requires hostPath permission) + emptyDir: boolean # ephemeral tmpfs scratch nfs: server: string path: string @@ -432,17 +432,17 @@ spec: tasks: - name: string # role name (master, worker, ps, evaluator, etc.) replicas: integer # pod/slot count for this role - min_replicas: integer # minimum for gang (YuniKorn: taskGroup.minMember) + minReplicas: integer # minimum for gang (YuniKorn: taskGroup.minMember) resources: # per-task resources for this role (overrides top-level) execution: # execution config for this role (overrides top-level) lifecycle: - max_retries: integer + maxRetries: integer policies: # Volcano-style event/action FSM (Volcano only; see extensions) - event: string action: string timeout: duration placement: # per-role placement constraints (YuniKorn: per task group) - depends_on: # intra-job task ordering (Volcano: DependsOn) + dependsOn: # intra-job task ordering (Volcano: DependsOn) - name: string # task name that must complete before this task starts ``` @@ -450,13 +450,13 @@ spec: ```yaml spec: - workload_type: training | interactive | inference | batch + workloadType: training | interactive | inference | batch # Run.ai: TrainingWorkload vs InteractiveWorkload vs InferenceWorkload # interactive = long-running, lower priority, non-preemptible by default # training = finite, preemptible by default # inference = latency-sensitive serving # batch = generic (default) - distributed_framework: pytorch | tensorflow | mpi | horovod | xgboost | none + distributedFramework: pytorch | tensorflow | mpi | horovod | xgboost | none # Run.ai: distributedFramework; Volcano: plugins (pytorch, tensorflow, mpi) # Slurm: --mpi= option; PBS: mpiprocs chunk resource ``` @@ -557,7 +557,7 @@ spec: periodic_vacate: string # ClassAd expression on_exit_hold: string # ClassAd expression evaluated at exit on_exit_remove: string # ClassAd expression evaluated at exit - checkpoint_exit_code: integer # exit with this code = checkpoint saved, requeue + checkpointExitCode: integer # exit with this code = checkpoint saved, requeue concurrency_limits: [string] # named token pool limits e.g. ["cms.higgs:5"] max_materialize: integer # late materialization cap max_idle: integer # idle job cap for factory jobs @@ -589,7 +589,7 @@ spec: value: string # named data artifact type: in | out | inout # consume / produce / both scope: user | global # visibility scope - embedded_files: # RFC 37 files inline in jobspec + embeddedFiles: # RFC 37 files inline in jobspec : encoding: utf-8 | base64 data: string @@ -669,10 +669,10 @@ spec: department: string # organizational grouping above project interactive: boolean # use InteractiveWorkload CRD large_shm: boolean # mount /dev/shm as large tmpfs for PyTorch DDP - min_replicas: integer # elastic distributed: minimum worker count + minReplicas: integer # elastic distributed: minimum worker count max_replicas: integer # elastic distributed: maximum worker count - node_pools: [string] # ordered node pool preference list - mig_profile: string # NVIDIA MIG profile e.g. "1g.10gb" + nodePools: [string] # ordered node pool preference list + migProfile: string # NVIDIA MIG profile e.g. "1g.10gb" pod_group_policy: all | none # gang scheduling policy run_as_user: boolean # run as container's default user service_account: string # Kubernetes service account @@ -774,7 +774,7 @@ so tooling can warn users. | `dependencies[singleton]` | Slurm | PBS/LSF/HTCondor/Flux/K8s | BAMMM resolves the name to a job ID at submit time (requires BAMMM to have scheduler access). | | `resources.gpu.type` | Any | Most | Used as a `nodeSelector` hint; not all schedulers support GPU model filtering natively. | | Volcano event/action policies | Volcano | Others | Other schedulers support only `max_retries`. Conditional completion logic (`if TaskCompleted → CompleteJob`) cannot be expressed generically. | -| `spec.workload_type: interactive` | Run.ai | Others | Interactive scheduling semantics (non-preemptible, long-running) are approximated as high-priority + `--exclusive` where applicable. | +| `spec.workloadType: interactive` | Run.ai | Others | Interactive scheduling semantics (non-preemptible, long-running) are approximated as high-priority + `--exclusive` where applicable. | | Slurm heterogeneous jobs | Slurm | Others | `extensions.slurm.het_components` preserves the spec. Only Slurm can execute het-jobs; other schedulers receive only the first component with a warning. | --- diff --git a/examples/array-job.yaml b/examples/array-job.yaml index dc41894..a2c4375 100644 --- a/examples/array-job.yaml +++ b/examples/array-job.yaml @@ -11,14 +11,14 @@ spec: walltime: PT30M resources: tasks: 1 - cpus_per_task: 1 - memory_per_task: "512Mi" + cpusPerTask: 1 + memoryPerTask: "512Mi" limits: - cpus_per_task: 1 - memory_per_task: "1Gi" + cpusPerTask: 1 + memoryPerTask: "1Gi" array: indices: "0-4" # 5 elements (0,1,2,3,4) - max_concurrent: 2 # at most 2 running simultaneously + maxConcurrent: 2 # at most 2 running simultaneously execution: container: image: busybox:latest diff --git a/examples/aws-batch-job.yaml b/examples/aws-batch-job.yaml index aac1643..0a10c37 100644 --- a/examples/aws-batch-job.yaml +++ b/examples/aws-batch-job.yaml @@ -19,10 +19,10 @@ spec: walltime: PT1H resources: tasks: 1 - cpus_per_task: 2 - memory_per_task: "4Gi" + cpusPerTask: 2 + memoryPerTask: "4Gi" lifecycle: - max_retries: 3 + maxRetries: 3 execution: container: image: "amazon/amazon-ecs-sample:latest" diff --git a/examples/container-hpc-bridge.yaml b/examples/container-hpc-bridge.yaml index bac0dbd..da3f9df 100644 --- a/examples/container-hpc-bridge.yaml +++ b/examples/container-hpc-bridge.yaml @@ -23,9 +23,9 @@ spec: resources: nodes: 1 tasks: 1 - cpus_per_task: 16 - memory_per_task: "64Gi" - disk_per_task: "500Gi" + cpusPerTask: 16 + memoryPerTask: "64Gi" + diskPerTask: "500Gi" execution: # K8s schedulers will use this: container: @@ -55,14 +55,14 @@ spec: --output "${SAMPLE_ID}.vcf.gz" fi - working_dir: /scratch/genomics + workingDir: /scratch/genomics environment: vars: SAMPLE_ID: "NA12878" TMPDIR: /scratch/tmp placement: - node_selector: + nodeSelector: node-type: high-memory constraint: "avx2" exclusive: false @@ -71,7 +71,7 @@ spec: stdout: "/scratch/logs/genomics-{job_id}.out" stderr: "/scratch/logs/genomics-{job_id}.err" - file_staging: + fileStaging: inputs: - src: "s3://my-genomics/samples/NA12878.bam" dst: "/scratch/genomics/NA12878.bam" @@ -83,22 +83,22 @@ spec: volumes: - name: reference - mount_path: /data - read_only: true + mountPath: /data + readOnly: true nfs: server: nfs.genomics.internal path: /exports/reference/hg38 - name: scratch - mount_path: /scratch - empty_dir: true + mountPath: /scratch + emptyDir: true notifications: email: "bioinformatician@example.com" events: [end, fail] lifecycle: - max_retries: 1 - ttl_after_finished: PT4H + maxRetries: 1 + ttlAfterFinished: PT4H extensions: slurm: diff --git a/examples/full-reference.yaml b/examples/full-reference.yaml index fd03c5a..4a31b66 100644 --- a/examples/full-reference.yaml +++ b/examples/full-reference.yaml @@ -15,7 +15,7 @@ metadata: experiment: bert-v2 annotations: notes: "Full reference example — not meant to be submitted" - client_id: "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3" # idempotency key + clientId: "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3" # idempotency key spec: @@ -24,53 +24,53 @@ spec: queue: gpu-batch # target queue / partition partition: gpu-batch # Slurm-friendly alias for queue priority: 600 # 0–1000 (500=normal, 600=slightly above) - priority_class: high-priority # K8s PriorityClass (takes precedence for K8s schedulers) + priorityClass: high-priority # K8s PriorityClass (takes precedence for K8s schedulers) account: project-nlp # billing account / allocation project: bert-research # sub-account project bank: allocation-123 # HPC bank (Flux) qos: gpu-qos # quality-of-service class (Slurm: --qos) walltime: PT4H # ISO 8601 duration; also accepts "4:00:00" or "14400" - walltime_min: PT1H # minimum acceptable runtime (Slurm backfill hint) - begin_after: "2026-06-01T08:00:00" # defer start (ISO 8601) + walltimeMin: PT1H # minimum acceptable runtime (Slurm backfill hint) + beginAfter: "2026-06-01T08:00:00" # defer start (ISO 8601) deadline: "2026-06-02T00:00:00" # hard kill-by - signal_before_end: "USR1@120" # send USR1 120s before walltime expires (Slurm) + signalBeforeEnd: "USR1@120" # send USR1 120s before walltime expires (Slurm) hold: false # submit in held state reservation: june-reservation # named advance reservation exclusive: false # exclusive node access - exclusive_mode: node # node | user | mcs | topo (Slurm) + exclusiveMode: node # node | user | mcs | topo (Slurm) oversubscribe: false # ── RESOURCES (per-task canonical form) ──────────────────────────────────── resources: nodes: 4 # node count tasks: 32 # total tasks / MPI ranks - tasks_per_node: 8 # tasks per node - tasks_per_socket: 4 # tasks per NUMA socket (Slurm) - cpus_per_task: 4 # CPUs per task - memory_per_task: "16Gi" # memory per task; accepts "16G", "16384M", "16384" - memory_per_cpu: "4Gi" # alternative: per-CPU (mutually exclusive with memory_per_task) + tasksPerNode: 8 # tasks per node + tasksPerSocket: 4 # tasks per NUMA socket (Slurm) + cpusPerTask: 4 # CPUs per task + memoryPerTask: "16Gi" # memory per task; accepts "16G", "16384M", "16384" + memoryPerCpu: "4Gi" # alternative: per-CPU (mutually exclusive with memory_per_task) gpu: count: 1 # GPUs per task (float 0.5 for fractional) type: a100 # GPU model hint (advisory) memory: "40Gi" # VRAM request fraction: 1.0 # Run.ai fractional GPU (0 < x ≤ 1) - mig_profile: "" # NVIDIA MIG profile e.g. "1g.10gb" + migProfile: "" # NVIDIA MIG profile e.g. "1g.10gb" exclusive: true # exclusive GPU (LSF: mode=exclusive_process) - disk_per_task: "100Gi" # local scratch disk - generic_resources: # Slurm --gres; HTCondor request_; PBS custom chunk + diskPerTask: "100Gi" # local scratch disk + genericResources: # Slurm --gres; HTCondor request_; PBS custom chunk fpga: 0 matlab_license: 0 limits: # K8s resource limits (defaults to = requests) - cpus_per_task: 8 - memory_per_task: "32Gi" + cpusPerTask: 8 + memoryPerTask: "32Gi" # ── EXECUTION ────────────────────────────────────────────────────────────── execution: # Container mode (K8s-based schedulers: Armada, Volcano, Kueue, YuniKorn, Run.ai) container: image: "pytorch/pytorch:2.3-cuda12.1-cudnn8-runtime" - image_pull_policy: IfNotPresent - image_pull_secrets: + imagePullPolicy: IfNotPresent + imagePullSecrets: - registry-credentials command: ["python"] args: ["-m", "torch.distributed.run", "train.py", "--epochs", "100"] @@ -86,7 +86,7 @@ spec: srun python train.py --epochs 100 shell: /bin/bash - working_dir: /scratch/bert-experiment + workingDir: /scratch/bert-experiment environment: vars: @@ -96,13 +96,13 @@ spec: BAMMM_ARRAY_INDEX: "" # populated per element for array jobs secrets: - name: WANDB_API_KEY - secret_name: wandb-secret - secret_key: api-key - config_maps: + secretName: wandb-secret + secretKey: api-key + configMaps: - name: MODEL_CONFIG - config_map_name: bert-config - config_map_key: config.json - inherit_from_submitter: false + configMapName: bert-config + configMapKey: config.json + inheritFromSubmitter: false stdin: /dev/null @@ -110,10 +110,10 @@ spec: tasks: - name: master replicas: 1 - min_replicas: 1 + minReplicas: 1 resources: - cpus_per_task: 4 - memory_per_task: "16Gi" + cpusPerTask: 4 + memoryPerTask: "16Gi" gpu: count: 0 execution: @@ -121,7 +121,7 @@ spec: image: "pytorch/pytorch:2.3-cuda12.1-cudnn8-runtime" command: ["python", "coordinator.py"] lifecycle: - max_retries: 2 + maxRetries: 2 policies: - event: PodFailed action: RestartTask @@ -129,15 +129,15 @@ spec: - event: TaskCompleted action: CompleteJob placement: - node_selector: + nodeSelector: node-role: coordinator - name: worker replicas: 8 - min_replicas: 4 # gang threshold for this role + minReplicas: 4 # gang threshold for this role resources: - cpus_per_task: 8 - memory_per_task: "32Gi" + cpusPerTask: 8 + memoryPerTask: "32Gi" gpu: count: 4 type: a100 @@ -146,23 +146,23 @@ spec: image: "pytorch/pytorch:2.3-cuda12.1-cudnn8-runtime" command: ["python", "-m", "torch.distributed.run", "train.py"] lifecycle: - max_retries: 3 + maxRetries: 3 policies: - event: PodEvicted action: RestartTask - depends_on: + dependsOn: - name: master # Volcano DependsOn (intra-job task ordering) # ── GANG SCHEDULING ──────────────────────────────────────────────────────── gang: - min_available: 9 # all 1 master + 8 workers must start together + minAvailable: 9 # all 1 master + 8 workers must start together style: hard # reject if gang cannot form within timeout timeout: PT10M # max wait for gang formation # ── ARRAY JOBS ───────────────────────────────────────────────────────────── array: indices: "0-99" # "0-99", "1-50:2" (step), "0-999%50" (throttle) - max_concurrent: 10 # max simultaneously running array elements + maxConcurrent: 10 # max simultaneously running array elements # ── DEPENDENCIES ─────────────────────────────────────────────────────────── dependencies: @@ -182,14 +182,14 @@ spec: # ── LIFECYCLE ────────────────────────────────────────────────────────────── lifecycle: - max_retries: 3 - requeue_on_failure: true - success_exit_codes: [0, 42] # exit 42 = "soft success" for this job - ttl_after_finished: PT1H + maxRetries: 3 + requeueOnFailure: true + successExitCodes: [0, 42] # exit 42 = "soft success" for this job + ttlAfterFinished: PT1H # ── PLACEMENT ────────────────────────────────────────────────────────────── placement: - node_selector: + nodeSelector: cloud.google.com/gke-accelerator: nvidia-tesla-a100 node-type: on-demand tolerations: @@ -205,24 +205,20 @@ spec: operator: In values: [us-central1-a, us-central1-b] topology: scatter # scatter | pack | free - group_by: rack # group within same network rack + groupBy: rack # group within same network rack constraint: "avx512&infiniband" # Slurm: --constraint; Flux: constraints.properties prefer: nvlink # soft preference (Slurm: --prefer) exclusive: false - node_pools: + nodePools: - a100-pool # first choice - h100-pool # fallback - mpi_mapping: - cpus: closest - gpus: closest - distribution: block # ── OUTPUT ───────────────────────────────────────────────────────────────── output: stdout: "/scratch/logs/{job_name}-{job_id}.out" stderr: "/scratch/logs/{job_name}-{job_id}.err" - merge_stderr: false - open_mode: truncate + mergeStderr: false + openMode: truncate # ── NOTIFICATIONS ────────────────────────────────────────────────────────── notifications: @@ -233,7 +229,7 @@ spec: - time_limit_90 # ── FILE STAGING ─────────────────────────────────────────────────────────── - file_staging: + fileStaging: inputs: - src: "s3://my-datasets/bert/tokenized/" dst: "/scratch/data/" @@ -244,44 +240,44 @@ spec: dst: "s3://my-models/bert-v2/model.pt" - src: "/scratch/logs/" dst: "s3://my-logs/bert-v2/{job_id}/" - embedded_files: + embeddedFiles: - name: train.py content: | import torch # ... training code ... encoding: utf-8 permissions: "0755" - transfer_policy: always - checkpoint_files: + transferPolicy: always + checkpointFiles: - "/scratch/checkpoint.pt" - "/scratch/optimizer.pt" - checkpoint_exit_code: 77 # exit 77 = checkpoint saved, requeue (HTCondor) + checkpointExitCode: 77 # exit 77 = checkpoint saved, requeue (HTCondor) # ── VOLUMES (K8s schedulers) ─────────────────────────────────────────────── volumes: - name: dataset - mount_path: /data - read_only: true + mountPath: /data + readOnly: true pvc: bert-dataset-pvc - name: results - mount_path: /results + mountPath: /results pvc: bert-results-pvc - name: config - mount_path: /etc/config - config_map: bert-hyperparams + mountPath: /etc/config + configMap: bert-hyperparams - name: scratch - mount_path: /scratch - empty_dir: true + mountPath: /scratch + emptyDir: true - name: nfs-models - mount_path: /models - read_only: true + mountPath: /models + readOnly: true nfs: server: nfs.internal path: /exports/models # ── WORKLOAD TYPE ────────────────────────────────────────────────────────── - workload_type: training # training | interactive | inference | batch - distributed_framework: pytorch # pytorch | tensorflow | mpi | horovod | xgboost | none + workloadType: training # training | interactive | inference | batch + distributedFramework: pytorch # pytorch | tensorflow | mpi | horovod | xgboost | none # ── EXTENSIONS: SCHEDULER-SPECIFIC PASSTHROUGH ───────────────────────────── # Fields here take precedence over generic equivalents for their target scheduler. @@ -337,7 +333,7 @@ spec: periodic_hold: "(JobStatus == 1) && (time() - EnteredCurrentStatus > 86400)" periodic_hold_reason: "Job idle for more than 24h" periodic_release: "(HoldReasonCode == 3)" - checkpoint_exit_code: 77 + checkpointExitCode: 77 concurrency_limits: - "ml_gpu_licenses:4" project_name: bert-training @@ -356,7 +352,7 @@ spec: value: tokenized-bert-dataset type: in scope: user - embedded_files: + embeddedFiles: train.py: encoding: utf-8 data: | @@ -423,9 +419,9 @@ spec: department: research interactive: false large_shm: true - min_replicas: 4 + minReplicas: 4 max_replicas: 8 - node_pools: + nodePools: - a100-80gb-pool - a100-40gb-pool pod_group_policy: all diff --git a/examples/hpc-mpi-job.yaml b/examples/hpc-mpi-job.yaml index 437947b..e775dc6 100644 --- a/examples/hpc-mpi-job.yaml +++ b/examples/hpc-mpi-job.yaml @@ -13,15 +13,15 @@ spec: queue: mpi-queue account: proj-cfd walltime: PT8H - walltime_min: PT2H # Slurm backfill: accept as short as 2h in gaps + walltimeMin: PT2H # Slurm backfill: accept as short as 2h in gaps priority: 500 resources: nodes: 8 tasks: 64 # total MPI ranks - tasks_per_node: 8 - tasks_per_socket: 4 - cpus_per_task: 4 # OpenMP threads per rank - memory_per_task: "8Gi" + tasksPerNode: 8 + tasksPerSocket: 4 + cpusPerTask: 4 # OpenMP threads per rank + memoryPerTask: "8Gi" gpu: count: 1 type: a100 @@ -31,12 +31,12 @@ spec: module load openmpi/4.1.5 cuda/12.1 export OMP_NUM_THREADS=${SLURM_CPUS_PER_TASK:-4} srun ./cfd-solver --input input.cfg --output /scratch/results/ - working_dir: /scratch/cfd-run + workingDir: /scratch/cfd-run environment: vars: OMP_NUM_THREADS: "4" OMPI_MCA_btl_openib_allow_ib: "1" - inherit_from_submitter: false + inheritFromSubmitter: false stdin: /dev/null placement: constraint: "infiniband&avx512" @@ -48,7 +48,7 @@ spec: notifications: email: "engineer@example.com" events: [end, fail] - file_staging: + fileStaging: inputs: - src: "/nfs/shared/cfd-inputs/input.cfg" dst: "/scratch/cfd-run/input.cfg" @@ -56,8 +56,8 @@ spec: - src: "/scratch/results/" dst: "/nfs/shared/cfd-results/{job_id}/" lifecycle: - max_retries: 1 - requeue_on_failure: false + maxRetries: 1 + requeueOnFailure: false extensions: slurm: diff --git a/examples/ml-training-job.yaml b/examples/ml-training-job.yaml index df59af5..8be4b4a 100644 --- a/examples/ml-training-job.yaml +++ b/examples/ml-training-job.yaml @@ -13,26 +13,26 @@ spec: schedule: queue: ml-gpu-queue priority: 600 - priority_class: high-priority + priorityClass: high-priority account: nlp-project walltime: PT6H resources: nodes: 4 tasks: 4 # 1 worker per node - cpus_per_task: 32 - memory_per_task: "128Gi" + cpusPerTask: 32 + memoryPerTask: "128Gi" gpu: count: 8 type: h100 memory: "80Gi" - distributed_framework: pytorch - workload_type: training + distributedFramework: pytorch + workloadType: training tasks: - name: worker replicas: 4 resources: - cpus_per_task: 32 - memory_per_task: "128Gi" + cpusPerTask: 32 + memoryPerTask: "128Gi" gpu: count: 8 type: h100 @@ -54,35 +54,35 @@ spec: NCCL_DEBUG: INFO NCCL_IB_DISABLE: "0" gang: - min_available: 4 + minAvailable: 4 style: hard timeout: PT10M placement: - node_selector: + nodeSelector: cloud.google.com/gke-accelerator: nvidia-h100-80gb node-type: on-demand tolerations: - key: "nvidia.com/gpu" operator: Exists effect: NoSchedule - node_pools: + nodePools: - h100-pool - a100-80gb-pool # fallback volumes: - name: dataset - mount_path: /data - read_only: true + mountPath: /data + readOnly: true pvc: bert-dataset-pvc - name: checkpoints - mount_path: /checkpoints + mountPath: /checkpoints pvc: bert-checkpoints-pvc - name: scratch - mount_path: /tmp/scratch - empty_dir: true + mountPath: /tmp/scratch + emptyDir: true lifecycle: - max_retries: 2 - requeue_on_failure: true - ttl_after_finished: PT2H + maxRetries: 2 + requeueOnFailure: true + ttlAfterFinished: PT2H output: stdout: "/checkpoints/logs/{job_id}.out" stderr: "/checkpoints/logs/{job_id}.err" @@ -120,5 +120,5 @@ spec: project: nlp-team department: research large_shm: true - node_pools: [h100-pool, a100-80gb-pool] + nodePools: [h100-pool, a100-80gb-pool] pod_group_policy: all diff --git a/examples/multi-task-job.yaml b/examples/multi-task-job.yaml index 7a6c654..ed3dc4f 100644 --- a/examples/multi-task-job.yaml +++ b/examples/multi-task-job.yaml @@ -10,18 +10,18 @@ spec: priority: 600 walltime: PT30M gang: - min_available: 4 + minAvailable: 4 style: hard timeout: PT5M tasks: - name: data-prep replicas: 1 resources: - cpus_per_task: 1 - memory_per_task: "1Gi" + cpusPerTask: 1 + memoryPerTask: "1Gi" limits: - cpus_per_task: 2 - memory_per_task: "2Gi" + cpusPerTask: 2 + memoryPerTask: "2Gi" execution: container: image: alpine:latest @@ -40,11 +40,11 @@ spec: - name: compute-worker replicas: 3 resources: - cpus_per_task: 2 - memory_per_task: "2Gi" + cpusPerTask: 2 + memoryPerTask: "2Gi" limits: - cpus_per_task: 4 - memory_per_task: "4Gi" + cpusPerTask: 4 + memoryPerTask: "4Gi" execution: container: image: busybox:latest @@ -62,5 +62,5 @@ spec: echo "Starting compute worker ${BAMMM_TASK_INDEX}" sleep 60 echo "Compute worker ${BAMMM_TASK_INDEX} completed" - depends_on: + dependsOn: - name: data-prep diff --git a/examples/simple-job.yaml b/examples/simple-job.yaml index f81988a..25b7310 100644 --- a/examples/simple-job.yaml +++ b/examples/simple-job.yaml @@ -11,8 +11,8 @@ spec: walltime: PT5M resources: tasks: 1 - cpus_per_task: 1 - memory_per_task: "128Mi" + cpusPerTask: 1 + memoryPerTask: "128Mi" execution: container: image: busybox:latest diff --git a/examples/slurm-job.yaml b/examples/slurm-job.yaml index afeacbe..d0af8fa 100644 --- a/examples/slurm-job.yaml +++ b/examples/slurm-job.yaml @@ -16,13 +16,13 @@ spec: account: my-project priority: 500 walltime: PT30M - walltime_min: PT5M # backfill: accept as short as 5 minutes + walltimeMin: PT5M # backfill: accept as short as 5 minutes reservation: "" resources: nodes: 1 tasks: 1 - cpus_per_task: 4 - memory_per_task: "8Gi" + cpusPerTask: 4 + memoryPerTask: "8Gi" gpu: count: 1 type: a100 @@ -34,7 +34,7 @@ spec: echo "Node: ${SLURM_NODELIST}" nvidia-smi sleep 120 - working_dir: /scratch/$USER + workingDir: /scratch/$USER environment: vars: MY_VAR: hello @@ -48,8 +48,8 @@ spec: email: "user@example.com" events: [end, fail] lifecycle: - max_retries: 1 - requeue_on_failure: false + maxRetries: 1 + requeueOnFailure: false extensions: slurm: @@ -60,4 +60,4 @@ spec: gpu_bind: closest cpus_per_gpu: 4 mem_per_gpu: "16Gi" - signal_before_end: "USR1@60" + signalBeforeEnd: "USR1@60" From c4489cba1d30b619e5f202b8d4621c2477329040 Mon Sep 17 00:00:00 2001 From: stackedsax Date: Sat, 18 Jul 2026 14:14:56 -0700 Subject: [PATCH 3/4] Gate examples on the SPLAT schema in CI Add a Go test (santhosh-tekuri/jsonschema/v6) that compiles schema/splat.schema.json and validates every examples/*.yaml against it. It runs under `go test ./...`, so a future example with a stray or mis-cased field fails CI instead of silently dropping data at parse time. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016PnTz6Zxqa4jHocK8kCbyx --- go.mod | 1 + go.sum | 4 ++ internal/splat/schema/examples_test.go | 61 ++++++++++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 internal/splat/schema/examples_test.go diff --git a/go.mod b/go.mod index 7f8ce22..5a662cf 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( require ( github.com/invopop/jsonschema v0.14.0 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 k8s.io/api v0.31.3 k8s.io/apimachinery v0.31.3 ) diff --git a/go.sum b/go.sum index ba480e7..34e09b8 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= @@ -44,6 +46,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= diff --git a/internal/splat/schema/examples_test.go b/internal/splat/schema/examples_test.go new file mode 100644 index 0000000..33a3ed6 --- /dev/null +++ b/internal/splat/schema/examples_test.go @@ -0,0 +1,61 @@ +package schema + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/santhosh-tekuri/jsonschema/v6" + "sigs.k8s.io/yaml" +) + +// TestExamplesValidate compiles the committed schema and validates every +// examples/*.yaml against it — the gate that keeps the docs' example specs +// honest (all fields camelCase and real, not silently dropped at parse time). +func TestExamplesValidate(t *testing.T) { + raw, err := os.ReadFile(schemaPath) + if err != nil { + t.Fatalf("read schema: %v", err) + } + doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw)) + if err != nil { + t.Fatalf("parse schema: %v", err) + } + c := jsonschema.NewCompiler() + if err := c.AddResource(SchemaID, doc); err != nil { + t.Fatalf("add schema: %v", err) + } + sch, err := c.Compile(SchemaID) + if err != nil { + t.Fatalf("compile schema: %v", err) + } + + files, err := filepath.Glob("../../../examples/*.yaml") + if err != nil { + t.Fatal(err) + } + if len(files) == 0 { + t.Fatal("no examples found") + } + for _, f := range files { + data, err := os.ReadFile(f) + if err != nil { + t.Errorf("%s: read: %v", f, err) + continue + } + jsonb, err := yaml.YAMLToJSON(data) + if err != nil { + t.Errorf("%s: yaml→json: %v", f, err) + continue + } + inst, err := jsonschema.UnmarshalJSON(bytes.NewReader(jsonb)) + if err != nil { + t.Errorf("%s: parse: %v", f, err) + continue + } + if err := sch.Validate(inst); err != nil { + t.Errorf("%s does not validate against the SPLAT schema:\n%v", filepath.Base(f), err) + } + } +} From 22521d49eb17afaf9f2b5b72200ebe66caeae444 Mon Sep 17 00:00:00 2001 From: stackedsax Date: Sat, 18 Jul 2026 14:28:40 -0700 Subject: [PATCH 4/4] Use reflect.Pointer instead of deprecated reflect.Ptr (govet lint) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016PnTz6Zxqa4jHocK8kCbyx --- internal/splat/schema/schema.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/splat/schema/schema.go b/internal/splat/schema/schema.go index e6870ab..905a065 100644 --- a/internal/splat/schema/schema.go +++ b/internal/splat/schema/schema.go @@ -24,7 +24,7 @@ func Generate() ([]byte, error) { // The opaque scalar wrappers marshal to strings; reflection alone would // emit empty objects for them (they have only unexported fields). Mapper: func(t reflect.Type) *jsonschema.Schema { - if t.Kind() == reflect.Ptr { + if t.Kind() == reflect.Pointer { t = t.Elem() } switch t {