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.
 
 
 
 
 
 

295 lines
8.5 KiB

package testrunner
import (
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
// ParseFile reads and parses a YAML scenario file.
// Include directives are resolved relative to the file's directory.
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 ParseWithBase(data, filepath.Dir(path))
}
// Parse parses YAML bytes into a Scenario and validates it.
// Include directives are resolved relative to the current working directory.
func Parse(data []byte) (*Scenario, error) {
return ParseWithBase(data, ".")
}
// ParseWithBase parses YAML bytes with a base directory for resolving includes.
func ParseWithBase(data []byte, baseDir string) (*Scenario, error) {
var s Scenario
if err := yaml.Unmarshal(data, &s); err != nil {
return nil, fmt.Errorf("parse YAML: %w", err)
}
// Resolve include directives.
expanded, err := resolveIncludes(s.Phases, baseDir, 0)
if err != nil {
return nil, fmt.Errorf("resolve includes: %w", err)
}
s.Phases = expanded
if err := validate(&s); err != nil {
return nil, fmt.Errorf("validate: %w", err)
}
return &s, nil
}
const maxIncludeDepth = 5
// resolveIncludes expands include directives in phases.
// An include phase is replaced by the phases from the included file.
// Include params are injected as {{ key }} substitutions in the included actions.
func resolveIncludes(phases []Phase, baseDir string, depth int) ([]Phase, error) {
if depth > maxIncludeDepth {
return nil, fmt.Errorf("include depth exceeds %d (circular?)", maxIncludeDepth)
}
var result []Phase
for _, p := range phases {
if p.Include == "" {
result = append(result, p)
continue
}
// Resolve include path relative to base directory.
includePath := p.Include
if !filepath.IsAbs(includePath) {
includePath = filepath.Join(baseDir, includePath)
}
data, err := os.ReadFile(includePath)
if err != nil {
return nil, fmt.Errorf("include %q: %w", p.Include, err)
}
// Parse the included file as a partial scenario (just phases).
var included struct {
Phases []Phase `yaml:"phases"`
}
if err := yaml.Unmarshal(data, &included); err != nil {
return nil, fmt.Errorf("parse include %q: %w", p.Include, err)
}
// Apply include_params as variable substitutions in action params.
if len(p.IncludeParams) > 0 {
for i := range included.Phases {
for j := range included.Phases[i].Actions {
act := &included.Phases[i].Actions[j]
for k, v := range act.Params {
act.Params[k] = substituteParams(v, p.IncludeParams)
}
// Also substitute in node, target, replica, save_as fields.
act.Node = substituteParams(act.Node, p.IncludeParams)
act.Target = substituteParams(act.Target, p.IncludeParams)
act.Replica = substituteParams(act.Replica, p.IncludeParams)
act.SaveAs = substituteParams(act.SaveAs, p.IncludeParams)
}
}
}
// Recursively resolve nested includes.
includeDir := filepath.Dir(includePath)
expanded, err := resolveIncludes(included.Phases, includeDir, depth+1)
if err != nil {
return nil, fmt.Errorf("include %q: %w", p.Include, err)
}
result = append(result, expanded...)
}
return result, nil
}
// substituteParams replaces {{ key }} with values from params.
func substituteParams(s string, params map[string]string) string {
for k, v := range params {
s = strings.ReplaceAll(s, "{{ "+k+" }}", v)
s = strings.ReplaceAll(s, "{{"+k+"}}", v)
}
return s
}
// 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
}