Coverage for netbox_interface_name_rules / family / installed.py: 98.57%

110 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"""Discover and plan installed interface families.""" 

4 

5from dcim.models import Interface 

6 

7from ..name_template import evaluate_name_template 

8from .domain import ( 

9 FamilyStatus, 

10 FamilyTopology, 

11 InstalledFamilyPlan, 

12 InstalledFamilyPlanSet, 

13 InterfaceSnapshot, 

14 MemberRole, 

15 PlannedMember, 

16) 

17from .raw_bases import GivenRawNames, RawBases 

18from .targets import ( 

19 UNCLAIMED_BASE_REASON, 

20 builds_flat_family, 

21 channelized_family_targets, 

22 flat_family_names, 

23 lockstep_family_targets, 

24 template_channel_suffixes, 

25) 

26from .template_names import TemplateNames 

27 

28 

29def is_plain_interface(interface) -> bool: 

30 """Return whether *interface* can be a flat-family member.""" 

31 return ( 

32 getattr(interface, "parent_id", None) is None 

33 and getattr(interface, "channel_id", None) is None 

34 and getattr(interface, "channels", None) is None 

35 ) 

36 

37 

38def is_channelized_parent(interface) -> bool: 

39 """Return whether *interface* declares a channelized family.""" 

40 return getattr(interface, "channels", None) is not None 

41 

42 

43def _is_channel(interface) -> bool: 

44 """Return whether *interface* is bound to a parent channel.""" 

45 return getattr(interface, "channel_id", None) is not None 

46 

47 

48def _flat_plan(module, target_names, interfaces, status=None, reason=""): 

49 """Build one immutable plan for the flat family whose rows are *interfaces*.""" 

50 members = tuple( 

51 PlannedMember( 

52 snapshot=InterfaceSnapshot.from_interface(interface), 

53 target_name=target_name, 

54 role=MemberRole.FLAT_MEMBER, 

55 ) 

56 for interface, target_name in zip(interfaces, target_names, strict=True) 

57 ) 

58 return InstalledFamilyPlan( 

59 family_id=f"flat:{members[0].snapshot.pk}", 

60 topology=FamilyTopology.FLAT, 

61 device_id=module.device_id, 

62 module_id=module.pk, 

63 members=members, 

64 precondition_status=status, 

65 precondition_reason=reason, 

66 ) 

67 

68 

69def _claimed_flat_plan(module, rule, variables, family, interfaces): 

70 """Return the plan for a claimed flat family, or None when its first row builds it again (ADR 0013).""" 

71 kept = tuple(interface.name for interface in interfaces) 

72 try: 

73 target_names = flat_family_names(rule, variables, family.base_name) 

74 except (TypeError, ValueError) as error: 

75 return _flat_plan( 

76 module, kept, interfaces, FamilyStatus.FAILED, f"failed to evaluate the family names: {error}" 

77 ) 

78 if len(interfaces) == len(family.names): 

79 return _flat_plan(module, target_names, interfaces) 

80 if target_names == family.names: 

81 return None 

82 missing = len(family.names) - len(interfaces) 

83 reason = f"the flat family is missing {missing} of its {len(family.names)} interfaces" 

84 return _flat_plan(module, kept, interfaces, FamilyStatus.BLOCKED, reason) 

85 

86 

87def _channelized_plan(device_id, module_id, parent, children, targets): 

88 """Build one plan for an existing channelized family from the names *targets* intends.""" 

89 members = [ 

90 PlannedMember( 

91 snapshot=InterfaceSnapshot.from_interface(parent), 

92 target_name=targets.parent_name, 

93 role=MemberRole.PARENT, 

94 ) 

95 ] 

96 members.extend( 

97 PlannedMember( 

98 snapshot=InterfaceSnapshot.from_interface(child), 

99 target_name=target, 

100 role=MemberRole.CHANNEL, 

101 reason=reason, 

102 ) 

103 for child, (target, reason) in zip(children, targets.channels, strict=True) 

104 ) 

105 return InstalledFamilyPlan( 

106 family_id=f"channelized:{parent.pk}", 

107 topology=FamilyTopology.CHANNELIZED, 

108 device_id=device_id, 

109 module_id=module_id, 

110 members=tuple(members), 

111 parent_pk=parent.pk, 

112 precondition_status=targets.status, 

113 precondition_reason=targets.reason, 

114 ) 

115 

116 

117def _module_family_targets(rule, variables, parent, base_name, children, suffixes): 

118 """Return the names *rule* intends for a channelized family a module carries.""" 

