infrahub-server 1.7.1__py3-none-any.whl → 1.7.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. infrahub/actions/gather.py +2 -2
  2. infrahub/api/query.py +3 -2
  3. infrahub/api/transformation.py +3 -3
  4. infrahub/computed_attribute/gather.py +2 -0
  5. infrahub/config.py +2 -2
  6. infrahub/core/attribute.py +21 -2
  7. infrahub/core/diff/model/path.py +43 -0
  8. infrahub/core/graph/__init__.py +1 -1
  9. infrahub/core/graph/index.py +2 -0
  10. infrahub/core/ipam/resource_allocator.py +229 -0
  11. infrahub/core/migrations/graph/__init__.py +8 -0
  12. infrahub/core/migrations/graph/m052_fix_global_branch_level.py +51 -0
  13. infrahub/core/migrations/graph/m053_fix_branch_level_zero.py +61 -0
  14. infrahub/core/migrations/graph/m054_cleanup_orphaned_nodes.py +87 -0
  15. infrahub/core/migrations/graph/m055_remove_webhook_validate_certificates_default.py +86 -0
  16. infrahub/core/migrations/schema/node_attribute_add.py +17 -19
  17. infrahub/core/node/lock_utils.py +23 -2
  18. infrahub/core/node/resource_manager/ip_address_pool.py +5 -11
  19. infrahub/core/node/resource_manager/ip_prefix_pool.py +5 -21
  20. infrahub/core/node/resource_manager/number_pool.py +109 -39
  21. infrahub/core/query/__init__.py +7 -1
  22. infrahub/core/query/branch.py +18 -2
  23. infrahub/core/query/ipam.py +629 -40
  24. infrahub/core/query/node.py +128 -0
  25. infrahub/core/query/resource_manager.py +114 -1
  26. infrahub/core/relationship/model.py +1 -1
  27. infrahub/core/schema/definitions/core/webhook.py +0 -1
  28. infrahub/core/schema/definitions/internal.py +7 -4
  29. infrahub/core/validators/determiner.py +4 -0
  30. infrahub/graphql/analyzer.py +3 -1
  31. infrahub/graphql/app.py +7 -10
  32. infrahub/graphql/execution.py +95 -0
  33. infrahub/graphql/mutations/proposed_change.py +15 -0
  34. infrahub/graphql/parser.py +10 -7
  35. infrahub/graphql/queries/ipam.py +20 -25
  36. infrahub/graphql/queries/search.py +29 -9
  37. infrahub/proposed_change/tasks.py +2 -0
  38. infrahub/services/adapters/http/httpx.py +27 -0
  39. infrahub/trigger/catalogue.py +2 -0
  40. infrahub/trigger/models.py +73 -4
  41. infrahub/trigger/setup.py +1 -1
  42. infrahub/trigger/system.py +36 -0
  43. infrahub/webhook/models.py +4 -2
  44. infrahub/webhook/tasks.py +2 -2
  45. infrahub/workflows/initialization.py +2 -2
  46. {infrahub_server-1.7.1.dist-info → infrahub_server-1.7.2.dist-info}/METADATA +3 -3
  47. {infrahub_server-1.7.1.dist-info → infrahub_server-1.7.2.dist-info}/RECORD +52 -46
  48. infrahub_testcontainers/docker-compose-cluster.test.yml +16 -10
  49. infrahub_testcontainers/docker-compose.test.yml +11 -10
  50. infrahub/pools/address.py +0 -16
  51. {infrahub_server-1.7.1.dist-info → infrahub_server-1.7.2.dist-info}/WHEEL +0 -0
  52. {infrahub_server-1.7.1.dist-info → infrahub_server-1.7.2.dist-info}/entry_points.txt +0 -0
  53. {infrahub_server-1.7.1.dist-info → infrahub_server-1.7.2.dist-info}/licenses/LICENSE.txt +0 -0
@@ -2,23 +2,19 @@ from __future__ import annotations
2
2
 
3
3
  import ipaddress
4
4
  from dataclasses import dataclass
5
- from typing import TYPE_CHECKING, Iterable
5
+ from typing import TYPE_CHECKING
6
6
 
7
7
  from infrahub.core.constants import InfrahubKind
8
8
  from infrahub.core.graph.schema import GraphAttributeIPHostNode, GraphAttributeIPNetworkNode
9
9
  from infrahub.core.ipam.constants import AllIPTypes, IPAddressType, IPNetworkType
10
- from infrahub.core.query import QueryResult, QueryType
10
+ from infrahub.core.query import Query, QueryResult, QueryType
11
11
  from infrahub.core.registry import registry
12
12
  from infrahub.core.utils import convert_ip_to_binary_str
13
13
 
14
- from . import Query
15
-
16
14
  if TYPE_CHECKING:
17
15
  from uuid import UUID
18
16
 
19
- from infrahub.core.branch import Branch
20
17
  from infrahub.core.node import Node
