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

90 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-02 15:19 +0000

1# SPDX-License-Identifier: Apache-2.0 

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

3import re 

4 

5from dcim.models import DeviceType, ModuleType, Platform 

6from django.core.exceptions import ValidationError 

7from django.db import models 

8from django.urls import reverse 

9from netbox.models import NetBoxModel 

10from taggit.managers import TaggableManager 

11 

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

13 

14 

15def _validate_module_type_pattern(pattern): 

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

17 

18 Raises ``ValidationError`` targeting ``module_type_pattern`` if the 

19 pattern is syntactically invalid or contains nested quantifiers. 

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

21 try/except + ReDoS guard in each branch. 

22 """ 

23 try: 

24 re.compile(pattern) 

25 except re.error as e: 

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

27 if _REDOS_PATTERN.search(pattern): 

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

29 

30 

31class InterfaceNameRule(NetBoxModel): 

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

33 

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

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

36 or breakout transceivers (QSFP+ 4x10G). 

37 

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

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

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

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

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

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

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

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

46 

47 Module type matching supports two modes: 

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

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

50 

51 Scoping fields (all optional): 

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

53 - device_type: match only devices of this hardware model 

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

55 """ 

56 

57 module_type = models.ForeignKey( 

58 ModuleType, 

59 on_delete=models.CASCADE, 

60 null=True, 

61 blank=True, 

62 related_name="+", 

63 verbose_name="Module Type", 

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

65 ) 

66 module_type_pattern = models.CharField( 

67 max_length=255, 

68 blank=True, 

69 default="", 

70 verbose_name="Module Type Pattern", 

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

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

73 ) 

74 module_type_is_regex = models.BooleanField( 

75 default=False, 

76 verbose_name="Use Regex Pattern", 

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

78 ) 

79 parent_module_type = models.ForeignKey( 

80 ModuleType, 

81 on_delete=models.SET_NULL, 

82 null=True, 

83 blank=True, 

84 related_name="+", 

85 verbose_name="Parent Module Type", 

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

87 ) 

88 device_type = models.ForeignKey( 

89 DeviceType, 

90 on_delete=models.SET_NULL, 

91 null=True, 

92 blank=True, 

93 related_name="+", 

94 verbose_name="Device Type", 

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

96 ) 

97 platform = models.ForeignKey( 

98 Platform, 

99 on_delete=models.SET_NULL, 

100 null=True, 

101 blank=True, 

102 related_name="+", 

103 verbose_name="Platform", 

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

105 ) 

106 name_template = models.CharField( 

107 max_length=255, 

108 help_text=( 

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

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

111 ), 

112 ) 

113 channel_count = models.PositiveSmallIntegerField( 

114 default=0, 

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

116 ) 

117 channel_start = models.PositiveSmallIntegerField( 

118 default=0, 

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

120 ) 

121 description = models.TextField( 

122 blank=True, 

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

124 ) 

125 enabled = models.BooleanField( 

126 default=True, 

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

128 ) 

129 applies_to_device_interfaces = models.BooleanField( 

130 default=False, 

131 verbose_name="Applies to Device Interfaces", 

132 help_text=( 

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

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

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

136 "to filter which interface names to rename." 

137 ), 

138 ) 

139 

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

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

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

143 

144 def clean(self): 

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

146 super().clean() 

147 if self.applies_to_device_interfaces: 

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

149 if self.module_type: 

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

151 # module_type_pattern is an optional interface-name filter regex 

152 if self.module_type_pattern: 

153 _validate_module_type_pattern(self.module_type_pattern) 

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

155 self.module_type_is_regex = False 

156 elif self.module_type_is_regex: 

157 if not self.module_type_pattern: 

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

159 if self.module_type: 

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

161 _validate_module_type_pattern(self.module_type_pattern) 

162 else: 

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

164 self.module_type_pattern = "" 

165 if not self.module_type: 

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

167 

168 def get_absolute_url(self): 

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

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

171 

172 clone_fields = [ 

173 "module_type", 

174 "module_type_pattern", 

175 "module_type_is_regex", 

176 "parent_module_type", 

177 "device_type", 

178 "platform", 

179 "name_template", 

180 "channel_count", 

181 "channel_start", 

182 "description", 

183 "enabled", 

184 "applies_to_device_interfaces", 

185 ] 

186 

187 @property 

188 def specificity_score(self) -> int: 

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

190 

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

192 

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

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

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

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

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

198 

199 Score layout: 

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

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

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

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

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

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

206 any possible regex score). 

207 

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

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

210 """ 

211 scope = ( 

212 (4 if self.parent_module_type_id else 0) 

213 + (2 if self.device_type_id else 0) 

214 + (1 if self.platform_id else 0) 

215 ) 

216 if not self.module_type_is_regex: 

217 return 1000 + scope 

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

219 

220 @property 

221 def specificity_label(self) -> str: 

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

223 if self.applies_to_device_interfaces: 

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

225 else: 

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

227 parts = [] 

228 if self.parent_module_type_id: 

229 parts.append("parent") 

230 if self.device_type_id: 

231 parts.append("device") 

232 if self.platform_id: 

233 parts.append("platform") 

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

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

236 

237 class Meta: 

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

239 constraints = [ 

240 models.CheckConstraint( 

241 check=( 

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

243 | models.Q( 

244 applies_to_device_interfaces=False, 

245 module_type_is_regex=True, 

246 module_type__isnull=True, 

247 module_type_pattern__gt="", 

248 ) 

249 | models.Q( 

250 applies_to_device_interfaces=False, 

251 module_type_is_regex=False, 

252 module_type__isnull=False, 

253 ) 

254 ), 

255 name="interfacenamerule_module_type_mode_check", 

256 ), 

257 models.UniqueConstraint( 

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

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

260 nulls_distinct=False, 

261 name="interfacenamerule_unique_exact", 

262 ), 

263 models.UniqueConstraint( 

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

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

266 nulls_distinct=False, 

267 name="interfacenamerule_unique_regex", 

268 ), 

269 models.UniqueConstraint( 

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

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

272 nulls_distinct=False, 

273 name="interfacenamerule_unique_device_iface", 

274 ), 

275 ] 

276 

277 def __str__(self): 

278 if self.module_type_is_regex: 

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

280 else: 

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

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

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

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

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

286 

287 csv_headers = [ 

288 "module_type", 

289 "module_type_pattern", 

290 "module_type_is_regex", 

291 "parent_module_type", 

292 "device_type", 

293 "platform", 

294 "name_template", 

295 "channel_count", 

296 "channel_start", 

297 "description", 

298 "enabled", 

299 "applies_to_device_interfaces", 

300 ] 

301 

302 def to_csv(self): 

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

304 return ( 

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

306 self.module_type_pattern, 

307 self.module_type_is_regex, 

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

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

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

311 self.name_template, 

312 self.channel_count, 

313 self.channel_start, 

314 self.description, 

315 self.enabled, 

316 self.applies_to_device_interfaces, 

317 ) 

318 

319 def to_yaml(self): 

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

321 import yaml 

322 

323 entry = {} 

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

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

326 entry[header] = value 

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