119 return channelized_family_targets( 

120 rule, 

121 variables, 

122 parent.name, 

123 base_name, 

124 parent.channels, 

125 tuple((child.name, child.channel_id) for child in children), 

126 suffixes, 

127 ) 

128 

129 

130def _channelized_plans(module, rule, variables, interfaces, bases): 

131 """Return one plan for every structurally discovered channelized family.""" 

132 parents = [interface for interface in interfaces if is_channelized_parent(interface)] 

133 if not parents: 

134 return [] 

135 children_by_parent: dict[int, list] = {} 

136 for interface in interfaces: 

137 if _is_channel(interface) and interface.parent_id is not None: 

138 children_by_parent.setdefault(interface.parent_id, []).append(interface) 

139 suffixes = template_channel_suffixes(bases.catalog.get()) 

140 plans = [] 

141 for parent in parents: 

142 children = children_by_parent.get(parent.pk, []) 

143 children.sort(key=lambda child: (child.channel_id, child.pk)) 

144 targets = _module_family_targets(rule, variables, parent, bases.base_for(parent.name), children, suffixes) 

145 plans.append(_channelized_plan(module.device_id, module.pk, parent, children, targets)) 

146 return plans 

147 

148 

149def interfaces_by_module(modules): 

150 """Load every interface of a module batch in one query, in stable per-module order.""" 

151 by_module: dict[int, list] = {module.pk: [] for module in modules} 

152 if not by_module: 152 ↛ 153line 152 didn't jump to line 153 because the condition on line 152 was never true

153 return by_module 

154 rows = Interface.objects.filter(module_id__in=list(by_module)).order_by("module_id", "name") 

155 for interface in rows: 

156 by_module[interface.module_id].append(interface) 

157 return by_module 

158 

159 

160def device_interface_families(interfaces): 

161 """Return each base interface outside a channel with its channel children, in channel order.""" 

162 children_by_parent: dict[int, list] = {} 

163 for interface in interfaces: 

164 if _is_channel(interface) and interface.parent_id is not None: 

165 children_by_parent.setdefault(interface.parent_id, []).append(interface) 

166 for children in children_by_parent.values(): 

167 children.sort(key=lambda child: (child.channel_id, child.pk)) 

168 return tuple( 

169 (interface, tuple(children_by_parent.get(interface.pk, ()))) 

170 for interface in interfaces 

171 if not _is_channel(interface) 

172 ) 

173 

174 

175def plan_kept_interface(module, interface, reason) -> InstalledFamilyPlan: 

176 """Return a plan that keeps the name of one interface of *module* and reports *reason*.""" 

177 return InstalledFamilyPlan( 

178 family_id=f"flat:{interface.pk}", 

179 topology=FamilyTopology.FLAT, 

180 device_id=module.device_id, 

181 module_id=module.pk, 

182 members=( 

183 PlannedMember( 

184 snapshot=InterfaceSnapshot.from_interface(interface), 

185 target_name=interface.name, 

186 role=MemberRole.FLAT_MEMBER, 

187 ), 

188 ), 

189 precondition_status=FamilyStatus.BLOCKED, 

190 precondition_reason=reason, 

191 ) 

192 

193 

194def _interface_rename_plan(device_id, module_id, rule, variables, interface, base_name) -> InstalledFamilyPlan: 

195 """Return a plan that renames one interface which belongs to no family, from *base_name* as ``{base}``.""" 

196 status, reason, target_name = None, "", interface.name 

197 if base_name is None: 

198 status, reason = FamilyStatus.BLOCKED, UNCLAIMED_BASE_REASON 

199 else: 

200 try: 

201 target_name = evaluate_name_template(rule.name_template, {**variables, "base": base_name}) 

202 except (TypeError, ValueError) as error: 

203 status, reason = FamilyStatus.FAILED, f"failed to evaluate the interface name: {error}" 

204 return InstalledFamilyPlan( 

205 family_id=f"flat:{interface.pk}", 

206 topology=FamilyTopology.FLAT, 

207 device_id=device_id, 

208 module_id=module_id, 

209 members=( 

210 PlannedMember( 

211 snapshot=InterfaceSnapshot.from_interface(interface), 

212 target_name=target_name, 

213 role=MemberRole.FLAT_MEMBER, 

214 ), 

215 ), 

216 precondition_status=status, 

217 precondition_reason=reason, 

218 ) 

219 

220 

221def plan_interface_rename(module, rule, variables, interface, bases) -> InstalledFamilyPlan: 

222 """Return the plan that renames one module interface which belongs to no family.""" 