21
- from infrahub.core.timestamp import Timestamp
22
18
  from infrahub.database import InfrahubDatabase
23
19
 
24
20
 
@@ -38,6 +34,58 @@ class IPAddressData:
38
34
  address: IPAddressType
39
35
 
40
36
 
37
+ @dataclass(frozen=True)
38
+ class IPPrefixFreeData:
39
+ free_start: int
40
+
41
+ @classmethod
42
+ def from_db(cls, result: QueryResult) -> IPPrefixFreeData:
43
+ return cls(
44
+ free_start=result.get_as_type("free_start", return_type=int),
45
+ )
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class IPv6PrefixFreeData:
50
+ free_start_bin: str
51
+
52
+ @classmethod
53
+ def from_db(cls, result: QueryResult) -> IPv6PrefixFreeData:
54
+ return cls(
55
+ free_start_bin=result.get_as_type("free_start_bin", return_type=str),
56
+ )
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class IPAddressFreeData:
61
+ free_addr: int
62
+ is_free: bool
63
+ is_last: bool
64
+
65
+ @classmethod
66
+ def from_db(cls, result: QueryResult) -> IPAddressFreeData:
67
+ return cls(
68
+ free_addr=result.get_as_type("free_addr", return_type=int),
69
+ is_free=result.get_as_type("is_free", return_type=bool),
70
+ is_last=result.get_as_type("is_last", return_type=bool),
71
+ )
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class IPv6AddressFreeData:
76
+ free_addr_bin: str
77
+ is_free: bool
78
+ is_last: bool
79
+
80
+ @classmethod
81
+ def from_db(cls, result: QueryResult) -> IPv6AddressFreeData:
82
+ return cls(
83
+ free_addr_bin=result.get_as_type("free_addr_bin", return_type=str),
84
+ is_free=result.get_as_type("is_free", return_type=bool),
85
+ is_last=result.get_as_type("is_last", return_type=bool),
86
+ )
87
+
88
+
41
89
  def _get_namespace_id(
42
90
  namespace: Node | str | None = None,
43
91
  ) -> str:
@@ -84,11 +132,11 @@ class IPPrefixSubnetFetch(Query):
84
132
  CALL (ns) {
85
133
  MATCH (ns)-[r:IS_PART_OF]-(root:Root)
86
134
  WHERE %(branch_filter)s
87
- RETURN ns as ns1, r as r1
135
+ RETURN r
88
136
  ORDER BY r.branch_level DESC, r.from DESC
89
137
  LIMIT 1
90
138
  }
91
- WITH ns, r1 as r
139
+ WITH ns, r
92
140
  WHERE r.status = "active"
93
141
  WITH ns
94
142
  // MATCH all prefixes that are IN SCOPE
@@ -137,6 +185,301 @@ class IPPrefixSubnetFetch(Query):
137
185
  return subnets
138
186
 
139
187
 
