Coverage for netbox_interface_name_rules / family / execution.py: 100.00%

80 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"""Execute installed interface-family plans.""" 

4 

5import logging 

6 

7from dcim.models import Interface 

8from django.core.exceptions import ValidationError 

9from django.db import IntegrityError 

10from django.db.models import Q 

11 

12from ..transactions import atomic_with_events 

13from .domain import ( 

14 FamilyOutcome, 

15 FamilyStatus, 

16 InstalledFamilyPlan, 

17 InstalledFamilyPlanSet, 

18 InstalledPlanSetOutcome, 

19 InterfaceSnapshot, 

20 MemberOutcome, 

21 MemberRole, 

22) 

23from .names import COLLISION_REASON, is_name_collision, name_is_taken, reconcile_after_parent_cascade 

24 

25logger = logging.getLogger(__name__) 

26 

27STALE_REASON = "installed family changed after planning" 

28PARENT_BLOCKED_REASON = "family parent was blocked" 

29 

30 

31def _lock_family(plan: InstalledFamilyPlan): 

32 """Lock and return the current family rows in stable primary-key order.""" 

33 queryset = Interface.objects 

34 if plan.parent_pk is None: 

35 queryset = queryset.filter(pk__in=plan.member_pks) 

36 else: # pragma: no cover - requires channelization support 

37 # Only channels are family members; a virtual subinterface may also parent here. 

38 queryset = queryset.filter(Q(pk=plan.parent_pk) | Q(parent_id=plan.parent_pk, channel_id__isnull=False)) 

39 return list(queryset.select_for_update().order_by("pk")) 

40 

41 

42def _is_stale(plan: InstalledFamilyPlan, interfaces) -> bool: 

43 """Return whether live identity, names, membership, or topology changed.""" 

44 planned = {member.snapshot.pk: member.snapshot for member in plan.members} 

45 live = {interface.pk: InterfaceSnapshot.from_interface(interface) for interface in interfaces} 

46 return planned != live 

47 

48 

49def _member_outcome(member, status, reason=""): 

50 """Build one immutable member outcome from its plan facts.""" 

51 return MemberOutcome( 

52 interface_pk=member.snapshot.pk, 

53 current_name=member.snapshot.name, 

54 target_name=member.target_name, 

55 status=status, 

56 reason=reason, 

57 ) 

58 

59 

60def _blocked_member(member, reason): 

61 """Return a blocked member outcome.""" 

62 return _member_outcome(member, FamilyStatus.BLOCKED, reason) 

63 

64 

65def _rename_member(member, interface): 

66 """Apply one planned name inside a savepoint and return its explicit outcome.""" 

67 target_name = member.target_name 

68 if target_name is None: 

69 logger.warning( 

70 "Cannot derive a name for channel interface %r; leaving it unchanged.", 

71 member.snapshot.name, 

72 ) 

73 return _blocked_member(member, member.reason) 

74 if target_name == interface.name: 

75 return _member_outcome(member, FamilyStatus.UNCHANGED) 

76 if name_is_taken(interface.device_id, target_name, exclude_pk=interface.pk): 

77 logger.warning( 

78 "Interface name %r already exists on device %s; skipping rename of %r to %r.", 

79 target_name, 

80 interface.device_id, 

81 interface.name, 

82 target_name, 

83 ) 

84 return _blocked_member(member, COLLISION_REASON) 

85 

86 previous_name = interface.name 

87 interface.snapshot() 

88 try: 

89 with atomic_with_events(): 

90 interface.name = target_name 

91 interface.full_clean() 

92 interface.save() 

93 except ValidationError as error: 

94 interface.name = previous_name 

95 logger.warning("NetBox rejected rename of %r to %r: %s", previous_name, target_name, error) 

96 return _blocked_member(member, " ".join(error.messages)) 

97 except IntegrityError as error: 

98 interface.name = previous_name 

99 if is_name_collision(error): 

100 logger.warning( 

101 "Interface name %r became occupied while renaming %r; skipping.", 

102 target_name, 

103 previous_name, 

104 ) 

105 return _blocked_member(member, COLLISION_REASON) 

106 raise 

107 return _member_outcome(member, FamilyStatus.CHANGED) 

108 

109 

110def _family_status(members): 

111 """Summarize member outcomes without hiding partial success.""" 

112 statuses = {member.status for member in members} 

113 if FamilyStatus.CHANGED in statuses: 

114 return FamilyStatus.CHANGED 

115 if FamilyStatus.BLOCKED in statuses: 

116 return FamilyStatus.BLOCKED 

117 return FamilyStatus.UNCHANGED 

118 

119 

120def _preserve_names_across_parent_cascade(plan, member_outcomes): # pragma: no cover - channelization only 

121 """Schedule restoration of blocked conventional channel names after a parent cascade.""" 

