infrahub-server 1.3.5__py3-none-any.whl → 1.4.0b0__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 (158) hide show
  1. infrahub/api/internal.py +5 -0
  2. infrahub/artifacts/tasks.py +17 -22
  3. infrahub/branch/merge_mutation_checker.py +38 -0
  4. infrahub/cli/__init__.py +2 -2
  5. infrahub/cli/context.py +7 -3
  6. infrahub/cli/db.py +5 -16
  7. infrahub/cli/upgrade.py +7 -29
  8. infrahub/computed_attribute/tasks.py +36 -46
  9. infrahub/config.py +53 -2
  10. infrahub/constants/environment.py +1 -0
  11. infrahub/core/attribute.py +9 -7
  12. infrahub/core/branch/tasks.py +43 -41
  13. infrahub/core/constants/__init__.py +20 -6
  14. infrahub/core/constants/infrahubkind.py +2 -0
  15. infrahub/core/diff/coordinator.py +3 -1
  16. infrahub/core/diff/repository/repository.py +0 -8
  17. infrahub/core/diff/tasks.py +11 -8
  18. infrahub/core/graph/__init__.py +1 -1
  19. infrahub/core/graph/index.py +1 -2
  20. infrahub/core/graph/schema.py +50 -29
  21. infrahub/core/initialization.py +62 -33
  22. infrahub/core/ipam/tasks.py +4 -3
  23. infrahub/core/merge.py +8 -10
  24. infrahub/core/migrations/graph/__init__.py +2 -0
  25. infrahub/core/migrations/graph/m035_drop_attr_value_index.py +45 -0
  26. infrahub/core/migrations/query/attribute_add.py +27 -2
  27. infrahub/core/migrations/schema/tasks.py +6 -5
  28. infrahub/core/node/proposed_change.py +43 -0
  29. infrahub/core/protocols.py +12 -0
  30. infrahub/core/query/attribute.py +32 -14
  31. infrahub/core/query/diff.py +11 -0
  32. infrahub/core/query/ipam.py +13 -7
  33. infrahub/core/query/node.py +51 -10
  34. infrahub/core/query/resource_manager.py +3 -3
  35. infrahub/core/schema/basenode_schema.py +8 -0
  36. infrahub/core/schema/definitions/core/__init__.py +10 -1
  37. infrahub/core/schema/definitions/core/ipam.py +28 -2
  38. infrahub/core/schema/definitions/core/propose_change.py +15 -0
  39. infrahub/core/schema/definitions/core/webhook.py +3 -0
  40. infrahub/core/schema/generic_schema.py +10 -0
  41. infrahub/core/schema/manager.py +10 -1
  42. infrahub/core/schema/node_schema.py +22 -17
  43. infrahub/core/schema/profile_schema.py +8 -0
  44. infrahub/core/schema/schema_branch.py +9 -5
  45. infrahub/core/schema/template_schema.py +8 -0
  46. infrahub/core/validators/checks_runner.py +5 -5
  47. infrahub/core/validators/tasks.py +6 -7
  48. infrahub/core/validators/uniqueness/checker.py +4 -2
  49. infrahub/core/validators/uniqueness/model.py +1 -0
  50. infrahub/core/validators/uniqueness/query.py +57 -7
  51. infrahub/database/__init__.py +2 -1
  52. infrahub/events/__init__.py +18 -0
  53. infrahub/events/constants.py +7 -0
  54. infrahub/events/generator.py +29 -2
  55. infrahub/events/proposed_change_action.py +181 -0
  56. infrahub/generators/tasks.py +24 -20
  57. infrahub/git/base.py +4 -7
  58. infrahub/git/integrator.py +21 -12
  59. infrahub/git/repository.py +15 -30
  60. infrahub/git/tasks.py +121 -106
  61. infrahub/graphql/field_extractor.py +69 -0
  62. infrahub/graphql/manager.py +15 -11
  63. infrahub/graphql/mutations/account.py +2 -2
  64. infrahub/graphql/mutations/action.py +8 -2
  65. infrahub/graphql/mutations/artifact_definition.py +4 -1
  66. infrahub/graphql/mutations/branch.py +10 -5
  67. infrahub/graphql/mutations/graphql_query.py +2 -1
  68. infrahub/graphql/mutations/main.py +14 -8
  69. infrahub/graphql/mutations/menu.py +2 -1
  70. infrahub/graphql/mutations/proposed_change.py +225 -8
  71. infrahub/graphql/mutations/relationship.py +5 -0
  72. infrahub/graphql/mutations/repository.py +2 -1
  73. infrahub/graphql/mutations/tasks.py +7 -9
  74. infrahub/graphql/mutations/webhook.py +4 -1
  75. infrahub/graphql/parser.py +15 -6
  76. infrahub/graphql/queries/__init__.py +10 -1
  77. infrahub/graphql/queries/account.py +3 -3
  78. infrahub/graphql/queries/branch.py +2 -2
  79. infrahub/graphql/queries/diff/tree.py +3 -3
  80. infrahub/graphql/queries/event.py +13 -3
  81. infrahub/graphql/queries/ipam.py +23 -1
  82. infrahub/graphql/queries/proposed_change.py +84 -0
  83. infrahub/graphql/queries/relationship.py +2 -2
  84. infrahub/graphql/queries/resource_manager.py +3 -3
  85. infrahub/graphql/queries/search.py +3 -2
  86. infrahub/graphql/queries/status.py +3 -2
  87. infrahub/graphql/queries/task.py +2 -2
  88. infrahub/graphql/resolvers/ipam.py +440 -0
  89. infrahub/graphql/resolvers/many_relationship.py +4 -3
  90. infrahub/graphql/resolvers/resolver.py +5 -5
  91. infrahub/graphql/resolvers/single_relationship.py +3 -2
  92. infrahub/graphql/schema.py +25 -5
  93. infrahub/graphql/types/__init__.py +2 -2
  94. infrahub/graphql/types/attribute.py +3 -3
  95. infrahub/graphql/types/event.py +60 -0
  96. infrahub/groups/tasks.py +6 -6
  97. infrahub/lock.py +3 -2
  98. infrahub/menu/generator.py +8 -0
  99. infrahub/message_bus/operations/__init__.py +9 -12
  100. infrahub/message_bus/operations/git/file.py +6 -5
  101. infrahub/message_bus/operations/git/repository.py +12 -20
  102. infrahub/message_bus/operations/refresh/registry.py +15 -9
  103. infrahub/message_bus/operations/send/echo.py +7 -4
  104. infrahub/message_bus/types.py +1 -0
  105. infrahub/permissions/globals.py +1 -4
  106. infrahub/permissions/manager.py +8 -5
  107. infrahub/pools/prefix.py +7 -5
  108. infrahub/prefect_server/app.py +31 -0
  109. infrahub/prefect_server/bootstrap.py +18 -0
  110. infrahub/proposed_change/action_checker.py +206 -0
  111. infrahub/proposed_change/approval_revoker.py +40 -0
  112. infrahub/proposed_change/branch_diff.py +3 -1
  113. infrahub/proposed_change/checker.py +45 -0
  114. infrahub/proposed_change/constants.py +32 -2
  115. infrahub/proposed_change/tasks.py +182 -150
  116. infrahub/py.typed +0 -0
  117. infrahub/server.py +29 -17
  118. infrahub/services/__init__.py +13 -28
  119. infrahub/services/adapters/cache/__init__.py +4 -0
  120. infrahub/services/adapters/cache/nats.py +2 -0
  121. infrahub/services/adapters/cache/redis.py +3 -0
  122. infrahub/services/adapters/message_bus/__init__.py +0 -2
  123. infrahub/services/adapters/message_bus/local.py +1 -2
  124. infrahub/services/adapters/message_bus/nats.py +6 -8
  125. infrahub/services/adapters/message_bus/rabbitmq.py +7 -9
  126. infrahub/services/adapters/workflow/__init__.py +1 -0
  127. infrahub/services/adapters/workflow/local.py +1 -8
  128. infrahub/services/component.py +2 -1
  129. infrahub/task_manager/event.py +52 -0
  130. infrahub/task_manager/models.py +9 -0
  131. infrahub/tasks/artifact.py +6 -7
  132. infrahub/tasks/check.py +4 -7
  133. infrahub/telemetry/tasks.py +15 -18
  134. infrahub/transformations/tasks.py +10 -6
  135. infrahub/trigger/tasks.py +4 -3
  136. infrahub/types.py +4 -0
  137. infrahub/validators/events.py +7 -7
  138. infrahub/validators/tasks.py +6 -7
  139. infrahub/webhook/models.py +45 -45
  140. infrahub/webhook/tasks.py +25 -24
  141. infrahub/workers/dependencies.py +143 -0
  142. infrahub/workers/infrahub_async.py +19 -43
  143. infrahub/workflows/catalogue.py +16 -2
  144. infrahub/workflows/initialization.py +5 -4
  145. infrahub/workflows/models.py +2 -0
  146. infrahub_sdk/client.py +6 -6
  147. infrahub_sdk/ctl/repository.py +51 -0
  148. infrahub_sdk/ctl/schema.py +9 -9
  149. infrahub_sdk/protocols.py +40 -6
  150. {infrahub_server-1.3.5.dist-info → infrahub_server-1.4.0b0.dist-info}/METADATA +5 -4
  151. {infrahub_server-1.3.5.dist-info → infrahub_server-1.4.0b0.dist-info}/RECORD +158 -144
  152. infrahub_testcontainers/container.py +17 -0
  153. infrahub_testcontainers/docker-compose-cluster.test.yml +56 -1
  154. infrahub_testcontainers/docker-compose.test.yml +56 -1
  155. infrahub_testcontainers/helpers.py +4 -1
  156. {infrahub_server-1.3.5.dist-info → infrahub_server-1.4.0b0.dist-info}/LICENSE.txt +0 -0
  157. {infrahub_server-1.3.5.dist-info → infrahub_server-1.4.0b0.dist-info}/WHEEL +0 -0
  158. {infrahub_server-1.3.5.dist-info → infrahub_server-1.4.0b0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from graphene import Boolean, Field, Int, List, NonNull, ObjectType, String
6
+ from infrahub_sdk.utils import extract_fields_first_node
7
+
8
+ from infrahub.core.manager import NodeManager
9
+ from infrahub.core.protocols import CoreGenericAccount, CoreProposedChange
10
+ from infrahub.proposed_change.action_checker import ACTION_RULES, ActionRulesEvaluator
11
+
12
+ if TYPE_CHECKING:
13
+ from graphql import GraphQLResolveInfo
14
+
15
+ from infrahub.graphql.initialization import GraphqlContext
16
+
17
+
18
+ class ActionAvailability(ObjectType):
19
+ action = Field(String, required=True, description="The action that a user may want to take on a proposed change")
20
+ available = Field(Boolean, required=True, description="Tells if the action is available")
21
+ unavailability_reason = Field(String, required=False, description="The reason why an action may be unavailable")
22
+
23
+
24
+ class ActionAvailabilityEdge(ObjectType):
25
+ node = Field(ActionAvailability, required=True)
26
+
27
+
28
+ class AvailableActions(ObjectType):
29
+ count = Field(Int, required=True, description="The number of available actions for the proposed change.")
30
+ edges = Field(List(of_type=NonNull(ActionAvailabilityEdge), required=True), required=True)
31
+
32
+ @staticmethod
33
+ async def resolve(
34
+ root: dict, # noqa: ARG004
35
+ info: GraphQLResolveInfo,
36
+ proposed_change_id: str,
37
+ ) -> dict:
38
+ graphql_context: GraphqlContext = info.context
39
+ proposed_change = await NodeManager.get_one(
40
+ kind=CoreProposedChange,
41
+ id=proposed_change_id,
42
+ db=graphql_context.db,
43
+ branch=graphql_context.branch,
44
+ raise_on_error=True,
45
+ )
46
+ proposed_change_author = await proposed_change.created_by.get_peer(
47
+ db=graphql_context.db, peer_type=CoreGenericAccount, raise_on_error=True
48
+ )
49
+ actions = await ActionRulesEvaluator(rules=ACTION_RULES).evaluate(
50
+ proposed_change=proposed_change,
51
+ graphql_context=graphql_context,
52
+ proposed_change_author=proposed_change_author,
53
+ )
54
+
55
+ fields = await extract_fields_first_node(info=info)
56
+ response: dict[str, Any] = {}
57
+
58
+ if "count" in fields:
59
+ response["count"] = len(actions)
60
+
61
+ if edges := fields.get("edges"):
62
+ node_fields = edges.get("node", {})
63
+
64
+ nodes = []
65
+ for action in actions:
66
+ node = {}
67
+
68
+ if "action" in node_fields:
69
+ node["action"] = action["action"]
70
+ if "available" in node_fields:
71
+ node["available"] = action["available"]
72
+ if "unavailability_reason" in node_fields:
73
+ node["unavailability_reason"] = action["unavailability_reason"]
74
+
75
+ nodes.append({"node": node})
76
+
77
+ response["edges"] = nodes
78
+
79
+ return response
80
+
81
+
82
+ ProposedChangeAvailableActions = Field(
83
+ AvailableActions, proposed_change_id=String(required=True), resolver=AvailableActions.resolve, required=True
84
+ )
@@ -3,9 +3,9 @@ from __future__ import annotations
3
3
  from typing import TYPE_CHECKING, Any
4
4
 
5
5
  from graphene import Field, Int, List, NonNull, ObjectType, String
6
- from infrahub_sdk.utils import extract_fields_first_node
7
6
 
8
7
  from infrahub.core.query.relationship import RelationshipGetByIdentifierQuery
8
+ from infrahub.graphql.field_extractor import extract_graphql_fields
9
9
  from infrahub.graphql.types import RelationshipNode
10
10
 
11
11
  if TYPE_CHECKING:
@@ -29,7 +29,7 @@ class Relationships(ObjectType):
29
29
  ) -> dict[str, Any]:
30
30
  graphql_context: GraphqlContext = info.context
31
31
 
32
- fields = await extract_fields_first_node(info)
32
+ fields = extract_graphql_fields(info)
33
33
  excluded_namespaces = excluded_namespaces or []
34
34
 
35
35
  response: dict[str, Any] = {"edges": [], "count": None}
@@ -3,7 +3,6 @@ from __future__ import annotations
3
3
  from typing import TYPE_CHECKING, Any
4
4
 
5
5
  from graphene import BigInt, Field, Float, Int, List, NonNull, ObjectType, String
6
- from infrahub_sdk.utils import extract_fields_first_node
7
6
 
8
7
  from infrahub.core import registry
9
8
  from infrahub.core.constants import InfrahubKind
@@ -16,6 +15,7 @@ from infrahub.core.query.resource_manager import (
16
15
  PrefixPoolGetIdentifiers,
17
16
  )
18
17
  from infrahub.exceptions import NodeNotFoundError, SchemaNotFoundError, ValidationError
18
+ from infrahub.graphql.field_extractor import extract_graphql_fields
19
19
  from infrahub.pools.number import NumberUtilizationGetter
20
20
 
21
21
  if TYPE_CHECKING:
@@ -87,7 +87,7 @@ class PoolAllocated(ObjectType):
87
87
  id=pool_id, db=graphql_context.db, branch=graphql_context.branch