223 return _interface_rename_plan( 

224 module.device_id, 

225 module.pk, 

226 rule, 

227 variables, 

228 interface, 

229 bases.base_for(interface.name), 

230 ) 

231 

232 

233def plan_device_interface_rename(device, rule, variables, interface, children=()) -> InstalledFamilyPlan: 

234 """Return the plan that renames one device-level interface family.""" 

235 if not children and not is_channelized_parent(interface): 

236 return _interface_rename_plan(device.pk, None, rule, variables, interface, interface.name) 

237 # A device rule never builds a family, so its channel count says nothing about this one: the 

238 # members keep the suffixes they carry under whatever name the parent takes. A device-level 

239 # interface has no module template family, so there is no suffix to recover from one either. 

240 targets = lockstep_family_targets( 

241 rule, variables, interface.name, interface.name, tuple((child.name, child.channel_id) for child in children), {} 

242 ) 

243 return _channelized_plan(device.pk, None, interface, children, targets) 

244 

245 

246def module_raw_bases( 

247 module, rule, variables, interfaces, previous_forms=None, catalog=None, earlier_raw_names=None 

248) -> RawBases: 

249 """Return the claim over the names of *module*'s interfaces outside a channel. 

250 

251 *previous_forms* holds what named the templates before a move, and *earlier_raw_names* the raw 

252 names the templates gave earlier; see ``RawBases``. *catalog* has ``get()`` for the resolved 

253 templates, and reads the module's templates when it is not given. 

254 """ 

255 families = { 

256 interface.name: tuple((child.name, child.channel_id) for child in children) 

257 for interface, children in device_interface_families(interfaces) 

258 } 

259 plain = [interface.name for interface in interfaces if is_plain_interface(interface)] 

260 catalog = TemplateNames(module) if catalog is None else catalog 

261 return RawBases(module, rule, variables, families, plain, catalog, previous_forms, earlier_raw_names) 

262 

263 

264def given_raw_names(module, rule, variables, names) -> GivenRawNames: 

265 """Return bases for *names* a caller gives as the module's raw template names, without channels.""" 

266 return GivenRawNames(RawBases(module, rule, variables, dict.fromkeys(names, ()), names, TemplateNames(module))) 

267 

268 

269def plan_installed_flat_families(module, rule, variables, interfaces, bases) -> list[InstalledFamilyPlan]: 

270 """Return a plan for each flat family that one template alone claims, when *rule* is a flat breakout rule. 

271 

272 A move recognises no flat family (ADR 0015). 

273 """ 

274 if not builds_flat_family(rule): 

275 return [] 

276 plain = {interface.name: interface for interface in interfaces if is_plain_interface(interface)} 

277 plans = ( 

278 _claimed_flat_plan( 

279 module, rule, variables, family, tuple(plain[name] for name in family.names if name in plain) 

280 ) 

281 for family in bases.flat_families() 

282 ) 

283 return [plan for plan in plans if plan is not None] 

284 

285 

286def half_built_members(rule, interfaces, bases) -> dict: 

287 """Return, by the name of its first row, the other rows of each half-built flat family the claim accepted.""" 

288 if not builds_flat_family(rule): 

289 return {} 

290 rows = {interface.name: interface for interface in interfaces if is_plain_interface(interface)} 

291 return { 

292 family.names[0]: tuple(rows[name] for name in family.names[1:] if name in rows) 

293 for family in bases.flat_families() 

294 } 

295 

296 

297def plan_installed_families(module, rule, variables) -> InstalledFamilyPlanSet: 

298 """Return immutable plans for the installed families owned by *module*, reading its interfaces.""" 

299 interfaces = list(Interface.objects.filter(module_id=module.pk).order_by("pk")) 

300 return plan_installed_families_from( 

301 module, rule, variables, interfaces, module_raw_bases(module, rule, variables, interfaces) 

302 ) 

303 

304 

305def plan_installed_families_from(module, rule, variables, interfaces, bases) -> InstalledFamilyPlanSet: 

306 """Return the installed family plans for *module*, reading ``{base}`` and templates from *bases*. 

307 

308 A batch that already holds the module's interface rows passes them in, so planning a fleet 

309 reads them once rather than once per module. 

310 """ 

311 plans = _channelized_plans(module, rule, variables, interfaces, bases) 

312 plans.extend(plan_installed_flat_families(module, rule, variables, interfaces, bases)) 

313 plans.sort(key=lambda plan: plan.member_pks[0]) 

314 return InstalledFamilyPlanSet(module_id=module.pk, plans=tuple(plans))