Coverage for netbox_interface_name_rules / family / conversion.py: 96.15%

26 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-10-16 10:13 +0000

1# SPDX-License-Identifier: Apache-2.0 

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

3"""Convert installed flat breakout families into the channelized topology. 

4 

5An earlier flat apply leaves N sibling interfaces where NetBox 4.7+ models a channelized parent 

6with N channel subinterfaces. Converting one rewrites rows an operator owns (cables, addresses, 

7tags), so it is never a side effect of applying a rule: the operator confirms it per family. 

8 

9A family is identified by its ch-0 row, the way the flat apply that installed it named that row. 

10A family whose siblings the module no longer carries whole is still offered, and refused with the 

11row it is missing: silence would read as "nothing here to convert". 

12""" 

13 

14import logging 

15from dataclasses import replace 

16 

17from dcim.choices import InterfaceTypeChoices 

18from dcim.models import Interface 

19from django.core.exceptions import ValidationError 

20from django.db import IntegrityError 

21 

22from ..naming import build_variables 

23from ..transactions import atomic_with_events 

24from .batch import BatchOutcome 

25from .capabilities import supports_channelization 

26from .domain import ( 

27 ConversionCandidate, 

28 ConversionMember, 

29 ConversionPlan, 

30 ConversionPreview, 

31 FamilyOutcome, 

32 FamilyStatus, 

33 FamilyTopology, 

34 InterfaceSnapshot, 

35 MemberOutcome, 

36) 

37from .installed import interfaces_by_module, module_raw_bases 

38from .names import COLLISION_REASON, is_name_collision, name_is_taken, validate_or_block 

39from .targets import channelized_family_names, used_parent_template 

40from .template_names import pinned_template_cache 

41 

42logger = logging.getLogger(__name__) 

43 

44STALE_REASON = "the flat family changed after it was scanned" 

45INCOMPLETE_REASON = "this module carries no complete flat family" 

46UNSUPPORTED_REASON = "this NetBox release cannot model channelized interfaces" 

47TEMPLATE_ERROR_REASON = "the parent name template does not resolve for this module" 

48 

49 

50def conversion_offered(rule) -> bool: 

51 """Return whether *rule* describes a topology an installed flat family could be converted into. 

52 

53 A disabled rule renames nothing on any apply path, so it converts nothing either. A flat 

54 family has no parent row (its ch-0 interface *is* the base), so without a parent name there is 

55 nowhere for that base to go and the conversion is not offered at all. 

56 """ 

57 return rule.enabled and bool(used_parent_template(rule)) 

58 

59 

60# --------------------------------------------------------------------------- 

61# Planning 

62# --------------------------------------------------------------------------- 

63 

64 

65def _conversion_plan( 

66 module, parent_name, channel_names, rows, channelization_supported 

67): # pragma: no cover - channelization only 

68 """Build one immutable conversion plan from the rows a module carries for one family.""" 

69 base, *siblings = rows 

70 plan = ConversionPlan( 

71 family_id=f"conversion:{base.pk}", 

72 device_id=module.device_id, 

73 module_id=module.pk, 

74 base=InterfaceSnapshot.from_interface(base), 

75 parent_target_name=parent_name, 

76 channel_names=channel_names, 

77 siblings=tuple( 

78 ConversionMember(snapshot=InterfaceSnapshot.from_interface(sibling), channel_id=channel_id) 

79 for channel_id, sibling in siblings 

80 ), 

81 ) 

82 if not channelization_supported: 

83 return replace( 

84 plan, 

85 precondition_status=FamilyStatus.UNSUPPORTED, 

86 precondition_reason=UNSUPPORTED_REASON, 

87 ) 

88 missing = plan.missing_names 

89 if not missing: 

90 return plan 

91 reason = f"{missing[0]!r} is missing: {INCOMPLETE_REASON}" 

92 return replace(plan, precondition_status=FamilyStatus.BLOCKED, precondition_reason=reason) 

93 

94 

95def _family_rows(by_name, channel_names): # pragma: no cover - requires channelization support 

96 """Return the ch-0 row and every sibling row this module still carries, with its channel id. 

97 

98 A sibling is taken whatever it has become, so a row that now belongs to another parent is 

99 reported against this family instead of quietly leaving a gap where it used to be. 

100 """ 

101 siblings = [ 

102 (channel_id, by_name[name]) for channel_id, name in enumerate(channel_names[1:], start=2) if name in by_name 

103 ] 

104 return [by_name[channel_names[0]], *siblings] 

105 

106 

107def plan_module_conversions( 

108 module, rule, variables, interfaces 

109) -> tuple[ConversionPlan, ...]: # pragma: no cover - requires channelization support 

