alt/apps/cli/internal/operator/scenario.go

123 lines
3.5 KiB
Go

// Package operator defines the headless operator scenario schema and the
// dry-run validation used before the API/worker runtime exists. Keeping the
// scenario format and its checks here lets the CLI verify operator workflows
// as plain YAML fixtures, in line with ALT's headless-first operations gate.
package operator
import (
"bytes"
"fmt"
"os"
"time"
"gopkg.in/yaml.v3"
)
// Action enumerates the operator step actions the headless validator
// understands. The set is intentionally small: it grows as later subtasks add
// real API/worker request runners. Any action outside this set is rejected at
// dry-run time so scenario files cannot reference unimplemented behaviour.
type Action string
const (
// ActionHello validates a basic API connection handshake.
ActionHello Action = "hello"
)
// validActions is the closed set used for strict dry-run validation.
var validActions = map[Action]bool{
ActionHello: true,
}
// Duration wraps time.Duration so scenario YAML can express timeouts as Go
// duration strings (for example "5s") rather than raw nanoseconds.
type Duration time.Duration
// UnmarshalYAML decodes a duration string such as "5s" into a Duration.
func (d *Duration) UnmarshalYAML(value *yaml.Node) error {
var s string
if err := value.Decode(&s); err != nil {
return fmt.Errorf("timeout must be a duration string: %w", err)
}
parsed, err := time.ParseDuration(s)
if err != nil {
return fmt.Errorf("invalid timeout %q: %w", s, err)
}
*d = Duration(parsed)
return nil
}
// Expect captures the assertions a step makes against its response. The fields
// are descriptive only at this stage; later subtasks compare them with real
// API/worker responses.
type Expect struct {
Status string `yaml:"status"`
Capabilities []string `yaml:"capabilities"`
}
// Step is a single operator action within a scenario.
type Step struct {
ID string `yaml:"id"`
Action Action `yaml:"action"`
Expect Expect `yaml:"expect"`
}
// Scenario is the top-level operator scenario document.
type Scenario struct {
Name string `yaml:"name"`
Timeout Duration `yaml:"timeout"`
Steps []Step `yaml:"steps"`
}
// LoadScenario reads a scenario file and returns it after strict decode and
// dry-run validation.
func LoadScenario(path string) (*Scenario, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read scenario: %w", err)
}
return ParseScenario(data)
}
// ParseScenario strictly decodes scenario YAML and validates it. Unknown YAML
// fields are rejected so typos in fixtures surface immediately.
func ParseScenario(data []byte) (*Scenario, error) {
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
var s Scenario
if err := dec.Decode(&s); err != nil {
return nil, fmt.Errorf("decode scenario: %w", err)
}
if err := s.Validate(); err != nil {
return nil, err
}
return &s, nil
}
// Validate runs the dry-run checks: a named scenario, at least one step, and
// every step having a unique id and a known action.
func (s *Scenario) Validate() error {
if s.Name == "" {
return fmt.Errorf("scenario name is required")
}
if len(s.Steps) == 0 {
return fmt.Errorf("scenario %q has no steps", s.Name)
}
seen := make(map[string]bool, len(s.Steps))
for i, step := range s.Steps {
if step.ID == "" {
return fmt.Errorf("step %d: id is required", i)
}
if seen[step.ID] {
return fmt.Errorf("step %q: duplicate id", step.ID)
}
seen[step.ID] = true
if !validActions[step.Action] {
return fmt.Errorf("step %q: unknown action %q", step.ID, step.Action)
}
}
return nil
}