Coverage for /home/runner/work/netbox-InterfaceNameRules-plugin/netbox-InterfaceNameRules-plugin/netbox-InterfaceNameRules-plugin/netbox_interface_name_rules/models.py: 100%

138 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-22 21:16 +0000

1# SPDX-License-Identifier: Apache-2.0 

2# Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com> 

3import ast 

4import re 

5 

6from dcim.models import DeviceType, ModuleType, Platform 

7from django.core.exceptions import ValidationError 

8from django.db import models 

9from django.urls import reverse 

10from netbox.models import NetBoxModel 

11from taggit.managers import TaggableManager 

12 

13from .choices import BreakoutModeChoices 

14 

15_REDOS_PATTERN = re.compile(r"(\+\*|\*\+|\?\?|\)\s*[\+\*\?]\s*[\+\*\?]|\)\s*\{[^{}]+\}\s*[\+\*\?])") 

16_TEMPLATE_FIELD = re.compile(r"\{([^{}]*)\}") 

17 

18 

19def _expression_names_channel(field): 

20 """Return True when the brace group *field* parses as an expression naming ``channel``. 

21 

22 Mirrors how ``evaluate_name_template`` reads a brace group — ``ast.parse`` plus a walk — so 

23 ``{channel + 1}`` is caught here instead of failing at evaluation time. A group that is not an 

24 expression at all is left to the plain-name check. 

25 """ 

26 try: 

27 tree = ast.parse(field.strip(), mode="eval") 

28 except (SyntaxError, ValueError): 

29 return False 

30 return any(isinstance(node, ast.Name) and node.id == "channel" for node in ast.walk(tree)) 

31 

32 

33def _references_channel(template): 

34 """Return True when *template* names ``{channel}`` in any spelling the engine can be handed. 

35 

36 Covers the plain form, the conversion and format-spec forms (``{channel!r}``, ``{channel:>2}``), 

37 the nested-arithmetic one (``{{channel} + 1}``) and the identifier inside an arithmetic 

38 expression (``{channel + 1}``). ``string.Formatter().parse()`` is not used here: it rejects the 

39 plugin's own arithmetic templates as malformed field names. 

40 """ 

41 for field in _TEMPLATE_FIELD.findall(template): 

42 name = field.split("!", 1)[0].split(":", 1)[0].strip() 

43 if name == "channel" or name.startswith(("channel.", "channel[")): 

44 return True 

45 if _expression_names_channel(field): 

46 return True 

47 return False 

48 

49 

50def _has_unbalanced_braces(template): 

51 """Return True when *template*'s braces do not pair up. 

52 

53 Nested groups are the plugin's arithmetic form (``{8 + ({x} - 1) * 2}``), so depth is counted 

54 rather than matched pairwise. 

55 """ 

56 depth = 0 

57 for char in template: 

58 if char == "{": 

59 depth += 1 

60 elif char == "}": 

61 depth -= 1 

62 if depth < 0: 

63 return True 

64 return depth != 0 

65 

66 

67def _validate_module_type_pattern(pattern): 

68 """Compile *pattern* and check for ReDoS-prone constructs. 

69 

70 Raises ``ValidationError`` targeting ``module_type_pattern`` if the 

71 pattern is syntactically invalid or contains nested quantifiers. 

72 Called from ``InterfaceNameRule.clean()`` to avoid duplicating the same 

73 try/except + ReDoS guard in each branch. 

74 """ 

75 try: 

76 re.compile(pattern) 

77 except re.error as e: 

78 raise ValidationError({"module_type_pattern": f"Invalid regex pattern: {e}"}) 

79 if _REDOS_PATTERN.search(pattern): 

80 raise ValidationError({"module_type_pattern": "Pattern contains potentially unsafe nested quantifiers."}) 

81 

82 

83def _validate_breakout_topology(breakout_mode, channel_count, parent_name_template, applies_to_device_interfaces=False): 

84 """Check that the mode, the channel count and the parent template describe one topology. 

85 

86 Raises ``ValidationError`` blaming the field that makes the combination impossible. Shared by 

87 the model's ``clean()`` and by ``RuleTestForm`` so the tester refuses exactly what a save would. 

88 """ 

89 channelized = breakout_mode == BreakoutModeChoices.CHANNELIZED 

90 if applies_to_device_interfaces: 

91 # The device-level path renames existing interfaces; it never creates a family to name. 

92 if channelized: 

93 raise ValidationError({"breakout_mode": "Device-level interface rules cannot build a channelized family."}) 

94 if parent_name_template: 

95 raise ValidationError( 

96 {"parent_name_template": "Parent name template is not available for device-level interface rules."} 

97 ) 

98 if parent_name_template: 

99 if not channelized: 

100 raise ValidationError( 

101 {"parent_name_template": "Parent name template requires the channelized breakout mode."} 

102 ) 

