master
go 281 lines 8.31 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package parity
4
5 import (
6 "bytes"
7 "encoding/json"
8 "fmt"
9 "os"
10 "sort"
11 "strings"
12
13 "gopkg.in/yaml.v3"
14 )
15
16 // GoldenVersion is the current parity golden schema version.
17 const GoldenVersion = "v1"
18
19 // GoldenDocument is the source-of-truth YAML structure for one scenario.
20 type GoldenDocument struct {
21 Version string `yaml:"version" json:"version"`
22 ScenarioID string `yaml:"scenario_id" json:"scenario_id"`
23 Description string `yaml:"description,omitempty" json:"description,omitempty"`
24 Devices []GoldenDevice `yaml:"devices" json:"devices"`
25 Adjacencies []GoldenAdjacency `yaml:"adjacencies" json:"adjacencies"`
26 Expectations GoldenCounts `yaml:"expectations" json:"expectations"`
27 }
28
29 // GoldenCounts stores aggregate assertions used by parity tests.
30 type GoldenCounts struct {
31 DirectionalAdjacencies int `yaml:"directional_adjacencies" json:"directional_adjacencies"`
32 BidirectionalPairs int `yaml:"bidirectional_pairs" json:"bidirectional_pairs"`
33 Devices int `yaml:"devices" json:"devices"`
34 }
35
36 // GoldenDevice is one expected device in the scenario.
37 type GoldenDevice struct {
38 ID string `yaml:"id" json:"id"`
39 Hostname string `yaml:"hostname" json:"hostname"`
40 }
41
42 // GoldenAdjacency is one expected directed adjacency observation.
43 type GoldenAdjacency struct {
44 Protocol string `yaml:"protocol" json:"protocol"`
45 SourceDevice string `yaml:"source_device" json:"source_device"`
46 SourcePort string `yaml:"source_port" json:"source_port"`
47 TargetDevice string `yaml:"target_device" json:"target_device"`
48 TargetPort string `yaml:"target_port" json:"target_port"`
49 }
50
51 // LoadGoldenYAML loads and validates a golden YAML source file.
52 func LoadGoldenYAML(path string) (GoldenDocument, error) {
53 data, err := os.ReadFile(path)
54 if err != nil {
55 return GoldenDocument{}, fmt.Errorf("read golden yaml %q: %w", path, err)
56 }
57 var doc GoldenDocument
58 if err := yaml.Unmarshal(data, &doc); err != nil {
59 return GoldenDocument{}, fmt.Errorf("decode golden yaml %q: %w", path, err)
60 }
61 if err := doc.validate(); err != nil {
62 return GoldenDocument{}, fmt.Errorf("validate golden yaml %q: %w", path, err)
63 }
64 return doc.Canonical(), nil
65 }
66
67 // LoadGoldenJSON loads a generated canonical JSON cache.
68 func LoadGoldenJSON(path string) (GoldenDocument, error) {
69 data, err := os.ReadFile(path)
70 if err != nil {
71 return GoldenDocument{}, fmt.Errorf("read golden json %q: %w", path, err)
72 }
73 var doc GoldenDocument
74 if err := json.Unmarshal(data, &doc); err != nil {
75 return GoldenDocument{}, fmt.Errorf("decode golden json %q: %w", path, err)
76 }
77 if err := doc.validate(); err != nil {
78 return GoldenDocument{}, fmt.Errorf("validate golden json %q: %w", path, err)
79 }
80 return doc.Canonical(), nil
81 }
82
83 // CanonicalJSON marshals a deterministic canonical JSON representation.
84 func (d GoldenDocument) CanonicalJSON() ([]byte, error) {
85 c := d.Canonical()
86 out, err := json.MarshalIndent(c, "", " ")
87 if err != nil {
88 return nil, err
89 }
90 return append(out, '\n'), nil
91 }
92
93 // Canonical returns a deterministic ordering for slices in the document.
94 func (d GoldenDocument) Canonical() GoldenDocument {
95 out := d
96 out.Devices = make([]GoldenDevice, 0, len(d.Devices))
97 out.Devices = append(out.Devices, d.Devices...)
98 out.Adjacencies = make([]GoldenAdjacency, 0, len(d.Adjacencies))
99 out.Adjacencies = append(out.Adjacencies, d.Adjacencies...)
100
101 sort.Slice(out.Devices, func(i, j int) bool {
102 if out.Devices[i].ID != out.Devices[j].ID {
103 return out.Devices[i].ID < out.Devices[j].ID
104 }
105 return out.Devices[i].Hostname < out.Devices[j].Hostname
106 })
107
108 sort.Slice(out.Adjacencies, func(i, j int) bool {
109 ai := out.Adjacencies[i]
110 aj := out.Adjacencies[j]
111 if ai.Protocol != aj.Protocol {
112 return ai.Protocol < aj.Protocol
113 }
114 if ai.SourceDevice != aj.SourceDevice {
115 return ai.SourceDevice < aj.SourceDevice
116 }
117 if ai.SourcePort != aj.SourcePort {
118 return ai.SourcePort < aj.SourcePort
119 }
120 if ai.TargetDevice != aj.TargetDevice {
121 return ai.TargetDevice < aj.TargetDevice
122 }
123 return ai.TargetPort < aj.TargetPort
124 })
125 return out
126 }
127
128 // ValidateCache compares authored YAML against generated JSON cache.
129 func ValidateCache(yamlPath, jsonPath string) error {
130 yamlDoc, err := LoadGoldenYAML(yamlPath)
131 if err != nil {
132 return err
133 }
134 jsonDoc, err := LoadGoldenJSON(jsonPath)
135 if err != nil {
136 return err
137 }
138 yamlJSON, err := yamlDoc.CanonicalJSON()
139 if err != nil {
140 return fmt.Errorf("marshal canonical yaml document: %w", err)
141 }
142 jsonJSON, err := jsonDoc.CanonicalJSON()
143 if err != nil {
144 return fmt.Errorf("marshal canonical json document: %w", err)
145 }
146 if !bytes.Equal(yamlJSON, jsonJSON) {
147 return fmt.Errorf("golden cache mismatch between %q and %q", yamlPath, jsonPath)
148 }
149 return nil
150 }
151
152 func (d GoldenDocument) validate() error {
153 if d.Version == "" {
154 return fmt.Errorf("version is required")
155 }
156 if d.Version != GoldenVersion {
157 return fmt.Errorf("unsupported version %q (want %q)", d.Version, GoldenVersion)
158 }
159 if d.ScenarioID == "" {
160 return fmt.Errorf("scenario_id is required")
161 }
162 if len(d.Devices) == 0 {
163 return fmt.Errorf("at least one device is required")
164 }
165
166 seenDevices := make(map[string]struct{}, len(d.Devices))
167 for _, dev := range d.Devices {
168 if dev.ID == "" {
169 return fmt.Errorf("device id is required")
170 }
171 if _, ok := seenDevices[dev.ID]; ok {
172 return fmt.Errorf("duplicate device id %q", dev.ID)
173 }
174 seenDevices[dev.ID] = struct{}{}
175 }
176
177 for _, adj := range d.Adjacencies {
178 if adj.Protocol == "" {
179 return fmt.Errorf("adjacency protocol is required")
180 }
181 if _, ok := seenDevices[adj.SourceDevice]; !ok {
182 return fmt.Errorf("adjacency source_device %q is not in devices", adj.SourceDevice)
183 }
184 if _, ok := seenDevices[adj.TargetDevice]; !ok {
185 return fmt.Errorf("adjacency target_device %q is not in devices", adj.TargetDevice)
186 }
187 }
188
189 if d.Expectations.DirectionalAdjacencies != len(d.Adjacencies) {
190 return fmt.Errorf("expectations.directional_adjacencies=%d does not match adjacencies=%d", d.Expectations.DirectionalAdjacencies, len(d.Adjacencies))
191 }
192 if d.Expectations.BidirectionalPairs != countGoldenBidirectionalPairs(d.Adjacencies) {
193 return fmt.Errorf("expectations.bidirectional_pairs=%d does not match bidirectional_pairs=%d", d.Expectations.BidirectionalPairs, countGoldenBidirectionalPairs(d.Adjacencies))
194 }
195 if d.Expectations.Devices != len(d.Devices) {
196 return fmt.Errorf("expectations.devices=%d does not match devices=%d", d.Expectations.Devices, len(d.Devices))
197 }
198
199 return nil
200 }
201
202 func countGoldenBidirectionalPairs(adjacencies []GoldenAdjacency) int {
203 directed := make(map[string]struct{}, len(adjacencies))
204 for _, adj := range adjacencies {
205 directed[goldenAdjacencyPairKey(adj)] = struct{}{}
206 }
207
208 counted := make(map[string]struct{}, len(directed))
209 pairs := 0
210 for _, adj := range adjacencies {
211 forward := goldenAdjacencyPairKey(adj)
212 reverse := goldenAdjacencyPairKey(GoldenAdjacency{
213 Protocol: adj.Protocol,
214 SourceDevice: adj.TargetDevice,
215 SourcePort: adj.TargetPort,
216 TargetDevice: adj.SourceDevice,
217 TargetPort: adj.SourcePort,
218 })
219 if forward == reverse {
220 continue
221 }
222
223 canonical := min(reverse, forward)
224 if _, done := counted[canonical]; done {
225 continue
226 }
227 if _, ok := directed[reverse]; !ok {
228 continue
229 }
230
231 counted[canonical] = struct{}{}
232 pairs++
233 }
234
235 return pairs
236 }
237
238 func goldenAdjacencyKey(parts ...string) string {
239 return strings.Join(parts, "|")
240 }
241
242 func goldenAdjacencyPairKey(adj GoldenAdjacency) string {
243 return goldenAdjacencyKey(
244 adj.Protocol,
245 adj.SourceDevice,
246 normalizeGoldenPortForPairing(adj.SourcePort),
247 adj.TargetDevice,
248 normalizeGoldenPortForPairing(adj.TargetPort),
249 )
250 }
251
252 func normalizeGoldenPortForPairing(port string) string {
253 port = strings.ToLower(strings.TrimSpace(port))
254 if port == "" {
255 return ""
256 }
257
258 port = strings.NewReplacer(" ", "", "\t", "", "\n", "", "\r", "").Replace(port)
259
260 for _, alias := range []struct {
261 short string
262 long string
263 }{
264 {short: "gi", long: "gigabitethernet"},
265 {short: "fa", long: "fastethernet"},
266 {short: "te", long: "tengigabitethernet"},
267 {short: "po", long: "portchannel"},
268 } {
269 if strings.HasPrefix(port, alias.long) {
270 return port
271 }
272 if rest, ok := strings.CutPrefix(port, alias.short); ok && rest != "" {
273 switch rest[0] {
274 case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '/':
275 return alias.long + rest
276 }
277 }
278 }
279
280 return port
281 }