Coverage for netbox_interface_name_rules / rule_selection.py: 98.31%

134 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"""Select an enabled interface-name rule for a module context.""" 

4 

5import contextlib 

6import threading 

7from dataclasses import dataclass 

8 

9from django.core.exceptions import ValidationError 

10from django.db.models import Aggregate, F, TextField, Value 

11from django.db.models.functions import Cast, Coalesce, Concat, Length 

12 

13from .regex_safety import compile_module_type_pattern 

14 

15 

16@dataclass(frozen=True, slots=True) 

17class _RuleSnapshot: 

18 """One enabled-rule set read from one alias; only the memo changes after publication.""" 

19 

20 alias: str | None 

21 version: str | None 

22 exact: tuple 

23 regex: tuple 

24 memo: dict 

25 

26 

27def _empty_rule_cache(): 

28 """Return a new snapshot that matches no alias, so the next read reloads.""" 

29 return _RuleSnapshot(alias=None, version=None, exact=(), regex=(), memo={}) 

30 

31 

32# A reload rebinds this to a new snapshot, so a reader never mixes entries from two versions. 

33_RULE_CACHE = _empty_rule_cache() 

34 

35# Bound the number of module and scope contexts retained for one rule-set version. 

36_MEMO_MAX = 4096 

37_MEMO_MISS = object() 

38 

39# A pinned batch owns a thread-local rule-set snapshot and a private memo. 

40_pin = threading.local() 

41 

42 

43@contextlib.contextmanager 

44def pinned_rule_cache(): 

45 """Pin one enabled-rule snapshot for all selections inside the block. 

46 

47 The first selection loads and fingerprints the rule set. Later selections in 

48 the same thread and from the same read alias skip the fingerprint query. 

49 Nested blocks share the snapshot. The pin is thread-local, and an empty block 

50 does not load rules. 

51 """ 

52 depth = getattr(_pin, "depth", 0) 

53 _pin.depth = depth + 1 

54 if depth == 0: 54 ↛ 56line 54 didn't jump to line 56 because the condition on line 54 was always true

55 _pin.primed = False 

56 try: 

57 yield 

58 finally: 

59 _pin.depth -= 1 

60 if _pin.depth == 0: 60 ↛ exitline 60 didn't return from function 'pinned_rule_cache' because the condition on line 60 was always true

61 _pin.primed = False 

62 for attr in ("alias", "exact", "regex", "memo"): 

63 _pin.__dict__.pop(attr, None) 

64 

65 

66def module_types_matching_pattern(pattern): 

67 """Return the model names *pattern* matches among the module types that exist now. 

68 

69 Compiles and full-matches exactly as selection does, so a pattern that returns nothing here 

70 also selects nothing when a module is installed. 

71 """ 

72 from dcim.models import ModuleType 

73 

74 compiled = compile_module_type_pattern(pattern) 

75 return tuple(model for model in ModuleType.objects.values_list("model", flat=True) if compiled.fullmatch(model)) 

76 

77 

78def _enabled_module_rules(): 

79 """Return the queryset of the enabled rules that module selection can pick. 

80 

81 Device-interface rows reuse module_type_pattern as an interface-name filter, so they are never module rules. 

82 """ 

83 from .models import InterfaceNameRule 

84 

85 return InterfaceNameRule.objects.filter(enabled=True, applies_to_device_interfaces=False) 

86 

87 

88def parent_type_scopes_a_rule(module_type_ids) -> bool: 

89 """Return whether an enabled module rule has one of *module_type_ids* as its parent module type. 

90 

91 One EXISTS query: the rule cache would load every rule during the save when it is not current. 

92 """ 

93 return _enabled_module_rules().filter(parent_module_type_id__in=module_type_ids).exists() 

94 

95 

96def compile_stored_pattern(pattern): 

97 """Compile a stored pattern once, or return None when RE2 rejects it.""" 

98 try: 

99 return compile_module_type_pattern(pattern) 

100 except ValidationError: 

101 return None 

102 

103 

104# These fields can change either matching or the selected rule's output. The row 

105# identity prevents compensating edits across two rules from preserving the hash. 

