You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

207 lines
5.6 KiB

package testrunner
import (
"fmt"
"os"
"strings"
"gopkg.in/yaml.v3"
)
// ParseFile reads and parses a YAML scenario file.
func ParseFile(path string) (*Scenario, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read scenario %s: %w", path, err)
}
return Parse(data)
}
// Parse parses YAML bytes into a Scenario and validates it.
func Parse(data []byte) (*Scenario, error) {
var s Scenario
if err := yaml.Unmarshal(data, &s); err != nil {
return nil, fmt.Errorf("parse YAML: %w", err)
}
if err := validate(&s); err != nil {
return nil, fmt.Errorf("validate: %w", err)
}
return &s, nil
}
// validate checks referential integrity and required fields.
func validate(s *Scenario) error {
if s.Name == "" {
return fmt.Errorf("scenario name is required")
}
// Check that every target references a valid node.
for tName, tSpec := range s.Targets {
if tSpec.Node == "" {
return fmt.Errorf("target %q: node is required", tName)
}
if _, ok := s.Topology.Nodes[tSpec.Node]; !ok {
return fmt.Errorf("target %q: node %q not found in topology", tName, tSpec.Node)
}
if tSpec.IQNSuffix == "" {
return fmt.Errorf("target %q: iqn_suffix is required", tName)
}
}
// Check port conflicts among targets on the same node.
type nodePort struct {
node string
port int
}
used := make(map[nodePort]string) // nodePort -> target name
for tName, tSpec := range s.Targets {
ports := []int{tSpec.ISCSIPort, tSpec.AdminPort, tSpec.ReplicaDataPort, tSpec.ReplicaCtrlPort, tSpec.RebuildPort}
for _, p := range ports {
if p == 0 {
continue
}
np := nodePort{tSpec.Node, p}
if other, ok := used[np]; ok {
return fmt.Errorf("port conflict: targets %q and %q both use port %d on node %q",
other, tName, p, tSpec.Node)
}
used[np] = tName
}
}
// Validate agents section (coordinator mode).
if len(s.Topology.Agents) > 0 {
for nodeName, nodeSpec := range s.Topology.Nodes {
if nodeSpec.Agent != "" {
if _, ok := s.Topology.Agents[nodeSpec.Agent]; !ok {
return fmt.Errorf("node %q: agent %q not found in topology.agents", nodeName, nodeSpec.Agent)
}
}
}
}
// Check phases and actions.
if len(s.Phases) == 0 {
return fmt.Errorf("at least one phase is required")
}
for _, phase := range s.Phases {
if phase.Name == "" {
return fmt.Errorf("phase name is required")
}
if phase.Repeat < 0 || phase.Repeat > 100 {
return fmt.Errorf("phase %q: repeat must be 0..100 (got %d)", phase.Name, phase.Repeat)
}
if phase.TrimPct < 0 || phase.TrimPct > 49 {
return fmt.Errorf("phase %q: trim_pct must be 0..49 (got %d)", phase.Name, phase.TrimPct)
}
if phase.Aggregate != "" && phase.Aggregate != "median" && phase.Aggregate != "mean" && phase.Aggregate != "none" {
return fmt.Errorf("phase %q: aggregate must be 'median', 'mean', or 'none' (got %q)", phase.Name, phase.Aggregate)
}
// Validate save_as uniqueness within parallel phases.
if phase.Parallel {
saveAsSet := make(map[string]int)
for i, act := range phase.Actions {
if act.SaveAs != "" {
if prev, ok := saveAsSet[act.SaveAs]; ok {
return fmt.Errorf("phase %q (parallel): save_as %q used by both action %d and %d",
phase.Name, act.SaveAs, prev, i)
}
saveAsSet[act.SaveAs] = i
}
}
}
for i, act := range phase.Actions {
if act.Action == "" {
return fmt.Errorf("phase %q, action %d: action type is required", phase.Name, i)
}
// Validate target references.
if act.Target != "" {
if _, ok := s.Targets[act.Target]; !ok {
return fmt.Errorf("phase %q, action %d (%s): target %q not found",
phase.Name, i, act.Action, act.Target)
}
}
if act.Replica != "" {
if _, ok := s.Targets[act.Replica]; !ok {
return fmt.Errorf("phase %q, action %d (%s): replica %q not found",
phase.Name, i, act.Action, act.Replica)
}
}
// Validate node references in actions.
if act.Node != "" {
if _, ok := s.Topology.Nodes[act.Node]; !ok {
return fmt.Errorf("phase %q, action %d (%s): node %q not found",
phase.Name, i, act.Action, act.Node)
}
}
}
}
// Validate variable references ({{ var }}) don't reference undefined save_as.
defined := make(map[string]bool)
// Add env vars.
for k := range s.Env {
defined[k] = true
}
for _, phase := range s.Phases {
if phase.Always {
continue // cleanup phases may use vars from any prior phase
}
for _, act := range phase.Actions {
// Check var references in all string fields.
refs := extractVarRefs(act)
for _, ref := range refs {
if !defined[ref] && !strings.HasPrefix(ref, "__") {
// Allow forward refs (they'll be resolved at runtime); just warn-level
}
}
if act.SaveAs != "" {
defined[act.SaveAs] = true
}
}
}
return nil
}
// extractVarRefs finds all {{ var }} references in action fields.
func extractVarRefs(act Action) []string {
var refs []string
fields := collectStringFields(act)
for _, f := range fields {
refs = append(refs, extractVarsFromString(f)...)
}
return refs
}
// collectStringFields returns all string values from an action's params and known fields.
func collectStringFields(act Action) []string {
var fields []string
for _, v := range act.Params {
fields = append(fields, v)
}
return fields
}
// extractVarsFromString finds all {{ name }} patterns in a string.
func extractVarsFromString(s string) []string {
var vars []string
for {
start := strings.Index(s, "{{")
if start < 0 {
break
}
end := strings.Index(s[start:], "}}")
if end < 0 {
break
}
name := strings.TrimSpace(s[start+2 : start+end])
if name != "" {
vars = append(vars, name)
}
s = s[start+end+2:]
}
return vars
}