103 if _has_unbalanced_braces(parent_name_template): 

104 # Parent template only: stray braces in name_template predate this and are already stored. 

105 raise ValidationError( 

106 {"parent_name_template": "Unbalanced braces — every '{' in the template needs a '}'."} 

107 ) 

108 if _references_channel(parent_name_template): 

109 raise ValidationError( 

110 {"parent_name_template": "The parent interface has no channel number; remove {channel}."} 

111 ) 

112 if channelized and not channel_count: 

113 raise ValidationError({"channel_count": "A channelized rule must define at least one channel."}) 

114 

115 

116class InterfaceNameRule(NetBoxModel): 

117 """Post-install interface rename rule for module types. 

118 

119 Handles cases where NetBox's position-based naming can't produce 

120 the correct interface name, such as converter offset (CVR-X2-SFP) 

121 or breakout transceivers (QSFP+ 4x10G). 

122 

123 The name_template uses Python str.format() syntax with these variables: 

124 {slot} - Slot number from parent module bay position 

125 {bay_position} - Position of the bay this module is installed into 

126 {bay_position_num} - Numeric suffix of bay position (e.g., "swp1" → "1") 

127 {parent_bay_position} - Position of the parent module's bay 

128 {sfp_slot} - Sub-bay index within the parent module 

129 {base} - Base interface name from NetBox position resolution 

130 {channel} - Channel number (iterated for breakout) 

131 

132 Module type matching supports two modes: 

133 - Exact: FK reference to a specific ModuleType (default) 

134 - Regex: Pattern matched against ModuleType.model via re.fullmatch() 

135 

136 Scoping fields (all optional): 

137 - parent_module_type: match only when installed inside this module type 

138 - device_type: match only devices of this hardware model 

139 - platform: match only devices running this software platform/OS 

140 """ 

141 

142 module_type = models.ForeignKey( 

143 ModuleType, 

144 on_delete=models.CASCADE, 

145 null=True, 

146 blank=True, 

147 related_name="+", 

148 verbose_name="Module Type", 

149 help_text="The module type whose installation triggers this rename rule (exact match)", 

150 ) 

151 module_type_pattern = models.CharField( 

152 max_length=255, 

153 blank=True, 

154 default="", 

155 verbose_name="Module Type Pattern", 

156 help_text="Regex pattern to match module type model name (e.g. 'QSFP-DD-400G-.*'). " 

157 "Uses Python re.fullmatch() — pattern must match the entire model name.", 

158 ) 

159 module_type_is_regex = models.BooleanField( 

160 default=False, 

161 verbose_name="Use Regex Pattern", 

162 help_text="When enabled, use regex pattern instead of exact module type FK", 

163 ) 

164 parent_module_type = models.ForeignKey( 

165 ModuleType, 

166 on_delete=models.SET_NULL, 

167 null=True, 

168 blank=True, 

169 related_name="+", 

170 verbose_name="Parent Module Type", 

171 help_text="If set, rule only applies when installed inside this parent module type", 

172 ) 

173 device_type = models.ForeignKey( 

174 DeviceType, 

175 on_delete=models.SET_NULL, 

176 null=True, 

177 blank=True, 

178 related_name="+", 

179 verbose_name="Device Type", 

180 help_text="If set, rule only applies to devices of this device type", 

181 ) 

182 platform = models.ForeignKey( 

183 Platform, 

184 on_delete=models.SET_NULL, 

185 null=True, 

186 blank=True, 

187 related_name="+", 

188 verbose_name="Platform", 

189 help_text="If set, rule only applies to devices running this software platform/OS", 

190 ) 

191 name_template = models.CharField( 

192 max_length=255, 

193 help_text=( 

194 "Interface name template expression, e.g. " 

195 "'GigabitEthernet{slot}/{8 + ({parent_bay_position} - 1) * 2 + {sfp_slot}}'" 

196 ), 

197 ) 

198 parent_name_template = models.CharField( 

199 max_length=255, 

200 blank=True, 

201 default="", 

202 verbose_name="Parent Name Template", 

203 help_text=( 

204 "Optional name template for the channelized parent interface, e.g. 'et-0/0/{bay_position}'. " 

205 "Same variables as the name template, minus {channel}. Blank leaves the parent's current name." 

206 ), 

207 ) 

208 breakout_mode = models.CharField( 

209 max_length=20, 

210 choices=BreakoutModeChoices, 

211 default=BreakoutModeChoices.FLAT, 

212 verbose_name="Breakout Mode", 

213 help_text=( 

214 "Topology a breakout rule produces: 'flat' renames the base to the first channel and creates " 

215 "the remaining channels as sibling interfaces; 'channelized' turns the base into a channelized " 

216 "parent with one channel subinterface per channel (requires a NetBox that models channels)." 

217 ), 

218 ) 