106_VERSION_COLUMNS = ( 

107 "id", 

108 "module_type_id", 

109 "module_type_is_regex", 

110 "module_type_pattern", 

111 "parent_module_type_id", 

112 "device_type_id", 

113 "platform_id", 

114 "name_template", 

115 "parent_name_template", 

116 "breakout_mode", 

117 "channel_count", 

118 "channel_start", 

119 "applies_to_device_interfaces", 

120) 

121 

122 

123class _Md5OrderedStringAgg(Aggregate): 

124 """Build ``md5(string_agg(<row>, <delimiter> ORDER BY id))``.""" 

125 

126 function = "STRING_AGG" 

127 template = "MD5(%(function)s(%(expressions)s ORDER BY id))" 

128 output_field = TextField() 

129 

130 

131def _version_row_signature(): 

132 """Build an unambiguous, length-prefixed signature for one rule row.""" 

133 empty = Value("", output_field=TextField()) 

134 colon = Value(":", output_field=TextField()) 

135 parts = [] 

136 for column in _VERSION_COLUMNS: 

137 cast = Cast(F(column), output_field=TextField()) 

138 value = Coalesce(cast, empty, output_field=TextField()) if column.endswith("_id") else cast 

139 parts.extend((Cast(Length(value), output_field=TextField()), colon, value)) 

140 return Concat(*parts, output_field=TextField()) 

141 

142 

143_ROW_SIGNATURE = _version_row_signature() 

144 

145 

146def _enabled_rules_version(alias): 

147 """Return a deterministic content fingerprint of all enabled rules on *alias*. 

148 

149 PostgreSQL hashes the matching and output columns in primary-key order. Each 

150 value is length-prefixed, so arbitrary text cannot create field or row boundary 

151 collisions. The empty rule set has a stable empty fingerprint. 

152 """ 

153 from .models import InterfaceNameRule 

154 

155 return ( 

156 InterfaceNameRule.objects.using(alias) 

157 .filter(enabled=True) 

158 .aggregate( 

159 fingerprint=Coalesce( 

160 _Md5OrderedStringAgg(_ROW_SIGNATURE, Value("", output_field=TextField())), 

161 Value("", output_field=TextField()), 

162 ) 

163 )["fingerprint"] 

164 ) 

165 

166 

167def _get_enabled_rules(): 

168 """Return the exact rules, regex rules, and memo for the current version on the read alias. 

169 

170 Exact rules retain model ordering, which reduces to primary-key order for one 

171 module type. Regex rules are compiled once and ordered by decreasing pattern 

172 length, then primary key. A reload publishes one new cache snapshot so a 

173 concurrent reader cannot combine values from two versions. 

174 """ 

175 # One module-level cache, replaced atomically. 

176 global _RULE_CACHE # noqa: PLW0603 

177 

178 # A branch can hold other rules than main, so a snapshot belongs to the alias it was read from. 

179 alias = _enabled_module_rules().db 

180 pinned = getattr(_pin, "depth", 0) > 0 

181 if pinned and getattr(_pin, "primed", False) and _pin.alias == alias: 

182 # Return the thread's snapshot. Another thread can replace the shared cache. 

183 return _pin.exact, _pin.regex, _pin.memo 

184 

185 cache = _RULE_CACHE 

186 version = _enabled_rules_version(alias) 

187 if (cache.alias, cache.version) != (alias, version): 

188 rules = list(_enabled_module_rules().using(alias).order_by("module_type__model", "pk")) 

189 exact = tuple(rule for rule in rules if not rule.module_type_is_regex) 

190 regex_rules = sorted( 

191 (rule for rule in rules if rule.module_type_is_regex), 

192 key=lambda rule: (-len(rule.module_type_pattern or ""), rule.pk), 

193 ) 

194 regex = tuple((compile_stored_pattern(rule.module_type_pattern), rule) for rule in regex_rules) 

195 cache = _RuleSnapshot(alias=alias, version=version, exact=exact, regex=regex, memo={}) 

196 _RULE_CACHE = cache 

197 

198 if pinned: 

199 # Keep a private memo so another thread cannot clear this batch's entries. 

200 _pin.alias = alias 

201 _pin.exact = cache.exact 