110 """Return one plan for every flat family *rule* names on *module*, complete or not. 

111 

112 A family converts only when one template alone claims it. The parent takes the name the rule 

113 resolves for this module now, so a family named before a virtual-chassis renumber converts to the 

114 parent an apply would give it; the channels keep the names they already carry, because converting 

115 retypes those rows in place. 

116 """ 

117 bases = module_raw_bases(module, rule, variables, interfaces) 

118 by_name = {interface.name: interface for interface in interfaces} 

119 channelization_supported = supports_channelization() 

120 plans = [] 

121 for family in bases.flat_families(): 

122 rows = _family_rows(by_name, family.names) 

123 try: 

124 parent_name, _channels = channelized_family_names(rule, family.base_name, family.base_name, variables) 

125 except (TypeError, ValueError) as exc: 

126 # One family that cannot resolve must not lose the outcome the batch already accumulated. 

127 plan = _conversion_plan(module, "", family.names, rows, channelization_supported) 

128 if channelization_supported: 

129 plan = replace( 

130 plan, 

131 precondition_status=FamilyStatus.FAILED, 

132 precondition_reason=f"{TEMPLATE_ERROR_REASON}: {exc}", 

133 ) 

134 plans.append(plan) 

135 continue 

136 plans.append(_conversion_plan(module, parent_name, family.names, rows, channelization_supported)) 

137 return tuple(plans) 

138 

139 

140def _planned_families(rule, modules): # pragma: no cover - requires channelization support 

141 """Yield every convertible family in the batch as ``(module, plan, base interface)``. 

142 

143 The module rows, their interfaces and each module type's templates are read once for the whole 

144 batch, so a second module of the same type costs the scan no extra query. 

145 """ 

146 by_module = interfaces_by_module(modules) 

147 for module in modules: 

148 interfaces = by_module[module.pk] 

149 rows_by_pk = {interface.pk: interface for interface in interfaces} 

150 variables = build_variables(module.module_bay, device=module.device) 

151 for plan in plan_module_conversions(module, rule, variables, interfaces): 

152 yield module, plan, rows_by_pk[plan.base.pk] 

153 

154 

155# --------------------------------------------------------------------------- 

156# Execution 

157# --------------------------------------------------------------------------- 

158 

159 

160def _outcome(plan, status, members, reason=""): # pragma: no cover - requires channelization support 

161 """Build the immutable outcome of one conversion.""" 

162 return FamilyOutcome( 

163 family_id=plan.family_id, 

164 topology=FamilyTopology.CHANNELIZED, 

165 status=status, 

166 members=members, 

167 reason=reason, 

168 ) 

169 

170 

171def _refused(plan, status, reason): # pragma: no cover - requires channelization support 

172 """Log why the family was not converted and return an outcome that touched no row.""" 

173 logger.warning( 

174 "Cannot convert the flat family of interface %r (device %s, module %s) into the channelized " 

175 "parent %r: %s. Skipping.", 

176 plan.base.name, 

177 plan.device_id, 

178 plan.module_id, 

179 plan.parent_target_name, 

180 reason, 

181 ) 

182 targets = (plan.parent_target_name, *plan.sibling_target_names) 

183 members = tuple( 

184 MemberOutcome( 

185 interface_pk=pk, 

186 current_name=current_name, 

187 target_name=target_name, 

188 status=status, 

189 reason=reason, 

190 ) 

191 for pk, current_name, target_name in zip(plan.member_pks, plan.current_names, targets, strict=True) 

192 ) 

193 return _outcome(plan, status, members, reason) 

194 

195 

196def _locked_family(plan): # pragma: no cover - requires channelization support 

197 """Lock every planned row for the transaction and return the live rows by primary key.""" 

198 return { 

199 interface.pk: interface 

200 for interface in ( 

201 Interface.objects.select_for_update(of=("self",)) # module is nullable, so locking the join is refused 

202 .select_related("device", "module") 

203 .filter(pk__in=plan.member_pks) 

204 .order_by("pk") 

205 ) 

206 } 

207 

208 

209def _is_stale(plan, live) -> bool: # pragma: no cover - requires channelization support 

210 """Return whether live identity, names, membership or topology changed since the scan.""" 

211 planned = {plan.base.pk: plan.base, **{member.snapshot.pk: member.snapshot for member in plan.siblings}} 

212 return planned != {pk: InterfaceSnapshot.from_interface(interface) for pk, interface in live.items()} 

213 

214 

215def _blocking_reason(plan, live) -> str: # pragma: no cover - requires channelization support 

216 """Return why this family cannot become a channelized family, or an empty string. 

217 

218 Only what upstream cannot decide for us is checked here; everything else is left to 

219 ``full_clean()`` on each prospective row, which inherits upstream's rules as they grow. 

220 """ 

221 if name_is_taken(plan.device_id, plan.parent_target_name, exclude_pk=plan.base.pk): 