88
88
  )
89
89
 
90
- fields = await extract_fields_first_node(info=info)
90
+ fields = extract_graphql_fields(info=info)
91
91
 
92
92
  allocated_kinds: list[str] = []
93
93
  pool = _validate_pool_type(pool_id=pool_id, pool=pool)
@@ -207,7 +207,7 @@ class PoolUtilization(ObjectType):
207
207
  utilization_getter = PrefixUtilizationGetter(
208
208
  db=db, ip_prefixes=list(resources_map.values()), at=graphql_context.at
209
209
  )
210
- fields = await extract_fields_first_node(info=info)
210
+ fields = extract_graphql_fields(info=info)
211
211
  response: dict[str, Any] = {}
212
212
  total_utilization = None
213
213
  default_branch_utilization = None
@@ -4,10 +4,11 @@ import ipaddress
4
4
  from typing import TYPE_CHECKING, Any
5
5
 
6
6
  from graphene import Boolean, Field, Int, List, NonNull, ObjectType, String
7
- from infrahub_sdk.utils import extract_fields_first_node, is_valid_uuid
7
+ from infrahub_sdk.utils import is_valid_uuid
8
8
 
9
9
  from infrahub.core.constants import InfrahubKind
10
10
  from infrahub.core.manager import NodeManager
