From 1cfd606243147e57337a0544684368f4aabc8c06 Mon Sep 17 00:00:00 2001 From: stackedsax Date: Sat, 18 Jul 2026 14:41:24 -0700 Subject: [PATCH 1/2] README: drop internal (Partner)/(Community) tags from the scheduler table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Those parentheticals track who validates each scheduler against a real cluster (a process note in CLAUDE.md), not anything user-facing β€” Run.ai's and LSF's parser/emitter are first-class like every other. The Type column now just names the paradigm. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016PnTz6Zxqa4jHocK8kCbyx --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f947dfd..24da7a6 100644 --- a/README.md +++ b/README.md @@ -83,8 +83,8 @@ Wherever the translation is lossy, BAMMM tells you exactly what got dropped, wha | **HTCondor** | HPC | βœ… | βœ… | | **Flux** | HPC | 🚧 | 🚧 | | **YuniKorn** | K8s | βœ… | βœ… | -| **Run.ai** | K8s (Partner) | βœ… | βœ… | -| **LSF** | HPC (Community) | 🚧 | 🚧 | +| **Run.ai** | K8s | βœ… | βœ… | +| **LSF** | HPC | 🚧 | 🚧 | Plus `splat` as both `--from` and `--to` for validation / round-tripping. Run `bammm formats` for the live list. From fff68fa440dc9f91a2ac7781a1f69122817129fc Mon Sep 17 00:00:00 2001 From: stackedsax Date: Sat, 18 Jul 2026 15:03:47 -0700 Subject: [PATCH 2/2] Add user-defined translation rules (--rules) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let users adjust a conversion without patching BAMMM: a --rules file rewrites the SPLAT IR between parse and emit, so one rule set applies regardless of the source/target pair. - internal/rules: a Ruleset of match β†’ action entries. `when` matches on from/to/has/equals; actions are default, set, rename, remove, plus a warn string. Paths are full SPLAT document paths (spec.schedule.queue, ...), evaluated over a generic map so removes actually drop fields. - convert.ConvertWithRules threads the ruleset through parse β†’ apply β†’ emit and returns rule warnings; Convert stays a thin wrapper (nil ruleset). - `bammm convert --rules ` for single and bulk runs; warnings print to stderr. Worked example in examples/rules/, reference in docs/translation-rules.md (promoted from design to implemented). This is option 1 (declarative rules file) from the earlier design; CEL expressions and array-index paths remain future work. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016PnTz6Zxqa4jHocK8kCbyx --- cmd/bammm/batch.go | 8 +- cmd/bammm/batch_test.go | 2 +- cmd/bammm/convert_rules_test.go | 55 +++++++ cmd/bammm/main.go | 32 +++- docs/translation-rules.md | 117 ++++++--------- examples/rules/route-debug-qos.yaml | 22 +++ internal/convert/convert.go | 21 ++- internal/rules/rules.go | 219 ++++++++++++++++++++++++++++ internal/rules/rules_test.go | 159 ++++++++++++++++++++ 9 files changed, 553 insertions(+), 82 deletions(-) create mode 100644 cmd/bammm/convert_rules_test.go create mode 100644 examples/rules/route-debug-qos.yaml create mode 100644 internal/rules/rules.go create mode 100644 internal/rules/rules_test.go diff --git a/cmd/bammm/batch.go b/cmd/bammm/batch.go index 5a6c539..c0c5d50 100644 --- a/cmd/bammm/batch.go +++ b/cmd/bammm/batch.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/InsightSoftmax/BAMMM/internal/convert" + "github.com/InsightSoftmax/BAMMM/internal/rules" ) // inputItem is one file to convert. rel is the path relative to the output @@ -93,7 +94,7 @@ type batchResult struct { // swapping each file's extension for the target format's. It continues past // per-file failures, writing a summary to w, and only returns a non-nil error // for problems that abort the whole run (e.g. an unwritable output dir). -func runBatch(items []inputItem, from, to, outDir string, w io.Writer) (batchResult, error) { +func runBatch(items []inputItem, from, to, outDir string, w io.Writer, rs *rules.Ruleset) (batchResult, error) { var res batchResult ext := convert.Extension(to) @@ -106,11 +107,14 @@ func runBatch(items []inputItem, from, to, outDir string, w io.Writer) (batchRes res.failures = append(res.failures, batchFailure{it.rel, err.Error()}) continue } - out, err := convert.Convert(data, from, to) + out, warnings, err := convert.ConvertWithRules(data, from, to, rs) if err != nil { res.failures = append(res.failures, batchFailure{it.rel, err.Error()}) continue } + for _, warn := range warnings { + fmt.Fprintf(w, " %s: rule: %s\n", it.rel, warn) + } outPath := filepath.Join(outDir, swapExt(it.rel, ext)) if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil { return res, fmt.Errorf("creating %s: %w", filepath.Dir(outPath), err) diff --git a/cmd/bammm/batch_test.go b/cmd/bammm/batch_test.go index 6d7e1f5..75913a2 100644 --- a/cmd/bammm/batch_test.go +++ b/cmd/bammm/batch_test.go @@ -117,7 +117,7 @@ func TestRunBatch_ContinuesAndMirrors(t *testing.T) { } var buf strings.Builder - res, err := runBatch(items, "slurm", "splat", out, &buf) + res, err := runBatch(items, "slurm", "splat", out, &buf, nil) if err != nil { t.Fatalf("runBatch: %v", err) } diff --git a/cmd/bammm/convert_rules_test.go b/cmd/bammm/convert_rules_test.go new file mode 100644 index 0000000..bb33a00 --- /dev/null +++ b/cmd/bammm/convert_rules_test.go @@ -0,0 +1,55 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestConvert_RulesFlag(t *testing.T) { + dir := t.TempDir() + rulesPath := filepath.Join(dir, "rules.yaml") + if err := os.WriteFile(rulesPath, []byte(` +apiVersion: bammm.io/rules/v1alpha1 +rules: + - when: + equals: + spec.schedule.qos: debug + set: + spec.schedule.queue: debug-queue + warn: routed debug qos +`), 0o644); err != nil { + t.Fatal(err) + } + + in := "#!/bin/bash\n#SBATCH --job-name=t\n#SBATCH --qos=debug\n#SBATCH --ntasks=1\necho hi\n" + var out, errBuf bytes.Buffer + cmd := newConvertCmd() + cmd.SetArgs([]string{"--from", "slurm", "--to", "pbs", "--rules", rulesPath}) + cmd.SetIn(strings.NewReader(in)) + cmd.SetOut(&out) + cmd.SetErr(&errBuf) + if err := cmd.Execute(); err != nil { + t.Fatalf("convert: %v", err) + } + + if !strings.Contains(out.String(), "#PBS -q debug-queue") { + t.Errorf("rule did not route the queue:\n%s", out.String()) + } + if !strings.Contains(errBuf.String(), "routed debug qos") { + t.Errorf("expected rule warning on stderr, got: %q", errBuf.String()) + } +} + +func TestConvert_RulesFileMissing(t *testing.T) { + cmd := newConvertCmd() + cmd.SetArgs([]string{"--from", "slurm", "--to", "pbs", "--rules", "/no/such/rules.yaml"}) + cmd.SetIn(strings.NewReader("#SBATCH --ntasks=1\n")) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + if err := cmd.Execute(); err == nil { + t.Fatal("expected an error for a missing --rules file") + } +} diff --git a/cmd/bammm/main.go b/cmd/bammm/main.go index bafbb89..6454c71 100644 --- a/cmd/bammm/main.go +++ b/cmd/bammm/main.go @@ -14,6 +14,7 @@ import ( _ "github.com/InsightSoftmax/BAMMM/internal/emitter/all" "github.com/InsightSoftmax/BAMMM/internal/parser" _ "github.com/InsightSoftmax/BAMMM/internal/parser/all" + "github.com/InsightSoftmax/BAMMM/internal/rules" "github.com/InsightSoftmax/BAMMM/internal/splat" ) @@ -45,7 +46,7 @@ func newRootCmd() *cobra.Command { func newConvertCmd() *cobra.Command { var from, to, inputFile, inputDir, outputDir, pattern string var recursive, report bool - var priorityRange string + var priorityRange, rulesFile string cmd := &cobra.Command{ Use: "convert [file...]", @@ -73,6 +74,11 @@ Use --from splat / --to splat to validate or round-trip without converting.`, splat.CanonicalScale = scale } + ruleSet, err := loadRules(rulesFile) + if err != nil { + return err + } + items, batch, err := gatherInputs(args, inputDir, pattern, recursive) if err != nil { return err @@ -80,11 +86,11 @@ Use --from splat / --to splat to validate or round-trip without converting.`, var runErr error if !batch { - runErr = convertSingle(cmd, items, inputFile, from, to, outputDir) + runErr = convertSingle(cmd, items, inputFile, from, to, outputDir, ruleSet) } else if outputDir == "" { return fmt.Errorf("--output-dir is required when converting multiple files or --input-dir") } else { - res, err := runBatch(items, from, to, outputDir, cmd.ErrOrStderr()) + res, err := runBatch(items, from, to, outputDir, cmd.ErrOrStderr(), ruleSet) if err != nil { return err } @@ -113,14 +119,27 @@ Use --from splat / --to splat to validate or round-trip without converting.`, cmd.Flags().BoolVar(&recursive, "recursive", true, "recurse into subdirectories of --input-dir") cmd.Flags().BoolVar(&report, "report", false, "print a SPLAT field-coverage report over the inputs") cmd.Flags().StringVar(&priorityRange, "priority-range", "0:1000", "canonical priority band MIN:MAX; widen to reduce round-trip rounding loss") + cmd.Flags().StringVar(&rulesFile, "rules", "", "user rules file to rewrite the SPLAT IR mid-conversion (see docs/translation-rules.md)") _ = cmd.MarkFlagRequired("from") _ = cmd.MarkFlagRequired("to") return cmd } +// loadRules reads and parses a --rules file, returning nil when none is given. +func loadRules(path string) (*rules.Ruleset, error) { + if path == "" { + return nil, nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading --rules: %w", err) + } + return rules.Load(data) +} + // convertSingle handles the one-input case: read from a file/stdin and write to // stdout, or to a single named file when --output-dir is set. -func convertSingle(cmd *cobra.Command, items []inputItem, inputFile, from, to, outputDir string) error { +func convertSingle(cmd *cobra.Command, items []inputItem, inputFile, from, to, outputDir string, rs *rules.Ruleset) error { var args []string if len(items) == 1 { args = []string{items[0].path} @@ -129,10 +148,13 @@ func convertSingle(cmd *cobra.Command, items []inputItem, inputFile, from, to, o if err != nil { return fmt.Errorf("reading input: %w", err) } - out, err := convert.Convert(data, from, to) + out, warnings, err := convert.ConvertWithRules(data, from, to, rs) if err != nil { return err } + for _, warn := range warnings { + fmt.Fprintf(cmd.ErrOrStderr(), "rule: %s\n", warn) + } if outputDir == "" { _, err = fmt.Fprint(cmd.OutOrStdout(), string(out)) return err diff --git a/docs/translation-rules.md b/docs/translation-rules.md index dab515f..c5c264e 100644 --- a/docs/translation-rules.md +++ b/docs/translation-rules.md @@ -1,91 +1,64 @@ -# User-defined translation rules (design β€” planned) +# User-defined translation rules -> **Status: proposed, not yet implemented.** This document is the design for a -> feature that lets users adjust translations *without patching BAMMM's source*. -> It exists so the feature is specified before it is built; nothing here works -> today. - -## Problem - -BAMMM maps native fields to SPLAT and back. When it meets something it doesn't -anticipate β€” a site-specific resource, a custom directive, a field a given -scheduler version added β€” the current options are: - -1. it lands in `extensions.` (preserved for round-trip, but **not** - translated to a *different* scheduler), or -2. it's dropped. - -Neither lets a user say "when converting X β†’ Y, turn *this* into *that*" without -editing Go and rebuilding. - -## Approach: a declarative rules file - -A user supplies a rules file and points BAMMM at it: +Adjust a conversion **without patching BAMMM**: supply a rules file that rewrites +the SPLAT intermediate representation between parse and emit. ```sh bammm convert --from slurm --to pbs --rules my-rules.yaml job.sh ``` -Rules operate on the **SPLAT IR** (after parse, before emit), so one rule set -works regardless of the source/target pair. Each rule is a `match` β†’ `action`. +Rules operate on the SPLAT IR (after the source is parsed, before the target is +emitted), so one rule set works regardless of the source/target pair. See a +worked example in [`examples/rules/`](../examples/rules/). + +## Why -### Sketch +When BAMMM meets something it doesn't anticipate β€” a site-specific resource, a +custom directive, a field a scheduler version added β€” it either preserves it +under `extensions.` (round-trips back, but doesn't translate to a +*different* scheduler) or drops it. Rules let you say "when converting X β†’ Y, +turn *this* into *that*" yourself. + +## Format ```yaml apiVersion: bammm.io/rules/v1alpha1 rules: - # Promote a preserved Slurm extension into a first-class field for the target. - - when: - from: slurm - has: extensions.slurm.switches # dotted path into the SPLAT IR - set: - placement.constraint: "network=switch" # target-agnostic SPLAT field - - # Rename / remap a value. - - when: - has: schedule.qos - equals: { schedule.qos: debug } - set: - schedule.queue: debug-queue - - # Drop with an explicit warning instead of silent loss. - when: - to: pbs - has: extensions.htcondor.requirements - drop: - warn: "HTCondor requirements expression cannot be expressed in PBS" + from: slurm # optional: source format must equal this + to: pbs # optional: target format must equal this + has: spec.schedule.qos # optional: path(s) must exist (string or list) + equals: # optional: path == value + spec.schedule.qos: debug + # Actions, applied in this order when `when` matches: + default: # set only if the path is absent + spec.schedule.queue: normal + set: # set (overwrite) + spec.schedule.queue: debug-queue + rename: # move a value from one path to another + spec.schedule.account: spec.schedule.project + remove: # delete path(s) (string or list) + - spec.extensions.slurm.switches + warn: routed debug QoS to debug-queue # optional: printed to stderr when the rule fires ``` -Action verbs (initial set): `set`, `rename`, `drop` (with `warn`), `default` -(set only if absent). - -### Match expressions - -Start with simple structural predicates (`has`, `equals`, `from`, `to`). If that -proves too limited, add an embedded **CEL** (`cel-go`) expression for conditions -and computed values β€” sandboxed, no code execution, e.g.: - -```yaml - - when: - expr: 'has(spec.resources.gpu) && spec.resources.gpu.count > 4' - set: - schedule.queue: gpu-large -``` +### Paths -Starlark or external pre/post hooks are a possible later escalation for -transforms that a declarative form can't express, but the declarative rules file -is the primary interface. +Paths are **full SPLAT document paths**, exactly as they appear in +`bammm convert --to splat` output β€” e.g. `spec.schedule.queue`, +`metadata.name`, `spec.extensions.htcondor.requirements`. (Array indices are not +supported yet.) -## Open questions +### Matching -- Precedence when multiple rules match (first-wins vs. all-apply). -- Whether rules may write into `extensions.*` (target-specific escape hatch). -- How rule-driven changes surface in `--report` and the lossiness accounting. -- Packaging: site-wide default rules vs. per-invocation `--rules`. +All conditions in a `when` must hold for the rule to fire. An empty `when: {}` +always matches. Multiple rules are evaluated in order; each matching rule's +actions apply cumulatively. -## When built +## Roadmap -This feature must ship with: a `SPEC` section (or its own `SPEC-rules.md`), CLI -help for `--rules`, worked examples under `examples/rules/`, and a note in -[gotchas.md](gotchas.md) about rule precedence. Update this document from -"proposed" to the real reference at that point. +- **CEL expressions** (`cel-go`) for `when` conditions and computed values, when + the structural predicates above prove too limited. +- **Array-index paths** for list elements. +- Starlark or external pre/post hooks for transforms a declarative form can't + express β€” an escalation, not a replacement for the rules file. diff --git a/examples/rules/route-debug-qos.yaml b/examples/rules/route-debug-qos.yaml new file mode 100644 index 0000000..2db9116 --- /dev/null +++ b/examples/rules/route-debug-qos.yaml @@ -0,0 +1,22 @@ +# Example BAMMM translation rules. Apply with: +# bammm convert --from slurm --to pbs --rules examples/rules/route-debug-qos.yaml job.sh +# +# Paths are full SPLAT document paths (what you see in `--to splat` output). +apiVersion: bammm.io/rules/v1alpha1 +rules: + # Jobs submitted at the "debug" QoS should land in the debug queue on the target. + - when: + equals: + spec.schedule.qos: debug + set: + spec.schedule.queue: debug-queue + warn: routed debug QoS to the debug-queue + + # HTCondor requirements expressions can't be expressed in PBS β€” drop them loudly + # rather than silently carrying a dead extension across. + - when: + to: pbs + has: spec.extensions.htcondor.requirements + remove: + - spec.extensions.htcondor.requirements + warn: dropped HTCondor requirements (no PBS equivalent) diff --git a/internal/convert/convert.go b/internal/convert/convert.go index a2f498e..ce1832b 100644 --- a/internal/convert/convert.go +++ b/internal/convert/convert.go @@ -6,6 +6,7 @@ import ( "github.com/InsightSoftmax/BAMMM/internal/emitter" "github.com/InsightSoftmax/BAMMM/internal/parser" + "github.com/InsightSoftmax/BAMMM/internal/rules" "github.com/InsightSoftmax/BAMMM/internal/splat" ) @@ -27,11 +28,27 @@ func Extension(format string) string { // Convert translates job spec bytes from one scheduler format to another. // Use "splat" as from/to for SPLAT pass-through (useful for validation). func Convert(input []byte, from, to string) ([]byte, error) { + out, _, err := ConvertWithRules(input, from, to, nil) + return out, err +} + +// ConvertWithRules is Convert plus an optional user rule set that rewrites the +// SPLAT IR between parse and emit. It also returns any warnings the matched +// rules emitted. A nil ruleset is a plain conversion. +func ConvertWithRules(input []byte, from, to string, rs *rules.Ruleset) ([]byte, []string, error) { job, err := parse(input, from) if err != nil { - return nil, err + return nil, nil, err + } + warnings, err := rs.Apply(job, from, to) + if err != nil { + return nil, nil, err + } + out, err := emit(job, to) + if err != nil { + return nil, warnings, err } - return emit(job, to) + return out, warnings, nil } // Parse converts source bytes into a SPLAT job without emitting, so callers diff --git a/internal/rules/rules.go b/internal/rules/rules.go new file mode 100644 index 0000000..3bd6783 --- /dev/null +++ b/internal/rules/rules.go @@ -0,0 +1,219 @@ +// Package rules implements user-defined translation rules that rewrite the SPLAT +// intermediate representation between parse and emit, so users can adjust a +// conversion without patching BAMMM. See docs/translation-rules.md. +package rules + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "sigs.k8s.io/yaml" + + "github.com/InsightSoftmax/BAMMM/internal/splat" +) + +// APIVersion is the expected apiVersion of a rules document. +const APIVersion = "bammm.io/rules/v1alpha1" + +// Ruleset is a parsed rules document. +type Ruleset struct { + APIVersion string `json:"apiVersion"` + Rules []Rule `json:"rules"` +} + +// Rule is a single match β†’ action entry. Actions apply in the order +// default β†’ set β†’ rename β†’ remove when When matches. +type Rule struct { + When When `json:"when"` + Default map[string]any `json:"default,omitempty"` // set only if the path is absent + Set map[string]any `json:"set,omitempty"` // set (overwrite) + Rename map[string]string `json:"rename,omitempty"` // oldPath -> newPath + Remove stringList `json:"remove,omitempty"` // paths to delete + Warn string `json:"warn,omitempty"` // printed when the rule fires +} + +// When holds the (all-must-hold) match conditions for a rule. +type When struct { + From string `json:"from,omitempty"` // source format must equal this + To string `json:"to,omitempty"` // target format must equal this + Has stringList `json:"has,omitempty"` // these dotted paths must exist + Equals map[string]any `json:"equals,omitempty"` // path == value +} + +// stringList unmarshals from either a single string or a list of strings. +type stringList []string + +func (s *stringList) UnmarshalJSON(b []byte) error { + var one string + if err := json.Unmarshal(b, &one); err == nil { + *s = []string{one} + return nil + } + var many []string + if err := json.Unmarshal(b, &many); err != nil { + return err + } + *s = many + return nil +} + +// Load parses and validates a rules document (YAML or JSON). +func Load(data []byte) (*Ruleset, error) { + var rs Ruleset + if err := yaml.Unmarshal(data, &rs); err != nil { + return nil, fmt.Errorf("rules: parse: %w", err) + } + if rs.APIVersion != APIVersion { + return nil, fmt.Errorf("rules: apiVersion %q, want %q", rs.APIVersion, APIVersion) + } + return &rs, nil +} + +// Apply rewrites the SPLAT job in place per the rules for a fromβ†’to conversion, +// returning any warnings emitted by matched rules (in rule order). +func (rs *Ruleset) Apply(job *splat.Job, from, to string) ([]string, error) { + if rs == nil || len(rs.Rules) == 0 { + return nil, nil + } + // Work on a generic map so dotted paths are easy to read and write. + var m map[string]any + data, err := yaml.Marshal(job) + if err != nil { + return nil, fmt.Errorf("rules: encode job: %w", err) + } + if err := yaml.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("rules: decode job: %w", err) + } + + var warnings []string + for i := range rs.Rules { + r := &rs.Rules[i] + if !matches(&r.When, m, from, to) { + continue + } + for _, p := range sortedKeys(r.Default) { + if _, ok := getPath(m, p); !ok { + setPath(m, p, r.Default[p]) + } + } + for _, p := range sortedKeys(r.Set) { + setPath(m, p, r.Set[p]) + } + for _, old := range sortedStrKeys(r.Rename) { + if v, ok := getPath(m, old); ok { + setPath(m, r.Rename[old], v) + deletePath(m, old) + } + } + for _, p := range r.Remove { + deletePath(m, p) + } + if r.Warn != "" { + warnings = append(warnings, r.Warn) + } + } + + // Materialize back into a fresh job so removed paths actually disappear. + out, err := yaml.Marshal(m) + if err != nil { + return nil, fmt.Errorf("rules: re-encode: %w", err) + } + var rebuilt splat.Job + if err := yaml.Unmarshal(out, &rebuilt); err != nil { + return nil, fmt.Errorf("rules: rebuild job: %w", err) + } + *job = rebuilt + return warnings, nil +} + +func matches(w *When, m map[string]any, from, to string) bool { + if w.From != "" && w.From != from { + return false + } + if w.To != "" && w.To != to { + return false + } + for _, p := range w.Has { + if v, ok := getPath(m, p); !ok || v == nil { + return false + } + } + for _, p := range sortedKeys(w.Equals) { + v, ok := getPath(m, p) + if !ok || !scalarEqual(v, w.Equals[p]) { + return false + } + } + return true +} + +// ── dotted-path helpers over map[string]any ────────────────────────────────── + +func getPath(m map[string]any, path string) (any, bool) { + segs := strings.Split(path, ".") + var cur any = m + for _, s := range segs { + mm, ok := cur.(map[string]any) + if !ok { + return nil, false + } + cur, ok = mm[s] + if !ok { + return nil, false + } + } + return cur, true +} + +func setPath(m map[string]any, path string, val any) { + segs := strings.Split(path, ".") + cur := m + for _, s := range segs[:len(segs)-1] { + next, ok := cur[s].(map[string]any) + if !ok { + next = map[string]any{} + cur[s] = next + } + cur = next + } + cur[segs[len(segs)-1]] = val +} + +func deletePath(m map[string]any, path string) { + segs := strings.Split(path, ".") + cur := m + for _, s := range segs[:len(segs)-1] { + next, ok := cur[s].(map[string]any) + if !ok { + return + } + cur = next + } + delete(cur, segs[len(segs)-1]) +} + +// scalarEqual compares two scalar values tolerantly (YAML/JSON may decode +// numbers as float64), by their string forms. +func scalarEqual(a, b any) bool { + return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b) +} + +func sortedKeys(m map[string]any) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func sortedStrKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/internal/rules/rules_test.go b/internal/rules/rules_test.go new file mode 100644 index 0000000..bc1e1e1 --- /dev/null +++ b/internal/rules/rules_test.go @@ -0,0 +1,159 @@ +package rules_test + +import ( + "testing" + + "github.com/InsightSoftmax/BAMMM/internal/rules" + "github.com/InsightSoftmax/BAMMM/internal/splat" +) + +func load(t *testing.T, doc string) *rules.Ruleset { + t.Helper() + rs, err := rules.Load([]byte(doc)) + if err != nil { + t.Fatalf("Load: %v", err) + } + return rs +} + +func TestLoad_BadAPIVersion(t *testing.T) { + if _, err := rules.Load([]byte("apiVersion: nope\nrules: []\n")); err == nil { + t.Fatal("expected error for wrong apiVersion") + } +} + +func TestApply_EqualsThenSetWithWarn(t *testing.T) { + job := &splat.Job{Spec: splat.Spec{Schedule: splat.Schedule{QOS: "debug"}}} + rs := load(t, ` +apiVersion: bammm.io/rules/v1alpha1 +rules: + - when: + equals: + spec.schedule.qos: debug + set: + spec.schedule.queue: debug-queue + warn: routed debug qos to debug-queue +`) + warns, err := rs.Apply(job, "slurm", "pbs") + if err != nil { + t.Fatalf("Apply: %v", err) + } + if job.Spec.Schedule.Queue != "debug-queue" { + t.Errorf("queue: got %q want debug-queue", job.Spec.Schedule.Queue) + } + if len(warns) != 1 { + t.Errorf("warnings: got %v want 1", warns) + } +} + +func TestApply_FromToGating(t *testing.T) { + rule := ` +apiVersion: bammm.io/rules/v1alpha1 +rules: + - when: + to: kueue + set: + spec.schedule.queue: k8s-queue +` + // Rule targets kueue; a pbs conversion must not fire it. + job := &splat.Job{} + if _, err := load(t, rule).Apply(job, "slurm", "pbs"); err != nil { + t.Fatal(err) + } + if job.Spec.Schedule.Queue != "" { + t.Errorf("rule should not have fired for to=pbs; queue=%q", job.Spec.Schedule.Queue) + } + // Same rule fires for kueue. + job = &splat.Job{} + if _, err := load(t, rule).Apply(job, "slurm", "kueue"); err != nil { + t.Fatal(err) + } + if job.Spec.Schedule.Queue != "k8s-queue" { + t.Errorf("rule should have fired for to=kueue; queue=%q", job.Spec.Schedule.Queue) + } +} + +func TestApply_DefaultOnlyWhenAbsent(t *testing.T) { + rs := load(t, ` +apiVersion: bammm.io/rules/v1alpha1 +rules: + - when: {} + default: + spec.schedule.queue: fallback +`) + // Absent -> filled. + a := &splat.Job{} + if _, err := rs.Apply(a, "slurm", "pbs"); err != nil { + t.Fatal(err) + } + if a.Spec.Schedule.Queue != "fallback" { + t.Errorf("default not applied: %q", a.Spec.Schedule.Queue) + } + // Present -> untouched. + b := &splat.Job{Spec: splat.Spec{Schedule: splat.Schedule{Queue: "mine"}}} + if _, err := rs.Apply(b, "slurm", "pbs"); err != nil { + t.Fatal(err) + } + if b.Spec.Schedule.Queue != "mine" { + t.Errorf("default overwrote existing value: %q", b.Spec.Schedule.Queue) + } +} + +func TestApply_RemoveExtensionWithHas(t *testing.T) { + job := &splat.Job{Spec: splat.Spec{Extensions: splat.Extensions{ + HTCondor: map[string]any{"requirements": "Memory > 8000"}, + }}} + rs := load(t, ` +apiVersion: bammm.io/rules/v1alpha1 +rules: + - when: + to: pbs + has: spec.extensions.htcondor.requirements + remove: + - spec.extensions.htcondor.requirements + warn: dropped HTCondor requirements (no PBS equivalent) +`) + warns, err := rs.Apply(job, "htcondor", "pbs") + if err != nil { + t.Fatalf("Apply: %v", err) + } + if _, ok := job.Spec.Extensions.HTCondor["requirements"]; ok { + t.Error("requirements should have been removed") + } + if len(warns) != 1 { + t.Errorf("expected one warning, got %v", warns) + } +} + +func TestApply_Rename(t *testing.T) { + job := &splat.Job{Spec: splat.Spec{Schedule: splat.Schedule{Account: "proj-x"}}} + rs := load(t, ` +apiVersion: bammm.io/rules/v1alpha1 +rules: + - when: + has: spec.schedule.account + rename: + spec.schedule.account: spec.schedule.project +`) + if _, err := rs.Apply(job, "slurm", "flux"); err != nil { + t.Fatalf("Apply: %v", err) + } + if job.Spec.Schedule.Account != "" { + t.Errorf("account should be cleared, got %q", job.Spec.Schedule.Account) + } + if job.Spec.Schedule.Project != "proj-x" { + t.Errorf("project: got %q want proj-x", job.Spec.Schedule.Project) + } +} + +func TestApply_NilRulesetIsNoop(t *testing.T) { + var rs *rules.Ruleset + job := &splat.Job{Spec: splat.Spec{Schedule: splat.Schedule{Queue: "q"}}} + warns, err := rs.Apply(job, "slurm", "pbs") + if err != nil || warns != nil { + t.Fatalf("nil ruleset should be a no-op: %v, %v", warns, err) + } + if job.Spec.Schedule.Queue != "q" { + t.Error("job mutated by nil ruleset") + } +}