222 return f"the parent name {plan.parent_target_name!r} is already taken on this device" 

223 for member in plan.siblings: 

224 sibling = live[member.snapshot.pk] 

225 # Rebinding it validates cleanly, so only this check keeps the other family whole. 

226 if sibling.channel_id is not None: 

227 owner = sibling.parent.name if sibling.parent_id else "another parent" 

228 return ( 

229 f"{sibling.name!r} is already channel {sibling.channel_id} of {owner}; " 

230 f"converting would take it out of that family" 

231 ) 

232 # NetBox refuses a channel_id on a row with a cable; this check names the row first. 

233 if sibling.cable_id: 

234 return f"{sibling.name!r} has a cable attached; a channel takes its cable from the parent" 

235 return "" 

236 

237 

238def _carry_assignments(base, channel): # pragma: no cover - requires channelization support 

239 """Move the ch-0 row's addresses and first-hop groups onto the channel that took its name. 

240 

241 Saved one row at a time so each carried object is validated, and recorded in the changelog, 

242 like every other row this conversion writes. A model save writes back every field it read, so 

243 each row is locked first: an edit landing between the read and the save would otherwise be 

244 overwritten by the values this transaction started with. 

245 """ 

246 addresses = base.ip_addresses.select_for_update().order_by("pk") 

247 for address in addresses: 

248 address.snapshot() 

249 address.assigned_object = channel 

250 address.full_clean() 

251 address.save() 

252 assignments = base.fhrp_group_assignments.select_for_update().order_by("pk") 

253 for assignment in assignments: 

254 assignment.snapshot() 

255 assignment.interface = channel 

256 assignment.full_clean() 

257 assignment.save() 

258 

259 

260def _split_base(plan, base): # pragma: no cover - requires channelization support 

261 """Make *base* the family parent and move its logical identity onto a new channel-1 row. 

262 

263 Everything an operator configured on the ch-0 row described a channel, not the cage carrying 

264 it, so addresses, VLANs, MTU, description and tags move; custom fields can mean either thing 

265 and are copied. The physical row keeps its pk, cable, type, module link and mark_connected. 

266 """ 

267 base.snapshot() 

268 carried = { 

269 "description": base.description, 

270 "mtu": base.mtu, 

271 "mode": base.mode, 

272 "untagged_vlan_id": base.untagged_vlan_id, 

273 "vrf_id": base.vrf_id, 

274 } 

275 tagged_vlans = list(base.tagged_vlans.all()) 

276 tags = list(base.tags.all()) 

277 channel_name = base.name 

278 

279 base.name = plan.parent_target_name 

280 base.channels = plan.channel_count 

281 base.description = "" 

282 base.mtu = None 

283 base.mode = "" 

284 base.untagged_vlan = None 

285 base.vrf = None 

286 validate_or_block(base, "parent") 

287 base.save() # BaseInterface.save() drops the tagged VLANs of a row that no longer tags 

288 base.snapshot() 

289 base.tags.clear() 

290 

291 channel = Interface( 

292 device=base.device, 

293 module=base.module, 

294 name=channel_name, 

295 type=InterfaceTypeChoices.TYPE_CHANNEL, 

296 parent=base, 

297 channel_id=1, 

298 enabled=base.enabled, 

299 custom_field_data=dict(base.custom_field_data or {}), 

300 **carried, 

301 ) 

302 validate_or_block(channel, "channel") 

303 channel.save() 

304 channel.tagged_vlans.set(tagged_vlans) 

305 channel.tags.set(tags) 

306 _carry_assignments(base, channel) 

307 return channel 

308 

309 

310def _rewrite(plan, live): # pragma: no cover - requires channelization support 

311 """Rewrite the whole family and return one outcome per row it wrote.""" 

312 base = live[plan.base.pk] 

313 previous_name = base.name 

314 channel = _split_base(plan, base) 

315 members = [ 

316 MemberOutcome(base.pk, previous_name, plan.parent_target_name, FamilyStatus.CHANGED), 

317 MemberOutcome(channel.pk, channel.name, channel.name, FamilyStatus.CHANGED), 

318 ] 

319 for member in plan.siblings: 

320 sibling = live[member.snapshot.pk] 

321 sibling.snapshot() 

322 sibling.type = InterfaceTypeChoices.TYPE_CHANNEL 

323 sibling.parent = base 

324 sibling.channel_id = member.channel_id 

325 validate_or_block(sibling, "channel") 

326 sibling.save() 

327 members.append(MemberOutcome(sibling.pk, sibling.name, sibling.name, FamilyStatus.CHANGED)) 

328 return tuple(members) 

329 

330 

331def _convert(plan, commit): # pragma: no cover - requires channelization support 