219 channel_count = models.PositiveSmallIntegerField( 

220 default=0, 

221 help_text="Number of breakout channels (0 = no breakout). Creates this many interfaces per template.", 

222 ) 

223 channel_start = models.PositiveSmallIntegerField( 

224 default=0, 

225 help_text="Starting channel number for breakout interfaces (e.g., 0 for Juniper; Cisco varies by model—check device docs)", 

226 ) 

227 description = models.TextField( 

228 blank=True, 

229 help_text="Optional description or notes about this rule", 

230 ) 

231 enabled = models.BooleanField( 

232 default=True, 

233 help_text="When disabled, this rule is ignored during module installation and Apply Rules operations.", 

234 ) 

235 applies_to_device_interfaces = models.BooleanField( 

236 default=False, 

237 verbose_name="Applies to Device Interfaces", 

238 help_text=( 

239 "When enabled, this rule renames device-level interfaces (module=None) when the device " 

240 "joins or changes position in a Virtual Chassis. " 

241 "The Module Type field must be empty; the Module Type Pattern (if set) is used as a regex " 

242 "to filter which interface names to rename." 

243 ), 

244 ) 

245 

246 # Override inherited tags to avoid reverse accessor clash when co-installed 

247 # with another plugin that has a model of the same name. 

248 tags = TaggableManager(through="extras.TaggedItem", related_name="+") 

249 

250 def clean(self): 

251 """Validate regex/FK mode exclusivity and required fields.""" 

252 super().clean() 

253 if self.applies_to_device_interfaces: 

254 # Device-level rules must not reference a module type 

255 if self.module_type: 

256 raise ValidationError({"module_type": "Module type must be empty for device-level interface rules."}) 

257 # module_type_pattern is an optional interface-name filter regex 

258 if self.module_type_pattern: 

259 _validate_module_type_pattern(self.module_type_pattern) 

260 # Force regex mode off — module_type_is_regex has no meaning here 

261 self.module_type_is_regex = False 

262 elif self.module_type_is_regex: 

263 if not self.module_type_pattern: 

264 raise ValidationError({"module_type_pattern": "Regex pattern is required when regex mode is enabled."}) 

265 if self.module_type: 

266 raise ValidationError({"module_type": "Cannot set both module type FK and regex pattern. Choose one."}) 

267 _validate_module_type_pattern(self.module_type_pattern) 

268 else: 

269 # Clear any stale pattern so it does not persist when switching modes 

270 self.module_type_pattern = "" 

271 if not self.module_type: 

272 raise ValidationError({"module_type": "Module type is required when regex mode is disabled."}) 

273 _validate_breakout_topology( 

274 self.breakout_mode, 

275 self.channel_count, 

276 self.parent_name_template, 

277 self.applies_to_device_interfaces, 

278 ) 

279 

280 def get_absolute_url(self): 

281 """Return the detail URL for this rule.""" 

282 return reverse("plugins:netbox_interface_name_rules:interfacenamerule_detail", args=[self.pk]) 

283 

284 clone_fields = [ 

285 "module_type", 

286 "module_type_pattern", 

287 "module_type_is_regex", 

288 "parent_module_type", 

289 "device_type", 

290 "platform", 

291 "name_template", 

292 "parent_name_template", 

293 "breakout_mode", 

294 "channel_count", 

295 "channel_start", 

296 "description", 

297 "enabled", 

298 "applies_to_device_interfaces", 

299 ] 

300 

301 def get_breakout_mode_color(self): 

302 """Return the badge colour NetBox renders the breakout mode with.""" 

303 return BreakoutModeChoices.colors.get(self.breakout_mode) 

304 

305 @property 

306 def specificity_score(self) -> int: 

307 """Numeric priority score — higher beats lower in rule lookup. 

308 

309 The engine selects rules in this order (``find_matching_rule``): 

310 

311 1. **Exact FK match** always outranks regex at any scope. 

312 2. **Scope specificity** (more constraints = higher priority): 

313 parent_module_type contributes 4 pts, device_type 2 pts, platform 1 pt. 

314 This mirrors the candidate-iteration order in the engine. 

315 3. **Regex pattern length** (longer = more specific string match). 

316 

317 Score layout: 

318 - Exact FK rules: 1000 + scope (1000–1007) 

319 - Regex rules: scope × 100 + len(pattern) 

320 e.g. device-scoped 15-char pattern → 2×100+15 = 215 

321 platform-scoped 2-char pattern → 1×100+2 = 102 

322 Exact rules always outrank regex (max regex score with scope=7, 

323 max_length=255 would be 7×100+255 = 955, so 1000 safely exceeds 

324 any possible regex score). 

325 

326 Scope bit weights: parent_module_type=4, device_type=2, platform=1. 

327 Two rules with the same score fall back to lowest pk (first created). 

328 """ 

