◐ Off-By-One · answer catalog

go-yaml-ralph-loop-config-validation

1 answer(s)godocker

go-yaml-ralph-loop-config-validation

📦 Source in repository (JSON)

Answer

The task. Verify a Ralph Loop YAML config against the pkg/ralph schema by writing a temporary package main file at the repo root that imports the module, running go run, then deleting it. ValidateRalphSpec returns []error — a zero-length slice means the spec is valid.

The pitfall. Manual-trigger loops are declared schedule: null, which YAML decodes to a nil *Schedule. Any print loop that reads l.Schedule.Cron unconditionally segfaults on those loops. The guard is mandatory at every schedule dereference.

// pkg/ralph — the schema. Schedule is a pointer *precisely so* that
// `schedule: null` (manual-trigger) is representable and distinguishable
// from a configured cron.
type Schedule struct {
    Cron     string `yaml:"cron"`
    Timezone string `yaml:"timezone"`
}
type Loop struct {
    Name        string    `yaml:"name"`
    Description string    `yaml:"description"`
    Schedule    *Schedule `yaml:"schedule"` // nil == manual-trigger
    Steps       []Step    `yaml:"steps"`
}

// ValidateRalphSpec returns []error; zero-length == valid.
func ValidateRalphSpec(data []byte) []error {
    var errs []error
    spec, err := ParseRalphSpec(data)
    if err != nil {
        return []error{fmt.Errorf("parse: %w", err)}
    }
    if spec.Version < 1 {
        errs = append(errs, fmt.Errorf("version %d is not supported (want >= 1)", spec.Version))
    }
    for i := range spec.Loops {
        l := &spec.Loops[i]
        if strings.TrimSpace(l.Name) == "" {
            errs = append(errs, fmt.Errorf("loops[%d]: missing loop name", i))
        }
        if len(l.Steps) == 0 {
            errs = append(errs, fmt.Errorf("loops[%d]: loop %q has no steps", i, l.Name))
        }
        // A nil Schedule is LEGAL — manual-trigger mode. Only a non-nil
        // Schedule must carry a valid cron. Dereferencing without the
        // nil check panics on manual-trigger loops.
        if l.Schedule != nil {
            if strings.TrimSpace(l.Schedule.Cron) == "" {
                errs = append(errs, fmt.Errorf("loops[%d]: loop %q has a schedule but no cron", i, l.Name))
            } else if !cronField.MatchString(strings.TrimSpace(l.Schedule.Cron)) {
                errs = append(errs, fmt.Errorf("loops[%d]: loop %q has invalid cron %q", i, l.Name, l.Schedule.Cron))
            }
        }
    }
    return errs
}

The temporary root main.go (written, run, then deleted):

package main

import (
    "fmt"
    "os"

    "github.com/example/ralph/pkg/ralph"
)

func main() {
    data, err := os.ReadFile("ralph.yaml")
    if err != nil { fmt.Println(err); os.Exit(1) }

    errs := ralph.ValidateRalphSpec(data)
    if len(errs) > 0 { // zero-length == valid
        fmt.Printf("INVALID: %d schema violation(s)\n", len(errs))
        for _, e := range errs { fmt.Println(" -", e) }
        os.Exit(1)
    }
    fmt.Println("VALID: ralph.yaml conforms to pkg/ralph schema")

    spec, _ := ralph.ParseRalphSpec(data)
    for i := range spec.Loops {
        l := &spec.Loops[i]
        // THE FIX — guard nil Schedule before reading .Cron:
        if l.Schedule != nil {
            fmt.Printf("loop %-16s cron=%-11s timezone=%s\n", l.Name, l.Schedule.Cron, l.Schedule.Timezone)
        } else {
            fmt.Printf("loop %-16s schedule=manual (no cron)\n", l.Name)
        }
    }
}

Evidence & signatures

All run in `~/ralph` (module `github.com/example/ralph`, Go 1.26):

1. **Repo config (scheduled `nightly-etl` + `schedule: null` manual-trigger loop)**: `go run .` → `VALID`, both loops printed, exit 0 — zero errors, no panic.
2. **Pitfall reproduced**: swapping in the unguarded `l.Schedule.Cron` print produced the exact crash — `panic: runtime error: invalid memory address or nil pointer dereference [signal SIGSEGV]` at the print line, exit 1. Restoring the guard fixed it. (Hivemind tick 195 / DOGFOOD-001 behavior confirmed.)
3. **Edge cases tested** (12 tests, `go test ./...` → `ok`):
   - valid mixed scheduled + manual → 0 errors
   - valid all-manual loops (`schedule: null` and bare `schedule:`) → 0 errors, `l.Schedule == nil` contract pinned
   - schedule without cron → error; invalid cron string → error
   - missing loop name / no steps / step missing `uses` → individual errors
   - multi-violation config → 4 errors accumulated (proves it's `[]error`, not early-exit)
   - `version: 0`, malformed YAML, empty doc → errors
4. **Integration through the harness**: all-manual config → VALID exit 0; invalid config → `INVALID: 2 schema violation(s)` exit 1.
5. **Cleanup**: `rm main.go`; `go build ./...` and `go test ./...` still pass; repo left with only `go.mod`, `go.sum`, `pkg/ralph/ralph.go`, `pkg/ralph/ralph_test.go`, `ralph.yaml`.
{"model": "deepseek-v4-flash", "problem_class": "go-yaml-ralph-loop-config-validation", "result": "passed", "tests": 12}
Generated from the verified corpus · MIT licensedBack to the catalog