332 """Convert one family inside a single transaction, or leave every row exactly as it was. 

333 

334 A dry run (*commit* False) and a family that turns out to be unconvertible both roll the whole 

335 rewrite back, so a family is never half converted and a scan writes nothing at all. 

336 """ 

337 try: 

338 with atomic_with_events() as block: 

339 live = _locked_family(plan) 

340 if _is_stale(plan, live): 

341 return _refused(plan, FamilyStatus.STALE, STALE_REASON) 

342 reason = _blocking_reason(plan, live) 

343 if reason: 

344 return _refused(plan, FamilyStatus.BLOCKED, reason) 

345 members = _rewrite(plan, live) 

346 if not commit: 

347 block.set_rollback() 

348 except ValidationError as error: 

349 return _refused(plan, FamilyStatus.BLOCKED, " ".join(error.messages)) 

350 except IntegrityError as error: 

351 if not is_name_collision(error): 

352 raise 

353 return _refused(plan, FamilyStatus.BLOCKED, COLLISION_REASON) 

354 return _outcome(plan, FamilyStatus.CHANGED, members) 

355 

356 

357def _dry_run(plan) -> FamilyOutcome: # pragma: no cover - requires channelization support 

358 """Report what converting *plan* would do, having written and rolled back every row of it.""" 

359 if plan.precondition_status is not None: 

360 return _refused(plan, plan.precondition_status, plan.precondition_reason) 

361 return _convert(plan, commit=False) 

362 

363 

364def execute_conversion(plan: ConversionPlan) -> FamilyOutcome: # pragma: no cover - requires channelization support 

365 """Convert the planned flat family, or leave every row exactly as it was. 

366 

367 Only a conversion plan names the family rows to rewrite, so anything else (a prospective plan 

368 above all) is refused before a single row is locked. 

369 """ 

370 if not isinstance(plan, ConversionPlan): 

371 raise TypeError(f"{type(plan).__name__} is not an executable conversion plan") 

372 if plan.precondition_status is not None: 

373 return _refused(plan, plan.precondition_status, plan.precondition_reason) 

374 return _convert(plan, commit=True) 

375 

376 

377# --------------------------------------------------------------------------- 

378# Batch entry points 

379# --------------------------------------------------------------------------- 

380 

381 

382def preview_rule_conversions(rule, modules, limit=None) -> ConversionPreview: 

383 """Return what converting each of *rule*'s flat families would do, convertible or not. 

384 

385 Nothing is written: every family is converted inside a savepoint that is rolled back again, so 

386 each candidate carries the reason NetBox itself would refuse the family rather than a guess at 

387 its rules. That dry run is what the scan costs, and a blocked family costs it too, so *limit* 

388 caps the families examined rather than the convertible ones among them. 

389 """ 

390 if not conversion_offered(rule): 

391 return ConversionPreview(candidates=()) 

392 return _preview(rule, modules, limit) # pragma: no cover - requires channelization support 

393 

394 

395def _preview(rule, modules, limit) -> ConversionPreview: # pragma: no cover - requires channelization support 

396 """Dry-run at most *limit* of the rule's flat families; see ``preview_rule_conversions``.""" 

397 candidates = [] 

398 with pinned_template_cache(modules): 

399 for module, plan, base in _planned_families(rule, modules): 

400 if limit is not None and len(candidates) >= limit: 

401 return ConversionPreview(candidates=tuple(candidates), has_more=True) 

402 outcome = _dry_run(plan) 

403 candidates.append(ConversionCandidate(plan=plan, module=module, interface=base, reason=outcome.reason)) 

404 return ConversionPreview(candidates=tuple(candidates)) 

405 

406 

407def convert_rule_families(rule, modules, selected_pks=None) -> BatchOutcome: 

408 """Convert the confirmed flat families of *rule*; return one explicit outcome per family. 

409 

410 *selected_pks* is the set of ch-0 interface primary keys the operator confirmed: ``None`` 

411 converts every convertible family (the batch the background job runs), an empty collection 

412 converts none. A family that cannot be converted is reported and passed over, so it is never 

413 half converted and never costs the rest of the batch. 

414 """ 

415 return _convert_families(rule, modules, selected_pks) # pragma: no cover - see above 

416 

417 

418def _convert_families(rule, modules, selected_pks) -> BatchOutcome: # pragma: no cover - channelization only 

419 """Convert the confirmed flat families of *rule*; see ``convert_rule_families``.""" 

420 if not conversion_offered(rule) or (selected_pks is not None and not selected_pks): 

421 return BatchOutcome(families=()) 

422 families = [] 

423 with pinned_template_cache(modules): 

424 for _module, plan, _base in _planned_families(rule, modules): 

425 if selected_pks is not None and plan.base.pk not in selected_pks: 

426 continue 

427 families.append(execute_conversion(plan)) 

428 return BatchOutcome(families=tuple(families))