122 if plan.parent_pk is None: 

123 return 

124 parent_member = next(member for member in plan.members if member.role == MemberRole.PARENT) 

125 parent_outcome = next(outcome for outcome in member_outcomes if outcome.interface_pk == plan.parent_pk) 

126 if parent_outcome.status != FamilyStatus.CHANGED: 

127 return 

128 outcomes_by_pk = {outcome.interface_pk: outcome for outcome in member_outcomes} 

129 channels = tuple( 

130 ( 

131 member.snapshot.pk, 

132 member.snapshot.channel_id, 

133 member.target_name 

134 if outcomes_by_pk[member.snapshot.pk].status in (FamilyStatus.CHANGED, FamilyStatus.UNCHANGED) 

135 else member.snapshot.name, 

136 ) 

137 for member in plan.members 

138 if member.role == MemberRole.CHANNEL 

139 ) 

140 reconcile_after_parent_cascade(parent_member.snapshot.name, parent_member.target_name, channels) 

141 

142 

143def _execute_channelized_members(plan, live_by_pk): # pragma: no cover - requires channelization support 

144 """Execute a channelized family after its parent succeeds.""" 

145 parent_member = next(member for member in plan.members if member.role == MemberRole.PARENT) 

146 parent_outcome = _rename_member(parent_member, live_by_pk[parent_member.snapshot.pk]) 

147 if parent_outcome.status == FamilyStatus.BLOCKED: 

148 return ( 

149 parent_outcome, 

150 *( 

151 _blocked_member(member, PARENT_BLOCKED_REASON) 

152 for member in plan.members 

153 if member.role != MemberRole.PARENT 

154 ), 

155 ) 

156 return ( 

157 parent_outcome, 

158 *( 

159 _rename_member(member, live_by_pk[member.snapshot.pk]) 

160 for member in plan.members 

161 if member.role != MemberRole.PARENT 

162 ), 

163 ) 

164 

165 

166def _execute_members(plan, live_by_pk): 

167 """Execute members while enforcing parent-first family semantics.""" 

168 if plan.parent_pk is None: 

169 return tuple(_rename_member(member, live_by_pk[member.snapshot.pk]) for member in plan.members) 

170 return _execute_channelized_members(plan, live_by_pk) # pragma: no cover - channelization only 

171 

172 

173def _stale_outcome(plan: InstalledFamilyPlan) -> FamilyOutcome: 

174 """Return a stale result without applying any planned member.""" 

175 return FamilyOutcome( 

176 family_id=plan.family_id, 

177 topology=plan.topology, 

178 status=FamilyStatus.STALE, 

179 members=tuple(_member_outcome(member, FamilyStatus.STALE, STALE_REASON) for member in plan.members), 

180 reason=STALE_REASON, 

181 ) 

182 

183 

184def execute_installed_plan(plan: InstalledFamilyPlan) -> FamilyOutcome: 

185 """Execute one family plan in its own transaction.""" 

186 with atomic_with_events(): 

187 interfaces = _lock_family(plan) 

188 if _is_stale(plan, interfaces): 

189 return _stale_outcome(plan) 

190 if plan.precondition_status is not None: 

191 logger.warning( 

192 "Installed family of interface %r is %s: %s.", 

193 plan.members[0].snapshot.name, 

194 plan.precondition_status, 

195 plan.precondition_reason, 

196 ) 

197 return FamilyOutcome( 

198 family_id=plan.family_id, 

199 topology=plan.topology, 

200 status=plan.precondition_status, 

201 members=tuple( 

202 _member_outcome(member, plan.precondition_status, plan.precondition_reason) 

203 for member in plan.members 

204 ), 

205 reason=plan.precondition_reason, 

206 ) 

207 live_by_pk = {interface.pk: interface for interface in interfaces} 

208 member_outcomes = _execute_members(plan, live_by_pk) 

209 _preserve_names_across_parent_cascade(plan, member_outcomes) 

210 return FamilyOutcome( 

211 family_id=plan.family_id, 

212 topology=plan.topology, 

213 status=_family_status(member_outcomes), 

214 members=member_outcomes, 

215 ) 

216 

217 

218def execute_installed_plan_set(plan_set: InstalledFamilyPlanSet) -> InstalledPlanSetOutcome: 

219 """Execute each installed family in a separate transaction. 

220 

221 Only an installed plan set carries the row snapshots execution revalidates, so anything else 

222 (a prospective plan set above all) is refused before a single row is locked. 

223 """ 

224 if not isinstance(plan_set, InstalledFamilyPlanSet): 

225 raise TypeError(f"{type(plan_set).__name__} is not an executable plan set") 

226 return InstalledPlanSetOutcome(families=tuple(execute_installed_plan(plan) for plan in plan_set.plans))