188
+ class IPPrefixSubnetFetchFree(Query):
189
+ name = "ipprefix_subnet_fetch_free"
190
+ type = QueryType.READ
191
+ raise_error_if_empty = False
192
+
193
+ def __init__(
194
+ self,
195
+ obj: IPNetworkType,
196
+ target_prefixlen: int,
197
+ namespace: Node | str | None = None,
198
+ **kwargs,
199
+ ) -> None:
200
+ self.obj = obj
201
+ self.target_prefixlen = target_prefixlen
202
+ self.namespace_id = _get_namespace_id(namespace)
203
+
204
+ super().__init__(**kwargs)
205
+
206
+ async def query_init(self, db: InfrahubDatabase, **kwargs) -> None: # noqa: ARG002
207
+ self.params["ns_id"] = self.namespace_id
208
+
209
+ prefix_bin = convert_ip_to_binary_str(self.obj)[: self.obj.prefixlen]
210
+ self.params["prefix_binary"] = prefix_bin
211
+ self.params["maxprefixlen"] = self.obj.prefixlen
212
+ self.params["ip_version"] = self.obj.version
213
+ self.params["parent_start"] = int(self.obj.network_address)
214
+ self.params["parent_end"] = int(self.obj.broadcast_address)
215
+ self.params["block_size"] = 1 << (32 - self.target_prefixlen)
216
+
217
+ branch_filter, branch_params = self.branch.get_query_filter_path(
218
+ at=self.at.to_string(), branch_agnostic=self.branch_agnostic
219
+ )
220
+ self.params.update(branch_params)
221
+
222
+ # ruff: noqa: E501
223
+ query = """
224
+ // First match on IPNAMESPACE
225
+ MATCH (ns:%(ns_label)s)
226
+ WHERE ns.uuid = $ns_id
227
+ CALL (ns) {
228
+ MATCH (ns)-[r:IS_PART_OF]-(root:Root)
229
+ WHERE %(branch_filter)s
230
+ RETURN r
231
+ ORDER BY r.branch_level DESC, r.from DESC
232
+ LIMIT 1
233
+ }
234
+ WITH ns, r
235
+ WHERE r.status = "active"
236
+ WITH ns
237
+ OPTIONAL MATCH path2 = (ns)-[:IS_RELATED]-(ns_rel:Relationship)-[:IS_RELATED]-(pfx:%(node_label)s)-[:HAS_ATTRIBUTE]-(an:Attribute {name: "prefix"})-[:HAS_VALUE]-(av:AttributeIPNetwork)
238
+ WHERE ns_rel.name = "ip_namespace__ip_prefix"
239
+ AND av.binary_address STARTS WITH $prefix_binary
240
+ AND av.prefixlen > $maxprefixlen
241
+ AND av.version = $ip_version
242
+ AND all(r IN relationships(path2) WHERE (%(branch_filter)s) AND r.status = "active")
243
+ WITH collect({binary: av.binary_address, prefixlen: av.prefixlen}) AS ranges_raw
244
+ // Convert binary strings to integer ranges for gap detection
245
+ // - start: Convert binary string to decimal by iterating through each bit
246
+ // and accumulating (dec * 2 + bit_value), which is binary-to-decimal conversion
247
+ // - end: start + (2^host_bits - 1), where host_bits = 32 - prefixlen
248
+ // This gives the broadcast address (last IP) of the prefix
249
+ WITH [r IN ranges_raw |
250
+ {
251
+ start: reduce(dec = 0, b IN split(r.binary, "") | dec * 2 + toInteger(b)),
252
+ end: reduce(dec = 0, b IN split(r.binary, "") | dec * 2 + toInteger(b)) + toInteger(2 ^ (32 - r.prefixlen)) - 1
253
+ }
254
+ ] AS ranges
255
+ UNWIND CASE WHEN size(ranges) = 0 THEN [{start: null, end: null}] ELSE ranges END AS r
256
+ WITH r
257
+ ORDER BY r.start ASC
258
+ WITH collect(r) AS ranges_sorted_raw
259
+ WITH [r IN ranges_sorted_raw WHERE r.start IS NOT NULL] AS ranges_sorted
260
+ // Gap detection algorithm using reduce to scan through sorted ranges
261
+ // acc.cursor: current candidate position for a free block (always block-aligned)
262
+ // acc.found: set when a gap is found, stops further iteration
263
+ WITH reduce(acc = {cursor: $parent_start, found: null}, r IN ranges_sorted |
264
+ CASE
265
+ // Already found a gap, preserve result
266
+ WHEN acc.found IS NOT NULL THEN acc
267
+ // Range ends before cursor, skip (range already passed)
268
+ WHEN r.end < acc.cursor THEN acc
269
+ // Gap found: cursor + block_size - 1 < range start means there's room before this range
270
+ WHEN acc.cursor + $block_size - 1 < r.start THEN {cursor: acc.cursor, found: acc.cursor}
271
+ // No gap: advance cursor past this range using ceiling division
272
+ // Formula: ceil((r.end + 1) / block_size) * block_size
273
+ // This aligns the cursor to the next block boundary after the range
274
+ ELSE
275
+ {
276
+ cursor: CASE
277
+ WHEN toInteger((r.end + 1 + $block_size - 1) / $block_size) * $block_size > $parent_end + 1
278
+ THEN $parent_end + 1
279
+ ELSE toInteger((r.end + 1 + $block_size - 1) / $block_size) * $block_size
280
+ END,
281
+ found: null
282
+ }
283
+ END
284
+ ) AS res
285
+ WITH CASE
286
+ WHEN res.found IS NOT NULL THEN res.found
287
+ WHEN res.cursor + $block_size - 1 <= $parent_end THEN res.cursor
288
+ ELSE NULL
289
+ END AS free_start
290
+ WHERE free_start IS NOT NULL
291
+ """ % {
292
+ "ns_label": InfrahubKind.IPNAMESPACE,
293
+ "node_label": InfrahubKind.IPPREFIX,
294
+ "branch_filter": branch_filter,
295
+ }
296
+
297
+ self.add_to_query(query)
298
+ self.return_labels = ["free_start"]
299
+ self.limit = 1
300
+
301
+ def get_prefix_data(self) -> IPPrefixFreeData | None:
302
+ result = self.get_result()
303
+ if not result:
304
+ return None
305
+
306
+ return IPPrefixFreeData.from_db(result=result)
307
+
308
+
309
+ class IPv6PrefixSubnetFetchFree(Query):
310
+ """Query to find the next free IPv6 prefix within a parent prefix.
311
+
312
+ This query uses binary string operations to handle IPv6's 128-bit address space,
313
+ as the integer values would overflow Neo4j's 64-bit integer type.
314
+ """
315
+
316
+ name = "ipv6prefix_subnet_fetch_free"
317
+ type = QueryType.READ
318
+ raise_error_if_empty = False
319
+
320
+ def __init__(
321
+ self,
322
+ obj: IPNetworkType,
323
+ target_prefixlen: int,
324
+ namespace: Node | str | None = None,
325
+ **kwargs,
326
+ ) -> None:
327
+ self.obj = obj
328
+ self.target_prefixlen = target_prefixlen
329
+ self.namespace_id = _get_namespace_id(namespace)
330
+
331
+ super().__init__(**kwargs)
332
+
333
+ async def query_init(self, db: InfrahubDatabase, **kwargs) -> None: # noqa: ARG002
334
+ self.params["ns_id"] = self.namespace_id
335
+
336
+ prefix_bin = convert_ip_to_binary_str(self.obj)[: self.obj.prefixlen]
337
+ self.params["prefix_binary"] = prefix_bin
338
+ self.params["maxprefixlen"] = self.obj.prefixlen
339
+ self.params["ip_version"] = self.obj.version
340
+ self.params["target_prefixlen"] = self.target_prefixlen
341
+ # Binary representation of parent network and broadcast addresses
342
+ self.params["parent_start_bin"] = convert_ip_to_binary_str(self.obj)
343
+ self.params["parent_end_bin"] = format(int(self.obj.broadcast_address), "0128b")
344
+
345
+ branch_filter, branch_params = self.branch.get_query_filter_path(
346
+ at=self.at.to_string(), branch_agnostic=self.branch_agnostic
347
+ )
348
+ self.params.update(branch_params)
349
+
350
+ # ruff: noqa: E501
351
+ query = """
352
+ // First match on IPNAMESPACE
353
+ MATCH (ns:%(ns_label)s)
354
+ WHERE ns.uuid = $ns_id
355
+ CALL (ns) {
356
+ MATCH (ns)-[r:IS_PART_OF]-(root:Root)
357
+ WHERE %(branch_filter)s
358
+ RETURN r
359
+ ORDER BY r.branch_level DESC, r.from DESC
360
+ LIMIT 1
361
+ }
362
+ WITH ns, r
363
+ WHERE r.status = "active"
364
+ WITH ns
365
+ OPTIONAL MATCH path2 = (ns)-[:IS_RELATED]-(ns_rel:Relationship)-[:IS_RELATED]-(pfx:%(node_label)s)-[:HAS_ATTRIBUTE]-(an:Attribute {name: "prefix"})-[:HAS_VALUE]-(av:AttributeIPNetwork)
366
+ WHERE ns_rel.name = "ip_namespace__ip_prefix"
367
+ AND av.binary_address STARTS WITH $prefix_binary
368
+ AND av.prefixlen > $maxprefixlen
369
+ AND av.version = $ip_version
370
+ AND all(r IN relationships(path2) WHERE (%(branch_filter)s) AND r.status = "active")
371
+ WITH collect({binary: av.binary_address, prefixlen: av.prefixlen}) AS ranges_raw
372
+ // Create ranges with start (block-aligned) and end_block (the block containing the end address)
373
+ WITH [r IN ranges_raw WHERE r.binary IS NOT NULL |
374
+ {
375
+ // Start block: first target_prefixlen bits of network address
376
+ start_block: left(r.binary, $target_prefixlen),
377
+ // End block: first target_prefixlen bits of broadcast address
378
+ // For a prefix, broadcast = network with all host bits set to 1
379
+ end_block: left(left(r.binary, r.prefixlen) + reduce(s = "", i IN range(1, 128 - r.prefixlen) | s + "1"), $target_prefixlen)
380
+ }
381
+ ] AS ranges
382
+ UNWIND CASE WHEN size(ranges) = 0 THEN [{start_block: null, end_block: null}] ELSE ranges END AS r
383
+ WITH r
384
+ ORDER BY r.start_block ASC
385
+ WITH collect(r) AS ranges_sorted_raw
386
+ WITH [r IN ranges_sorted_raw WHERE r.start_block IS NOT NULL] AS ranges_sorted
387
+ // Find first available slot using binary string comparison
388
+ // We track cursor as the current candidate block (target_prefixlen bits)
389
+ WITH reduce(acc = {cursor: left($parent_start_bin, $target_prefixlen), found: null}, r IN ranges_sorted |
390
+ CASE
391
+ WHEN acc.found IS NOT NULL THEN acc
392
+ // Range ends before cursor, skip it
393
+ WHEN r.end_block < acc.cursor THEN acc
394
+ // Gap found: cursor is before this range starts
395
+ WHEN acc.cursor < r.start_block THEN {cursor: acc.cursor, found: acc.cursor}
396
+ // Cursor overlaps with range: advance cursor past this range
397
+ // Binary string increment: add 1 to end_block to get the next block
398
+ // Algorithm: process bits right-to-left with carry propagation
399
+ // - Start with carry=1 (we're adding 1)
400
+ // - For each bit: if carry=0, keep bit unchanged
401
+ // if carry=1 and bit="1", output "0" and carry remains 1
402
+ // if carry=1 and bit="0", output "1" and carry becomes 0
403
+ // Note: if all bits are "1", result will be all "0"s with carry=1 (overflow)
404
+ // We track the final carry and set cursor to null if overflow occurred
405
+ ELSE
406
+ {
407
+ cursor: CASE
408
+ // Check for overflow: if final carry is 1, return null
409
+ WHEN reduce(
410
+ inc = {bits: split(r.end_block, ""), carry: 1, result: []},
411
+ idx IN reverse(range(0, $target_prefixlen - 1)) |
412
+ {
413
+ bits: inc.bits,
414
+ carry: CASE
415
+ WHEN inc.carry = 0 THEN 0
416
+ WHEN inc.bits[idx] = "1" THEN 1
417
+ ELSE 0
418
+ END,
419
+ result: CASE
420
+ WHEN inc.carry = 0 THEN [inc.bits[idx]] + inc.result
421
+ WHEN inc.bits[idx] = "1" THEN ["0"] + inc.result
422
+ ELSE ["1"] + inc.result
423
+ END
424
+ }
425
+ ).carry = 1 THEN null
426
+ ELSE reduce(s = "", c IN reduce(
427
+ inc = {bits: split(r.end_block, ""), carry: 1, result: []},
428
+ idx IN reverse(range(0, $target_prefixlen - 1)) |
429
+ {
430
+ bits: inc.bits,
431
+ carry: CASE
432
+ WHEN inc.carry = 0 THEN 0
433
+ WHEN inc.bits[idx] = "1" THEN 1
434
+ ELSE 0
435
+ END,
436
+ result: CASE
437
+ WHEN inc.carry = 0 THEN [inc.bits[idx]] + inc.result
438
+ WHEN inc.bits[idx] = "1" THEN ["0"] + inc.result
439
+ ELSE ["1"] + inc.result
440
+ END
441
+ }
442
+ ).result | s + c)
443
+ END,
444
+ found: null
445
+ }
446
+ END
447
+ ) AS res
448
+ // Handle overflow case
449
+ WITH
450
+ CASE
451
+ WHEN res.found IS NOT NULL THEN res.found
452
+ ELSE res.cursor
453
+ END AS cursor_str,
454
+ res.found AS found
455
+ // Check if we found a slot or if there's space after all ranges
456
+ WITH CASE
457
+ WHEN found IS NOT NULL THEN found
458
+ // Check cursor is valid (not overflowed) and within parent range
459
+ WHEN size(cursor_str) = $target_prefixlen AND cursor_str <= left($parent_end_bin, $target_prefixlen) THEN cursor_str
460
+ ELSE NULL
461
+ END AS free_start_partial
462
+ WHERE free_start_partial IS NOT NULL
463
+ // Pad the partial binary to full 128 bits
464
+ WITH free_start_partial + reduce(s = "", i IN range(1, 128 - $target_prefixlen) | s + "0") AS free_start_bin
465
+ """ % {
466
+ "ns_label": InfrahubKind.IPNAMESPACE,
467
+ "node_label": InfrahubKind.IPPREFIX,
468
+ "branch_filter": branch_filter,
469
+ }
470
+
471
+ self.add_to_query(query)
472
+ self.return_labels = ["free_start_bin"]
473
+ self.limit = 1
474
+
475
+ def get_prefix_data(self) -> IPv6PrefixFreeData | None:
476
+ result = self.get_result()
477
+ if not result:
478
+ return None
479
+
480
+ return IPv6PrefixFreeData.from_db(result=result)
481
+
482
+
140
483
  class IPPrefixIPAddressFetch(Query):