11
+ from infrahub.graphql.field_extractor import extract_graphql_fields
11
12
 
12
13
  if TYPE_CHECKING:
13
14
  from graphql import GraphQLResolveInfo
@@ -107,7 +108,7 @@ async def search_resolver(
107
108
  response: dict[str, Any] = {}
108
109
  results: list[CoreNode] = []
109
110
 
110
- fields = await extract_fields_first_node(info)
111
+ fields = extract_graphql_fields(info=info)
111
112
 
112
113
  if is_valid_uuid(q):
113
114
  matching: CoreNode | None = await NodeManager.get_one(
@@ -3,7 +3,8 @@ from __future__ import annotations
3
3
  from typing import TYPE_CHECKING, Any
4
4
 
5
5
  from graphene import Boolean, Field, List, NonNull, ObjectType, String
6
- from infrahub_sdk.utils import extract_fields_first_node
6
+
7
+ from infrahub.graphql.field_extractor import extract_graphql_fields
7
8
 
8
9
  if TYPE_CHECKING:
9
10
  from graphql import GraphQLResolveInfo
@@ -45,7 +46,7 @@ async def resolve_status(
45
46
  if service is None:
46
47
  raise ValueError("GraphqlContext.service is None")
47
48
 
48
- fields = await extract_fields_first_node(info)
49
+ fields = extract_graphql_fields(info=info)
49
50
  response: dict[str, Any] = {}
50
51
  workers = await service.component.list_workers(
51
52
  branch=str(graphql_context.branch.uuid) or graphql_context.branch.name, schema_hash=True
@@ -3,9 +3,9 @@ from __future__ import annotations
3
3
  from typing import TYPE_CHECKING, Any
4
4
 
5
5
  from graphene import Field, Int, List, NonNull, ObjectType, String
6
- from infrahub_sdk.utils import extract_fields_first_node
7
6
  from prefect.client.schemas.objects import StateType
8
7
 
8
+ from infrahub.graphql.field_extractor import extract_graphql_fields
9
9
  from infrahub.graphql.types.task import TaskNodes, TaskState
10
10
  from infrahub.task_manager.task import PrefectTask
11
11
  from infrahub.workflows.constants import WorkflowTag
@@ -79,7 +79,7 @@ class Tasks(ObjectType):
79
79
  log_offset: int | None = None,
80
80
  ) -> dict[str, Any]:
81
81
  graphql_context: GraphqlContext = info.context
82
- fields = await extract_fields_first_node(info)
82
+ fields = extract_graphql_fields(info=info)
83
83
 
84
84
  prefect_tasks = await PrefectTask.query(
85
85
  db=graphql_context.db,
@@ -0,0 +1,440 @@
1
+ from __future__ import annotations
2
+
3
+ import ipaddress
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from graphql.type.definition import GraphQLNonNull
7
+ from netaddr import IPSet
8
+ from opentelemetry import trace
9
+
10
+ from infrahub.core import registry
11
+ from infrahub.core.constants import InfrahubKind
12
+ from infrahub.core.ipam.constants import PrefixMemberType
13
+ from infrahub.core.manager import NodeManager
14
+ from infrahub.core.node import Node
15
+ from infrahub.core.protocols import BuiltinIPNamespace, BuiltinIPPrefix
16
+ from infrahub.core.schema.generic_schema import GenericSchema
17
+ from infrahub.exceptions import ValidationError
18
+ from infrahub.graphql.parser import extract_selection
19
+ from infrahub.graphql.permissions import get_permissions
20
+
21
+ from ..models import OrderModel
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Sequence
25
+
26
+ from graphql import GraphQLResolveInfo
27
+ from pydantic import IPvAnyAddress, IPvAnyInterface, IPvAnyNetwork
28
+
29
+ from infrahub.core.branch.models import Branch
30
+ from infrahub.core.schema import NodeSchema
31
+ from infrahub.database import InfrahubDatabase
32
+ from infrahub.graphql.initialization import GraphqlContext
33
+ from infrahub.graphql.models import OrderModel
34
+
35
+
36
+ def _ip_range_display_label(node: Node) -> str:
37
+ """Return a human friendly summary of an IP range"""
38
+ size = int(node.last_address.obj) - int(node.address.obj) + 1
39
+
40
+ if size == 1:
41
+ return "1 IP address available"
42
+ if size <= 2**16:
43
+ return f"{size} IP addresses available"
44
+ return f"More than {2**16} IP addresses available"
45
+
46
+
47
+ def _ip_with_prefix_length(ip_address: IPvAnyAddress, ip_prefix: IPvAnyNetwork) -> IPvAnyInterface:
48
+ """Convert an `IPAddress` object into an `IPInterface` one given a `IPNetwork`."""
49
+ return ipaddress.ip_interface(f"{ip_address}/{ip_prefix.prefixlen}")
50
+
51
+
52
+ async def _build_ip_range_node(
53
+ db: InfrahubDatabase,
54
+ branch: Branch,
55
+ schema: NodeSchema,
56
+ address: IPvAnyAddress,
57
+ last_address: IPvAnyAddress,
58
+ ip_namespace: BuiltinIPNamespace,
59
+ ip_prefix: BuiltinIPPrefix,
60
+ ) -> Node:
61
+ address_with_len = str(_ip_with_prefix_length(ip_address=address, ip_prefix=ip_prefix.prefix.obj))
62
+ last_address_with_len = str(_ip_with_prefix_length(ip_address=last_address, ip_prefix=ip_prefix.prefix.obj))
63
+
64
+ n = await Node.init(schema=schema, db=db, branch=branch)
65
+ await n.new(
66
+ db=db,
67
+ address=address_with_len,
68
+ last_address=last_address_with_len,
69
+ description=f"Available IP range {address_with_len} - {last_address_with_len}",
70
+ ip_namespace=ip_namespace,
71
+ ip_prefix=ip_prefix,
72
+ )
73
+ return n
74
+
75
+
76
+ def _include_first_and_last_ips(ip_prefix: BuiltinIPPrefix) -> bool:
77
+ if ip_prefix.prefix.version == 6 or ip_prefix.is_pool.value:
78
+ return True
79
+
80
+ return ip_prefix.member_type.value == PrefixMemberType.ADDRESS.value and ip_prefix.prefix.prefixlen == 31
81
+
82
+
83
+ async def _resolve_available_address_nodes(
84
+ db: InfrahubDatabase,
85
+ branch: Branch,
86
+ prefix: BuiltinIPPrefix,
87
+ existing_nodes: Sequence[Node],
88
+ first_node_context: Node | None = None,
89
+ last_node_context: Node | None = None,
90
+ ) -> list[Node]:
91
+ """Annotate a list of IP addresses node with available ranges within a prefix."""
92
+ ip_prefix: IPvAnyNetwork = prefix.prefix.obj
93
+ ip_namespace = await prefix.ip_namespace.get_peer(db=db, peer_type=BuiltinIPNamespace, raise_on_error=True)
94
+ ip_range_schema = registry.get_node_schema(name=InfrahubKind.IPRANGEAVAILABLE, branch=branch)
95
+
96
+ # Make sure nodes are ordered by addresses
97
+ sorted_nodes = sorted(existing_nodes, key=lambda n: n.address.obj)
98
+ prefix_first_address = (
99
+ ip_prefix.network_address if _include_first_and_last_ips(ip_prefix=prefix) else ip_prefix.network_address + 1
100
+ )
101
+ prefix_last_address = (
102
+ ip_prefix.broadcast_address
103
+ if _include_first_and_last_ips(ip_prefix=prefix)
104
+ else ip_prefix.broadcast_address - 1
105
+ )
106
+
107
+ if not sorted_nodes:
108
+ return [
109
+ await _build_ip_range_node(
110
+ db=db,
111
+ branch=branch,
112
+ schema=ip_range_schema,
113
+ address=prefix_first_address,
114
+ last_address=prefix_last_address,
115
+ ip_namespace=ip_namespace,
116
+ ip_prefix=prefix,
117
+ )
118
+ ]
119
+
120
+ first_address: IPvAnyAddress = prefix_first_address
121
+ last_address: IPvAnyAddress = prefix_last_address
122
+
123
+ # Use but exclude context addresses to avoid having them in the result
124
+ if first_node_context:
125
+ first_address = first_node_context.address.obj.ip + 1
126
+ if last_node_context:
127
+ last_address = last_node_context.address.obj.ip - 1
128
+
129
+ with_available_ranges: list[Node] = []
130
+ previous_address: IPvAnyAddress | None = None
131
+
132
+ # Look for a gap at the beginning of the prefix
133
+ if sorted_nodes[0].address.obj.ip > first_address:
134
+ with_available_ranges.append(
135
+ await _build_ip_range_node(
136
+ db=db,
137
+ branch=branch,
138
+ schema=ip_range_schema,
139
+ address=first_address,
140
+ last_address=sorted_nodes[0].address.obj.ip - 1,
141
+ ip_namespace=ip_namespace,
142
+ ip_prefix=prefix,
143
+ )
144
+ )
145
+
146
+ # Look for gaps between existing addresses
147
+ for existing in sorted_nodes:
148
+ current = existing.address.obj.ip
149
+ if previous_address:
150
+ if int(current) - int(previous_address) > 1:
151
+ with_available_ranges.append(
152
+ await _build_ip_range_node(
153
+ db=db,
154
+ branch=branch,
155
+ schema=ip_range_schema,
156
+ address=previous_address + 1,
157
+ last_address=current - 1,
158
+ ip_namespace=ip_namespace,
159
+ ip_prefix=prefix,
160
+ )
161
+ )
162
+
163
+ with_available_ranges.append(existing)
164
+ previous_address = existing.address.obj.ip
165
+
166
+ # Look for a gap at the end of the prefix
167
+ if previous_address and previous_address < last_address:
168
+ with_available_ranges.append(
169
+ await _build_ip_range_node(
170
+ db=db,
171
+ branch=branch,
172
+ schema=ip_range_schema,
173
+ address=previous_address + 1,
174
+ last_address=last_address,
175
+ ip_namespace=ip_namespace,
176
+ ip_prefix=prefix,
177
+ )
178
+ )
179
+
180
+ return with_available_ranges
181
+
182
+
183
+ async def _resolve_available_prefix_nodes(
184
+ db: InfrahubDatabase,
185
+ branch: Branch,
186
+ prefix: BuiltinIPPrefix,
187
+ existing_nodes: Sequence[Node],
188
+ first_node_context: Node | None = None,
189
+ last_node_context: Node | None = None,
190
+ ) -> list[Node]:
191
+ """Annotate a list of IP prefixes node with available prefixes within a parent one."""
192
+ ip_prefix_schema = registry.get_node_schema(name=InfrahubKind.IPPREFIXAVAILABLE, branch=branch)
193
+
194
+ existing_prefixes = IPSet([n.prefix.value for n in existing_nodes])
195
+ if first_node_context:
196
+ existing_prefixes.add(first_node_context.prefix.value)
197
+ if last_node_context:
198
+ existing_prefixes.add(last_node_context.prefix.value)
199
+
200
+ # Infer which prefixes are actually available
201
+ available_prefixes = IPSet([prefix.prefix.value]) ^ existing_prefixes
202
+ available_nodes: list[Node] = []
203
+
204
+ # Turn them into nodes (without saving them in the database)
205
+ for available_prefix in available_prefixes.iter_cidrs():
206
+ p = ipaddress.ip_network(str(available_prefix))
207
+ if (first_node_context and p < first_node_context.prefix.obj) or (
208
+ last_node_context and p > last_node_context.prefix.obj
209
+ ):
210
+ continue
211
+
212
+ node = await Node.init(schema=ip_prefix_schema, db=db, branch=branch)
213
+ await node.new(
214
+ db=db, prefix=str(available_prefix), ip_namespace=await prefix.ip_namespace.get_peer(db=db), parent=prefix
215
+ )
216
+ available_nodes.append(node)
217
+
218
+ # Properly sort existing nodes with available prefixes
219
+ with_available_prefixes = sorted(existing_nodes + available_nodes, key=lambda n: n.prefix.obj)
220
+
221
+ if len(with_available_prefixes) > 1 or with_available_prefixes[0].prefix.obj != prefix.prefix.obj:
222
+ return with_available_prefixes
223
+
224
+ # If the only available prefix is the same as the container prefix, this means the container prefix is empty and we should therefore at least
225
+ # offer two smaller prefixes allocatable within it
226
+ available_nodes.clear()
227
+
228
+ for subnet in prefix.prefix.obj.subnets():
229
+ node = await Node.init(schema=ip_prefix_schema, db=db, branch=branch)
230
+ await node.new(db=db, prefix=str(subnet), ip_namespace=await prefix.ip_namespace.get_peer(db=db), parent=prefix)
231
+ available_nodes.append(node)
232
+
233
+ return available_nodes
234
+
235
+
236
+ def _filter_kinds(nodes: list[Node], kinds: list[str], limit: int | None) -> list[Node]:
237
+ filtered: list[Node] = []
238
+ available_node_kinds = [InfrahubKind.IPPREFIXAVAILABLE, InfrahubKind.IPRANGEAVAILABLE]
239
+ kinds_with_available = kinds + available_node_kinds
240
+
241
+ limit_with_available = limit
242
+ for node in nodes:
243
+ if node.get_schema().kind not in kinds_with_available:
244
+ continue
245
+ # Adapt the limit of nodes to return by always including available ones
246
+ if limit and node.get_schema().kind in available_node_kinds:
247
+ limit_with_available += 1
248
+ filtered.append(node)
249
+
250
+ return filtered[:limit_with_available] if limit else filtered
251
+
252
+
253
+ async def _annotate_result(
254
+ db: InfrahubDatabase,
255
+ branch: Branch,
256
+ resolve_available: bool,
257
+ schema: NodeSchema | GenericSchema,
258
+ parent_prefix: BuiltinIPPrefix | None,
259
+ result: list[Node],
260
+ first_node_context: Node | None = None,
261
+ last_node_context: Node | None = None,
262
+ kinds_to_filter: list[str] | None = None,
263
+ limit: int | None = None,
264
+ ) -> list[Node]:
265
+ nodes: list[Node] = result
266
+
267
+ if resolve_available and parent_prefix:
268
+ if schema.is_ip_address:
269
+ nodes = await _resolve_available_address_nodes(
270
+ db=db,
271
+ branch=branch,
272
+ prefix=parent_prefix,
273
+ existing_nodes=result,
274
+ first_node_context=first_node_context,
275
+ last_node_context=last_node_context,
276
+ )
277
+ else:
278
+ nodes = await _resolve_available_prefix_nodes(
279
+ db=db,
280
+ branch=branch,
281
+ prefix=parent_prefix,
282
+ existing_nodes=result,
283
+ first_node_context=first_node_context,
284
+ last_node_context=last_node_context,
285
+ )
286
+
287
+ return _filter_kinds(nodes=nodes, kinds=kinds_to_filter, limit=limit) if kinds_to_filter else nodes
288
+
289
+
290
+ @trace.get_tracer(__name__).start_as_current_span("ipam_paginated_list_resolver")
291
+ async def ipam_paginated_list_resolver( # noqa: PLR0915
292
+ root: dict, # noqa: ARG001
293
+ info: GraphQLResolveInfo,
294
+ offset: int | None = None,
295
+ limit: int | None = None,
296
+ order: OrderModel | None = None,
297
+ partial_match: bool = False,
298
+ **kwargs: dict[str, Any],
299
+ ) -> dict[str, Any]:
300
+ schema: NodeSchema | GenericSchema = (
301
+ info.return_type.of_type.graphene_type._meta.schema
302
+ if isinstance(info.return_type, GraphQLNonNull)
303
+ else info.return_type.graphene_type._meta.schema
304
+ )
305
+
306
+ if not isinstance(schema, GenericSchema) or schema.kind not in [InfrahubKind.IPADDRESS, InfrahubKind.IPPREFIX]:
307
+ raise ValidationError(f"{schema.kind} is not {InfrahubKind.IPADDRESS} or {InfrahubKind.IPPREFIX}")
308
+
309
+ fields = await extract_selection(info=info, schema=schema)
310
+ resolve_available = bool(kwargs.pop("include_available", False))
311
+ kinds_to_filter: list[str] = kwargs.pop("kinds", []) # type: ignore[assignment]
312
+
313
+ for kind in kinds_to_filter:
314
+ if kind not in schema.used_by:
315
+ raise ValidationError(f"{kind} is not a node inheriting from {schema.kind}")
316
+
317
+ graphql_context: GraphqlContext = info.context
318
+ async with graphql_context.db.start_session(read_only=True) as db:
319
+ response: dict[str, Any] = {"edges": []}
320
+ filters = {
321
+ key: value for key, value in kwargs.items() if ("__" in key and value is not None) or key in ("ids", "hfid")
322
+ }
323
+
324
+ edges = fields.get("edges", {})
325
+ node_fields = edges.get("node", {})
326
+
327
+ permission_set: dict[str, Any] | None = None
328
+ permissions = (
329
+ await get_permissions(schema=schema, graphql_context=graphql_context)
330
+ if graphql_context.permissions
331
+ else None
332
+ )
333
+ if fields.get("permissions"):
334
+ response["permissions"] = permissions
335
+
336
+ if permissions:
337
+ for edge in permissions["edges"]:
338
+ if edge["node"]["kind"] == schema.kind:
339
+ permission_set = edge["node"]
340
+
341
+ parent_prefix_id = ""
342
+ if schema.is_ip_address and "ip_prefix__ids" in filters:
343
+ parent_prefix_id = next(iter(filters["ip_prefix__ids"]))
344
+ if schema.is_ip_prefix and "parent__ids" in filters:
345
+ parent_prefix_id = next(iter(filters["parent__ids"]))
346
+
347
+ parent_prefix: BuiltinIPPrefix | None = None
348
+ if parent_prefix_id:
349
+ parent_prefix = await NodeManager.get_one(
350
+ db=db, kind=BuiltinIPPrefix, id=parent_prefix_id, at=graphql_context.at, branch=graphql_context.branch
351
+ )
352
+
353
+ first_node_context: Node | None = None
354
+ fetch_first_node_context = False
355
+ if offset is not None and offset > 0:
356
+ offset -= 1
357
+ fetch_first_node_context = True
358
+
359
+ last_node_context: Node | None = None
360
+ fetch_last_node_context = False
361
+ if limit is not None and limit > 0:
362
+ limit += 1
363
+ fetch_last_node_context = True
364
+
365
+ # Since we are going to narrow down the number of nodes in the end, we will query for a larger set (that can potentially include all kinds of
366
+ # implementations) in the first place to make sure that we will fill in the page to its maximum
367
+ query_limit = limit
368
+ if kinds_to_filter and limit:
369
+ query_limit *= len(schema.used_by)
370
+
371
+ objs = []
372
+ if edges or "hfid" in filters:
373
+ objs = await NodeManager.query(
374
+ db=db,
375
+ schema=schema,
376
+ filters=filters or None,
377
+ fields=node_fields,
378
+ at=graphql_context.at,
379
+ branch=graphql_context.branch,
380
+ limit=query_limit,
381
+ offset=offset,
382
+ account=graphql_context.account_session,
383
+ include_source=True,
384
+ include_owner=True,
385
+ partial_match=partial_match,
386
+ order=order,
387
+ )
388
+
389
+ if fetch_first_node_context and len(objs) > 2:
390
+ first_node_context = objs[0]
391
+ objs = objs[1:]
392
+ if fetch_last_node_context and len(objs) >= limit >= 2:
393
+ last_node_context = objs[-1]
394
+ objs = objs[:-1]
395
+
396
+ if "count" in fields:
397
+ if filters.get("hfid"):
398
+ response["count"] = len(objs)
399
+ else:
400
+ response["count"] = await NodeManager.count(
401
+ db=db,
402
+ schema=schema,
403
+ filters=filters,
404
+ at=graphql_context.at,
405
+ branch=graphql_context.branch,
406
+ partial_match=partial_match,
407
+ )
408
+
409
+ result = await _annotate_result(
410
+ db=db,
411
+ branch=graphql_context.branch,
412
+ resolve_available=resolve_available,
413
+ schema=schema,
414
+ parent_prefix=parent_prefix,
415
+ result=objs,
416
+ first_node_context=first_node_context,
417
+ last_node_context=last_node_context,
418
+ kinds_to_filter=kinds_to_filter,
419
+ limit=limit,
420
+ )
421
+
422
+ if result:
423
+ objects = []
424
+ for obj in result:
425
+ obj_data = await obj.to_graphql(
426
+ db=db,
427
+ fields=node_fields,
428
+ related_node_ids=graphql_context.related_node_ids,
429
+ permissions=permission_set,
430
+ )
431
+
432
+ # Override display label for available IP ranges
433
+ if obj.get_schema().kind == InfrahubKind.IPRANGEAVAILABLE and "display_label" in obj_data:
434
+ obj_data["display_label"] = _ip_range_display_label(node=obj)
435
+
436
+ objects.append({"node": obj_data})
437
+
438
+ response["edges"] = objects
439
+
440
+ return response
@@ -1,7 +1,7 @@
1
1
  from typing import TYPE_CHECKING, Any
2
2
 
3
3
  from graphql import GraphQLResolveInfo
4
- from infrahub_sdk.utils import deep_merge_dict, extract_fields
4
+ from infrahub_sdk.utils import deep_merge_dict
5
5
 
6
6
  from infrahub.core.branch.models import Branch
7
7
  from infrahub.core.constants import BranchSupportType, RelationshipHierarchyDirection
@@ -11,6 +11,7 @@ from infrahub.core.schema.node_schema import NodeSchema
11
11
  from infrahub.core.schema.relationship_schema import RelationshipSchema
12
12
  from infrahub.core.timestamp import Timestamp
13
13
  from infrahub.database import InfrahubDatabase
14
+ from infrahub.graphql.field_extractor import extract_graphql_fields
14
15
 
15
16
  from ..loaders.peers import PeerRelationshipsDataLoader, QueryPeerParams
16
17
  from ..types import RELATIONS_PROPERTY_MAP, RELATIONS_PROPERTY_MAP_REVERSED
@@ -81,14 +82,14 @@ class ManyRelationshipResolver:
81
82
  This resolver is used for paginated responses and as such we redefined the requested
82
83
  fields by only reusing information below the 'node' key.
83
84
  """
84
- # Extract the InfraHub schema by inspecting the GQL Schema
85
+ # Extract the Infrahub schema by inspecting the GQL Schema
85
86
 
86
87
  node_schema: MainSchemaTypes = info.parent_type.graphene_type._meta.schema # type: ignore[attr-defined]
87
88
 
88
89
  graphql_context: GraphqlContext = info.context
89
90
 
90
91
  # Extract the name of the fields in the GQL query
91
- fields = await extract_fields(info.field_nodes[0].selection_set)
92
+ fields = extract_graphql_fields(info=info)
92
93
  edges = fields.get("edges", {})
93
94
  node_fields = edges.get("node", {})
94
95
  property_fields = edges.get("properties", {})