329 scope = ( 

330 (4 if self.parent_module_type_id else 0) 

331 + (2 if self.device_type_id else 0) 

332 + (1 if self.platform_id else 0) 

333 ) 

334 if not self.module_type_is_regex: 

335 return 1000 + scope 

336 return scope * 100 + len(self.module_type_pattern) 

337 

338 @property 

339 def specificity_label(self) -> str: 

340 """Short human-readable description of what this rule matches.""" 

341 if self.applies_to_device_interfaces: 

342 mode = f"iface-filter({len(self.module_type_pattern)})" if self.module_type_pattern else "iface-filter(*)" 

343 else: 

344 mode = "exact" if not self.module_type_is_regex else f"regex({len(self.module_type_pattern)})" 

345 parts = [] 

346 if self.parent_module_type_id: 

347 parts.append("parent") 

348 if self.device_type_id: 

349 parts.append("device") 

350 if self.platform_id: 

351 parts.append("platform") 

352 scope = "+".join(parts) if parts else "global" 

353 return f"{mode} / {scope}" 

354 

355 class Meta: 

356 ordering = ["module_type__model", "pk"] 

357 constraints = [ 

358 models.CheckConstraint( 

359 condition=( 

360 models.Q(applies_to_device_interfaces=True, module_type__isnull=True) 

361 | models.Q( 

362 applies_to_device_interfaces=False, 

363 module_type_is_regex=True, 

364 module_type__isnull=True, 

365 module_type_pattern__gt="", 

366 ) 

367 | models.Q( 

368 applies_to_device_interfaces=False, 

369 module_type_is_regex=False, 

370 module_type__isnull=False, 

371 ) 

372 ), 

373 name="interfacenamerule_module_type_mode_check", 

374 ), 

375 models.UniqueConstraint( 

376 fields=["module_type", "parent_module_type", "device_type", "platform"], 

377 condition=models.Q(module_type_is_regex=False, applies_to_device_interfaces=False), 

378 nulls_distinct=False, 

379 name="interfacenamerule_unique_exact", 

380 ), 

381 models.UniqueConstraint( 

382 fields=["module_type_pattern", "parent_module_type", "device_type", "platform"], 

383 condition=models.Q(module_type_is_regex=True), 

384 nulls_distinct=False, 

385 name="interfacenamerule_unique_regex", 

386 ), 

387 models.UniqueConstraint( 

388 fields=["module_type_pattern", "device_type", "platform"], 

389 condition=models.Q(applies_to_device_interfaces=True), 

390 nulls_distinct=False, 

391 name="interfacenamerule_unique_device_iface", 

392 ), 

393 ] 

394 

395 def __str__(self): 

396 if self.module_type_is_regex: 

397 module = f"/{self.module_type_pattern}/" 

398 else: 

399 module = self.module_type.model if self.module_type else "?" 

400 parent = f" in {self.parent_module_type.model}" if self.parent_module_type else "" 

401 device = f" on {self.device_type.model}" if self.device_type else "" 

402 platform = f" [{self.platform.name}]" if self.platform else "" 

403 return f"{module}{parent}{device}{platform}{self.name_template}" 

404 

405 csv_headers = [ 

406 "module_type", 

407 "module_type_pattern", 

408 "module_type_is_regex", 

409 "parent_module_type", 

410 "device_type", 

411 "platform", 

412 "name_template", 

413 "parent_name_template", 

414 "breakout_mode", 

415 "channel_count", 

416 "channel_start", 

417 "description", 

418 "enabled", 

419 "applies_to_device_interfaces", 

420 ] 

421 

422 def to_csv(self): 

423 """Return a tuple of field values for CSV export (matches csv_headers order).""" 

424 return ( 

425 self.module_type.model if self.module_type else "", 

426 self.module_type_pattern, 

427 self.module_type_is_regex, 

428 self.parent_module_type.model if self.parent_module_type else "", 

429 self.device_type.model if self.device_type else "", 

430 self.platform.name if self.platform else "", 

431 self.name_template, 

432 self.parent_name_template, 

433 self.breakout_mode, 

434 self.channel_count, 

435 self.channel_start, 

436 self.description, 

437 self.enabled, 

438 self.applies_to_device_interfaces, 

439 ) 

440 

441 def to_yaml(self): 

442 """Return a YAML document for this rule (used by NetBox's built-in Export).""" 

443 import yaml 

444 

445 entry = {} 

446 for header, value in zip(self.csv_headers, self.to_csv()): 

447 if (value != "" and value is not None) or header in {"name_template"}: 

448 entry[header] = value 

449 return yaml.dump([entry], default_flow_style=False, allow_unicode=True, sort_keys=False)