123 lines
3.5 KiB
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
|
|
}
|