141
484
  name = "ipprefix_ipaddress_fetch"
142
485
  type = QueryType.READ
@@ -173,11 +516,11 @@ class IPPrefixIPAddressFetch(Query):
173
516
  CALL (ns) {
174
517
  MATCH (ns)-[r:IS_PART_OF]-(root:Root)
175
518
  WHERE %(branch_filter)s
176
- RETURN ns as ns1, r as r1
519
+ RETURN r
177
520
  ORDER BY r.branch_level DESC, r.from DESC
178
521
  LIMIT 1
179
522
  }
180
- WITH ns, r1 as r
523
+ WITH ns, r
181
524
  WHERE r.status = "active"
182
525
  WITH ns
183
526
  // MATCH all IPAddress that are IN SCOPE
@@ -210,36 +553,282 @@ class IPPrefixIPAddressFetch(Query):
210
553
  return addresses
211
554
 
212
555
 
213
- async def get_subnets(
214
- db: InfrahubDatabase,
215
- ip_prefix: IPNetworkType,
216
- namespace: Node | str | None = None,
217
- branch: Branch | str | None = None,
218
- at: Timestamp | str | None = None,
219
- branch_agnostic: bool = False,
220
- ) -> Iterable[IPPrefixData]:
221
- branch = await registry.get_branch(db=db, branch=branch)
222
- query = await IPPrefixSubnetFetch.init(
223
- db=db, branch=branch, obj=ip_prefix, namespace=namespace, at=at, branch_agnostic=branch_agnostic
224
- )
225
- await query.execute(db=db)
226
- return query.get_subnets()
227
-
228
-
229
- async def get_ip_addresses(
230
- db: InfrahubDatabase,
231
- ip_prefix: IPNetworkType,
232
- namespace: Node | str | None = None,
233
- branch: Branch | str | None = None,
234
- at: Timestamp | str | None = None,
235
- branch_agnostic: bool = False,
236
- ) -> Iterable[IPAddressData]:
237
- branch = await registry.get_branch(db=db, branch=branch)
238
- query = await IPPrefixIPAddressFetch.init(
239
- db=db, branch=branch, obj=ip_prefix, namespace=namespace, at=at, branch_agnostic=branch_agnostic
240
- )
241
- await query.execute(db=db)
242
- return query.get_addresses()
556
+ class IPPrefixIPAddressFetchFree(Query):
557
+ name = "ipprefix_ipaddress_fetch_free"
558
+ type = QueryType.READ
559
+
560
+ def __init__(
561
+ self,
562
+ obj: IPNetworkType,
563
+ is_pool: bool,
564
+ namespace: Node | str | None = None,
565
+ **kwargs,
566
+ ) -> None:
567
+ self.obj = obj
568
+ self.namespace_id = _get_namespace_id(namespace)
569
+ self.is_pool = is_pool
570
+
571
+ super().__init__(**kwargs)
572
+
573
+ async def query_init(self, db: InfrahubDatabase, **kwargs) -> None: # noqa: ARG002
574
+ self.params["ns_id"] = self.namespace_id
575
+
576
+ prefix_bin = convert_ip_to_binary_str(self.obj)[: self.obj.prefixlen]
577
+ self.params["prefix_binary"] = prefix_bin
578
+ self.params["start_range"] = int(self.obj.network_address)
579
+ self.params["end_range"] = int(self.obj.broadcast_address)
580
+ if not self.is_pool:
581
+ self.params["start_range"] += 1
582
+ self.params["end_range"] -= 1
583
+
584
+ self.params["maxprefixlen"] = self.obj.prefixlen
585
+ self.params["ip_version"] = self.obj.version
586
+ self.limit = 1 # Query only works at returning a single, free entry
587
+
588
+ branch_filter, branch_params = self.branch.get_query_filter_path(
589
+ at=self.at.to_string(), branch_agnostic=self.branch_agnostic
590
+ )
591
+ self.params.update(branch_params)
592
+
593
+ # ruff: noqa: E501
594
+ query = """
595
+ // First match on IPNAMESPACE
596
+ MATCH (ns:%(ns_label)s)
597
+ WHERE ns.uuid = $ns_id
598
+ CALL (ns) {
599
+ MATCH (ns)-[r:IS_PART_OF]-(root:Root)
600
+ WHERE %(branch_filter)s
601
+ RETURN r
602
+ ORDER BY r.branch_level DESC, r.from DESC
603
+ LIMIT 1
604
+ }
605
+ WITH ns, r
606
+ WHERE r.status = "active"
607
+ WITH ns
608
+ // MATCH all IPAddress that are IN SCOPE
609
+ MATCH path2 = (ns)-[:IS_RELATED]-(ns_rel:Relationship)-[:IS_RELATED]-(addr:%(node_label)s)-[:HAS_ATTRIBUTE]-(an:Attribute {name: "address"})-[:HAS_VALUE]-(av:AttributeIPHost)
610
+ WHERE ns_rel.name = "ip_namespace__ip_address"
611
+ AND av.binary_address STARTS WITH $prefix_binary
612
+ AND av.prefixlen >= $maxprefixlen
613
+ AND av.version = $ip_version
614
+ AND all(r IN relationships(path2) WHERE (%(branch_filter)s) and r.status = "active")
615
+ ORDER BY av.binary_address
616
+ // Gap detection algorithm: collect used addresses and find first available slot
617
+ // Each used_address is a single binary string representing an allocated IP
618
+ WITH DISTINCT av.binary_address AS used_address
619
+ // Convert binary string to integer for comparison
620
+ WITH [x IN split(used_address, "") | toInteger(x)] AS bits
621
+ // Build array: [start_range - 1] prepended to all used addresses as integers
622
+ // This creates a baseline for gap detection starting from the range beginning
623
+ WITH [$start_range - 1] + collect(reduce(dec = 0, b IN bits | dec * 2 + b)) AS nums
624
+ UNWIND range(0, size(nums) - 1) AS idx
625
+ CALL (nums, idx) {
626
+ // Compare expected sequential address with actual address at this position
627
+ // If they differ, we found a gap (is_free = true)
628
+ WITH nums[idx] AS curr, idx - 1 + $start_range AS expected
629
+ RETURN expected AS addr, expected <> curr AS is_free, idx = size(nums) - 1 AS is_last
630
+ }
631
+ WITH addr, is_free, is_last
632
+ WHERE is_free = true OR is_last = true
633
+ WITH addr AS free_addr, is_free, is_last
634
+ """ % {
635
+ "ns_label": InfrahubKind.IPNAMESPACE,
636
+ "node_label": InfrahubKind.IPADDRESS,
637
+ "branch_filter": branch_filter,
638
+ }
639
+
640
+ self.add_to_query(query)
641
+ self.return_labels = ["free_addr", "is_free", "is_last"]
642
+ self.order_by = ["free_addr"]
643
+
644
+ def get_address_data(self) -> IPAddressFreeData | None:
645
+ if not self.results:
646
+ return None
647
+
648
+ return IPAddressFreeData.from_db(result=self.results[0])
649
+
650
+ def get_address(self) -> IPAddressType | None:
651
+ """Return the next free address fitting in the prefix."""
652
+ result_data = self.get_address_data()
653
+ if result_data is None:
654
+ return None
655
+
656
+ if result_data.is_free:
657
+ return ipaddress.ip_interface(result_data.free_addr)
658
+ # When is_last=True and is_free=False, we've reached the end of all allocated addresses
659
+ # without finding a gap. The next available address is one past the last allocated address,
660
+ # but only if it doesn't exceed the end of the valid range (end_range).
661
+ if result_data.is_last and result_data.free_addr < self.params["end_range"]:
662
+ return ipaddress.ip_interface(result_data.free_addr + 1)
663
+
664
+ return None
665
+
666
+
667
+ class IPv6PrefixIPAddressFetchFree(Query):
668
+ """Query to find the next free IPv6 address within a prefix.
669
+
670
+ This query uses binary string operations to handle IPv6's 128-bit address space,
671
+ as the integer values would overflow Neo4j's 64-bit integer type.
672
+ """
673
+
674
+ name = "ipv6prefix_ipaddress_fetch_free"
675
+ type = QueryType.READ
676
+
677
+ def __init__(
678
+ self,
679
+ obj: IPNetworkType,
680
+ is_pool: bool,
681
+ namespace: Node | str | None = None,
682
+ **kwargs,
683
+ ) -> None:
684
+ self.obj = obj
685
+ self.namespace_id = _get_namespace_id(namespace)
686
+ self.is_pool = is_pool
687
+
688
+ super().__init__(**kwargs)
689
+
690
+ async def query_init(self, db: InfrahubDatabase, **kwargs) -> None: # noqa: ARG002
691
+ self.params["ns_id"] = self.namespace_id
692
+
693
+ prefix_bin = convert_ip_to_binary_str(self.obj)[: self.obj.prefixlen]
694
+ self.params["prefix_binary"] = prefix_bin
695
+ self.params["maxprefixlen"] = self.obj.prefixlen
696
+ self.params["ip_version"] = self.obj.version
697
+
698
+ # Binary representation of start and end of valid range
699
+ start_addr = self.obj.network_address
700
+ end_addr = self.obj.broadcast_address
701
+ if not self.is_pool:
702
+ start_addr += 1
703
+ end_addr -= 1
704
+ self.params["start_range_bin"] = format(int(start_addr), "0128b")
705
+ self.params["end_range_bin"] = format(int(end_addr), "0128b")
706
+
707
+ self.limit = 1 # Query only works at returning a single, free entry
708
+
709
+ branch_filter, branch_params = self.branch.get_query_filter_path(
710
+ at=self.at.to_string(), branch_agnostic=self.branch_agnostic
711
+ )
712
+ self.params.update(branch_params)
713
+
714
+ # ruff: noqa: E501
715
+ query = """
716
+ // First match on IPNAMESPACE
717
+ MATCH (ns:%(ns_label)s)
718
+ WHERE ns.uuid = $ns_id
719
+ CALL (ns) {
720
+ MATCH (ns)-[r:IS_PART_OF]-(root:Root)
721
+ WHERE %(branch_filter)s
722
+ RETURN r
723
+ ORDER BY r.branch_level DESC, r.from DESC
724
+ LIMIT 1
725
+ }
726
+ WITH ns, r
727
+ WHERE r.status = "active"
728
+ WITH ns
729
+ // MATCH all IPAddress that are IN SCOPE
730
+ OPTIONAL MATCH path2 = (ns)-[:IS_RELATED]-(ns_rel:Relationship)-[:IS_RELATED]-(addr:%(node_label)s)-[:HAS_ATTRIBUTE]-(an:Attribute {name: "address"})-[:HAS_VALUE]-(av:AttributeIPHost)
731
+ WHERE ns_rel.name = "ip_namespace__ip_address"
732
+ AND av.binary_address STARTS WITH $prefix_binary
733
+ AND av.prefixlen >= $maxprefixlen
734
+ AND av.version = $ip_version
735
+ AND all(r IN relationships(path2) WHERE (%(branch_filter)s) and r.status = "active")
736
+ WITH DISTINCT av.binary_address AS used_address
737
+ ORDER BY used_address
738
+ // Collect used addresses as binary strings, prepend a sentinel value before start_range
739
+ WITH collect(used_address) AS used_addrs_raw
740
+ WITH [addr IN used_addrs_raw WHERE addr IS NOT NULL AND addr >= $start_range_bin AND addr <= $end_range_bin] AS used_addrs
741
+ // Gap detection using binary string comparison
742
+ // We iterate through used addresses and find the first gap
743
+ WITH reduce(acc = {cursor: $start_range_bin, found: null, is_last: false}, addr IN used_addrs |
744
+ CASE
745
+ // Already found a gap, preserve result
746
+ WHEN acc.found IS NOT NULL THEN acc
747
+ // Gap found: cursor is less than this address
748
+ WHEN acc.cursor < addr THEN {cursor: acc.cursor, found: acc.cursor, is_last: false}
749
+ // No gap: advance cursor past this address using binary increment
750
+ ELSE
751
+ {
752
+ cursor: CASE
753
+ // Check for overflow: if final carry is 1, return a marker value
754
+ WHEN reduce(
755
+ inc = {bits: split(addr, ""), carry: 1, result: []},
756
+ idx IN reverse(range(0, 127)) |
757
+ {
758
+ bits: inc.bits,
759
+ carry: CASE
760
+ WHEN inc.carry = 0 THEN 0
761
+ WHEN inc.bits[idx] = "1" THEN 1
762
+ ELSE 0
763
+ END,
764
+ result: CASE
765
+ WHEN inc.carry = 0 THEN [inc.bits[idx]] + inc.result
766
+ WHEN inc.bits[idx] = "1" THEN ["0"] + inc.result
767
+ ELSE ["1"] + inc.result
768
+ END
769
+ }
770
+ ).carry = 1 THEN null
771
+ ELSE reduce(s = "", c IN reduce(
772
+ inc = {bits: split(addr, ""), carry: 1, result: []},
773
+ idx IN reverse(range(0, 127)) |
774
+ {
775
+ bits: inc.bits,
776
+ carry: CASE
777
+ WHEN inc.carry = 0 THEN 0
778
+ WHEN inc.bits[idx] = "1" THEN 1
779
+ ELSE 0
780
+ END,
781
+ result: CASE
782
+ WHEN inc.carry = 0 THEN [inc.bits[idx]] + inc.result
783
+ WHEN inc.bits[idx] = "1" THEN ["0"] + inc.result
784
+ ELSE ["1"] + inc.result
785
+ END
786
+ }
787
+ ).result | s + c)
788
+ END,
789
+ found: null,
790
+ is_last: false
791
+ }
792
+ END
793
+ ) AS res
794
+ // Determine final result
795
+ WITH CASE
796
+ // Gap was found during iteration
797
+ WHEN res.found IS NOT NULL THEN {addr: res.found, is_free: true, is_last: false}
798
+ // No gap found, check if cursor is still valid and within range
799
+ WHEN res.cursor IS NOT NULL AND res.cursor <= $end_range_bin THEN {addr: res.cursor, is_free: true, is_last: true}
800
+ // No addresses available
801
+ ELSE {addr: null, is_free: false, is_last: true}
802
+ END AS result
803
+ WHERE result.addr IS NOT NULL
804
+ WITH result.addr AS free_addr_bin, result.is_free AS is_free, result.is_last AS is_last
805
+ """ % {
806
+ "ns_label": InfrahubKind.IPNAMESPACE,
807
+ "node_label": InfrahubKind.IPADDRESS,
808
+ "branch_filter": branch_filter,
809
+ }
810
+
811
+ self.add_to_query(query)
812
+ self.return_labels = ["free_addr_bin", "is_free", "is_last"]
813
+
814
+ def get_address_data(self) -> IPv6AddressFreeData | None:
815
+ if not self.results:
816
+ return None
817
+
818
+ return IPv6AddressFreeData.from_db(result=self.results[0])
819
+
820
+ def get_address(self) -> IPAddressType | None:
821
+ """Return the next free IPv6 address fitting in the prefix."""
822
+ result_data = self.get_address_data()
823
+ if result_data is None:
824
+ return None
825
+
826
+ if result_data.is_free:
827
+ # Convert binary string to IPv6 address
828
+ addr_int = int(result_data.free_addr_bin, 2)
829
+ return ipaddress.ip_interface(ipaddress.IPv6Address(addr_int))
830
+
831
+ return None
243
832
 
244
833
 
245
834
  @dataclass(frozen=True)