| 1 | // SPDX-License-Identifier: GPL-3.0-or-later |
| 2 | |
| 3 | package parity |
| 4 | |
| 5 | import ( |
| 6 | "fmt" |
| 7 | "maps" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "strings" |
| 11 | |
| 12 | "gopkg.in/yaml.v3" |
| 13 | ) |
| 14 | |
| 15 | // ManifestVersion is the current topology parity manifest schema version. |
| 16 | const ManifestVersion = "v1" |
| 17 | |
| 18 | // Manifest defines one or more topology parity scenarios. |
| 19 | type Manifest struct { |
| 20 | Version string `yaml:"version"` |
| 21 | Source ManifestSource `yaml:"source"` |
| 22 | Scenarios []ManifestScenario `yaml:"scenarios"` |
| 23 | } |
| 24 | |
| 25 | // ManifestSource captures provenance for imported parity fixtures. |
| 26 | type ManifestSource struct { |
| 27 | Repo string `yaml:"repo"` |
| 28 | Commit string `yaml:"commit"` |
| 29 | Path string `yaml:"path"` |
| 30 | } |
| 31 | |
| 32 | // ManifestProtocols declares protocol toggles for a scenario. |
| 33 | type ManifestProtocols struct { |
| 34 | LLDP bool `yaml:"lldp"` |
| 35 | CDP bool `yaml:"cdp"` |
| 36 | Bridge bool `yaml:"bridge"` |
| 37 | ARPND bool `yaml:"arp_nd"` |
| 38 | } |
| 39 | |
| 40 | // ManifestFixture describes one device fixture. |
| 41 | type ManifestFixture struct { |
| 42 | DeviceID string `yaml:"device_id"` |
| 43 | Hostname string `yaml:"hostname"` |
| 44 | Address string `yaml:"address"` |
| 45 | WalkFile string `yaml:"walk_file"` |
| 46 | Labels map[string]string `yaml:"labels,omitempty"` |
| 47 | } |
| 48 | |
| 49 | // ManifestScenario defines one parity scenario. |
| 50 | type ManifestScenario struct { |
| 51 | ID string `yaml:"id"` |
| 52 | Description string `yaml:"description"` |
| 53 | Protocols ManifestProtocols `yaml:"protocols"` |
| 54 | Fixtures []ManifestFixture `yaml:"fixtures"` |
| 55 | GoldenYAML string `yaml:"golden_yaml"` |
| 56 | GoldenJSON string `yaml:"golden_json"` |
| 57 | } |
| 58 | |
| 59 | // ResolvedScenario contains absolute paths resolved from a manifest file. |
| 60 | type ResolvedScenario struct { |
| 61 | ID string |
| 62 | Description string |
| 63 | Protocols ManifestProtocols |
| 64 | Fixtures []ResolvedFixture |
| 65 | GoldenYAML string |
| 66 | GoldenJSON string |
| 67 | } |
| 68 | |
| 69 | // ResolvedFixture is one resolved fixture path. |
| 70 | type ResolvedFixture struct { |
| 71 | DeviceID string |
| 72 | Hostname string |
| 73 | Address string |
| 74 | WalkFile string |
| 75 | Labels map[string]string |
| 76 | } |
| 77 | |
| 78 | // LoadManifest reads and validates a parity scenario manifest. |
| 79 | func LoadManifest(path string) (Manifest, error) { |
| 80 | data, err := os.ReadFile(path) |
| 81 | if err != nil { |
| 82 | return Manifest{}, fmt.Errorf("read manifest %q: %w", path, err) |
| 83 | } |
| 84 | |
| 85 | var m Manifest |
| 86 | if err := yaml.Unmarshal(data, &m); err != nil { |
| 87 | return Manifest{}, fmt.Errorf("decode manifest %q: %w", path, err) |
| 88 | } |
| 89 | if err := m.validate(); err != nil { |
| 90 | return Manifest{}, fmt.Errorf("validate manifest %q: %w", path, err) |
| 91 | } |
| 92 | return m, nil |
| 93 | } |
| 94 | |
| 95 | // ResolveScenario resolves one scenario's relative file paths against the |
| 96 | // manifest location and validates that all referenced files exist. |
| 97 | func ResolveScenario(manifestPath string, scenario ManifestScenario) (ResolvedScenario, error) { |
| 98 | if scenario.ID == "" { |
| 99 | return ResolvedScenario{}, fmt.Errorf("scenario id is required") |
| 100 | } |
| 101 | |
| 102 | baseDir := filepath.Dir(manifestPath) |
| 103 | resolved := ResolvedScenario{ |
| 104 | ID: scenario.ID, |
| 105 | Description: scenario.Description, |
| 106 | Protocols: scenario.Protocols, |
| 107 | Fixtures: make([]ResolvedFixture, 0, len(scenario.Fixtures)), |
| 108 | GoldenYAML: resolvePath(baseDir, scenario.GoldenYAML), |
| 109 | GoldenJSON: resolvePath(baseDir, scenario.GoldenJSON), |
| 110 | } |
| 111 | |
| 112 | if err := requireFile(resolved.GoldenYAML); err != nil { |
| 113 | return ResolvedScenario{}, fmt.Errorf("scenario %q golden_yaml: %w", scenario.ID, err) |
| 114 | } |
| 115 | if err := requireFile(resolved.GoldenJSON); err != nil { |
| 116 | return ResolvedScenario{}, fmt.Errorf("scenario %q golden_json: %w", scenario.ID, err) |
| 117 | } |
| 118 | |
| 119 | seenDevice := make(map[string]struct{}, len(scenario.Fixtures)) |
| 120 | for _, fixture := range scenario.Fixtures { |
| 121 | if fixture.DeviceID == "" { |
| 122 | return ResolvedScenario{}, fmt.Errorf("scenario %q has fixture with empty device_id", scenario.ID) |
| 123 | } |
| 124 | if _, ok := seenDevice[fixture.DeviceID]; ok { |
| 125 | return ResolvedScenario{}, fmt.Errorf("scenario %q has duplicate fixture device_id %q", scenario.ID, fixture.DeviceID) |
| 126 | } |
| 127 | seenDevice[fixture.DeviceID] = struct{}{} |
| 128 | |
| 129 | walkPath := resolvePath(baseDir, fixture.WalkFile) |
| 130 | if err := requireFile(walkPath); err != nil { |
| 131 | return ResolvedScenario{}, fmt.Errorf("scenario %q fixture %q walk_file: %w", scenario.ID, fixture.DeviceID, err) |
| 132 | } |
| 133 | |
| 134 | resolved.Fixtures = append(resolved.Fixtures, ResolvedFixture{ |
| 135 | DeviceID: fixture.DeviceID, |
| 136 | Hostname: fixture.Hostname, |
| 137 | Address: fixture.Address, |
| 138 | WalkFile: walkPath, |
| 139 | Labels: copyStringMap(fixture.Labels), |
| 140 | }) |
| 141 | } |
| 142 | |
| 143 | return resolved, nil |
| 144 | } |
| 145 | |
| 146 | // FindScenario finds one scenario by ID. |
| 147 | func (m Manifest) FindScenario(id string) (ManifestScenario, bool) { |
| 148 | for _, scenario := range m.Scenarios { |
| 149 | if scenario.ID == id { |
| 150 | return scenario, true |
| 151 | } |
| 152 | } |
| 153 | return ManifestScenario{}, false |
| 154 | } |
| 155 | |
| 156 | func (m Manifest) validate() error { |
| 157 | if m.Version == "" { |
| 158 | return fmt.Errorf("version is required") |
| 159 | } |
| 160 | if m.Version != ManifestVersion { |
| 161 | return fmt.Errorf("unsupported version %q (want %q)", m.Version, ManifestVersion) |
| 162 | } |
| 163 | if len(m.Scenarios) == 0 { |
| 164 | return fmt.Errorf("at least one scenario is required") |
| 165 | } |
| 166 | |
| 167 | seenScenario := make(map[string]struct{}, len(m.Scenarios)) |
| 168 | for _, scenario := range m.Scenarios { |
| 169 | if scenario.ID == "" { |
| 170 | return fmt.Errorf("scenario id is required") |
| 171 | } |
| 172 | if _, ok := seenScenario[scenario.ID]; ok { |
| 173 | return fmt.Errorf("duplicate scenario id %q", scenario.ID) |
| 174 | } |
| 175 | seenScenario[scenario.ID] = struct{}{} |
| 176 | |
| 177 | if scenario.GoldenYAML == "" || scenario.GoldenJSON == "" { |
| 178 | return fmt.Errorf("scenario %q requires golden_yaml and golden_json", scenario.ID) |
| 179 | } |
| 180 | if len(scenario.Fixtures) == 0 { |
| 181 | return fmt.Errorf("scenario %q requires at least one fixture", scenario.ID) |
| 182 | } |
| 183 | if !scenario.Protocols.LLDP && !scenario.Protocols.CDP && !scenario.Protocols.Bridge && !scenario.Protocols.ARPND { |
| 184 | return fmt.Errorf("scenario %q must enable at least one protocol", scenario.ID) |
| 185 | } |
| 186 | |
| 187 | seenDevice := make(map[string]struct{}, len(scenario.Fixtures)) |
| 188 | for _, fixture := range scenario.Fixtures { |
| 189 | if fixture.DeviceID == "" { |
| 190 | return fmt.Errorf("scenario %q has fixture with empty device_id", scenario.ID) |
| 191 | } |
| 192 | if strings.TrimSpace(fixture.WalkFile) == "" { |
| 193 | return fmt.Errorf("scenario %q fixture %q requires walk_file", scenario.ID, fixture.DeviceID) |
| 194 | } |
| 195 | if _, ok := seenDevice[fixture.DeviceID]; ok { |
| 196 | return fmt.Errorf("scenario %q has duplicate fixture device_id %q", scenario.ID, fixture.DeviceID) |
| 197 | } |
| 198 | seenDevice[fixture.DeviceID] = struct{}{} |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | return nil |
| 203 | } |
| 204 | |
| 205 | func requireFile(path string) error { |
| 206 | if strings.TrimSpace(path) == "" { |
| 207 | return fmt.Errorf("path is empty") |
| 208 | } |
| 209 | st, err := os.Stat(path) |
| 210 | if err != nil { |
| 211 | return err |
| 212 | } |
| 213 | if st.IsDir() { |
| 214 | return fmt.Errorf("%q is a directory", path) |
| 215 | } |
| 216 | return nil |
| 217 | } |
| 218 | |
| 219 | func resolvePath(baseDir, path string) string { |
| 220 | if filepath.IsAbs(path) { |
| 221 | return filepath.Clean(path) |
| 222 | } |
| 223 | return filepath.Clean(filepath.Join(baseDir, path)) |
| 224 | } |
| 225 | |
| 226 | func copyStringMap(in map[string]string) map[string]string { |
| 227 | if len(in) == 0 { |
| 228 | return nil |
| 229 | } |
| 230 | out := make(map[string]string, len(in)) |
| 231 | maps.Copy(out, in) |
| 232 | return out |
| 233 | } |