202 _pin.regex = cache.regex 

203 _pin.memo = dict(cache.memo) 

204 _pin.primed = True 

205 return _pin.exact, _pin.regex, _pin.memo 

206 

207 return cache.exact, cache.regex, cache.memo 

208 

209 

210def _scope_ids(parent_module_type, device_type, platform): 

211 """Map optional scope objects to their foreign-key values.""" 

212 return ( 

213 parent_module_type.pk if parent_module_type is not None else None, 

214 device_type.pk if device_type is not None else None, 

215 platform.pk if platform is not None else None, 

216 ) 

217 

218 

219def _rule_scope_matches(rule, scope_ids): 

220 """Return whether a rule has exactly the requested scope values.""" 

221 parent_module_type_id, device_type_id, platform_id = scope_ids 

222 return ( 

223 rule.parent_module_type_id == parent_module_type_id 

224 and rule.device_type_id == device_type_id 

225 and rule.platform_id == platform_id 

226 ) 

227 

228 

229def _build_candidates(parent_module_type, device_type, platform) -> list: 

230 """Build scope combinations from most specific to least specific.""" 

231 seen: set = set() 

232 candidates = [] 

233 parent_options = [parent_module_type, None] if parent_module_type else [None] 

234 device_options = [device_type, None] if device_type else [None] 

235 platform_options = [platform, None] if platform else [None] 

236 for parent in parent_options: 

237 for device in device_options: 

238 for candidate_platform in platform_options: 

239 candidate = (parent, device, candidate_platform) 

240 if candidate not in seen: 240 ↛ 238line 240 didn't jump to line 238 because the condition on line 240 was always true

241 seen.add(candidate) 

242 candidates.append(candidate) 

243 return candidates 

244 

245 

246def _find_exact_match(module_type, candidates, exact_rules=None): 

247 """Return the first enabled exact rule in scope-precedence order.""" 

248 if exact_rules is None: 

249 exact_rules, _, _ = _get_enabled_rules() 

250 

251 scoped_rules = [rule for rule in exact_rules if rule.module_type_id == module_type.pk] 

252 for candidate in candidates: 

253 scope_ids = _scope_ids(*candidate) 

254 for rule in scoped_rules: 

255 if _rule_scope_matches(rule, scope_ids): 

256 return rule 

257 return None 

258 

259 

260def _find_regex_match(model_name: str, candidates, regex_rules=None): 

261 """Return the first enabled regex rule in scope-precedence order.""" 

262 if regex_rules is None: 

263 _, regex_rules, _ = _get_enabled_rules() 

264 

265 for candidate in candidates: 

266 scope_ids = _scope_ids(*candidate) 

267 for compiled, rule in regex_rules: 

268 if compiled is not None and _rule_scope_matches(rule, scope_ids) and compiled.fullmatch(model_name): 

269 return rule 

270 return None 

271 

272 

273def find_matching_rule(module_type, parent_module_type, device_type, platform=None): 

274 """Return the most specific enabled rule for a module and scope context. 

275 

276 Exact module-type rules take priority over regular-expression rules. Within 

277 each tier, parent module type, device type, and platform scopes are tried from 

278 most specific to least specific. 

279 """ 

280 if module_type is None: 

281 return None 

282 

283 exact_rules, regex_rules, memo = _get_enabled_rules() 

284 signature = ( 

285 module_type.pk, 

286 # Regex matching reads the live model name, so the memo key must include it. 

287 module_type.model, 

288 *_scope_ids(parent_module_type, device_type, platform), 

289 ) 

290 # A single dictionary read cannot race with another thread's memo clear between 

291 # a membership check and a later subscript. 

292 cached = memo.get(signature, _MEMO_MISS) 

293 if cached is not _MEMO_MISS: 

294 return cached 

295 

296 candidates = _build_candidates(parent_module_type, device_type, platform) 

297 result = _find_exact_match(module_type, candidates, exact_rules) or _find_regex_match( 

298 module_type.model, 

299 candidates, 

300 regex_rules, 

301 ) 

302 if len(memo) >= _MEMO_MAX: 

303 memo.clear() 

304 memo[signature] = result 

305 return result