master
go 233 lines 6.85 KB
Raw
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 }