diff --git a/changelog/+from-pool-sent-once.fixed.md b/changelog/+from-pool-sent-once.fixed.md new file mode 100644 index 000000000..787b104e1 --- /dev/null +++ b/changelog/+from-pool-sent-once.fixed.md @@ -0,0 +1 @@ +A `from_pool` request is sent once: after a successful save, the next update no longer sends it again. diff --git a/changelog/+number-pool-detach-attach.added.md b/changelog/+number-pool-detach-attach.added.md new file mode 100644 index 000000000..f0c8bb729 --- /dev/null +++ b/changelog/+number-pool-detach-attach.added.md @@ -0,0 +1 @@ +Number attributes have `attach_to_pool()` and `detach_from_pool()`. `attach_to_pool()` makes a number pool track the number the attribute holds, without allocating a new one, and `detach_from_pool()` makes the pool stop tracking it while the attribute keeps its number. Both take effect when the node is saved, and require Infrahub 1.12 or later. diff --git a/changelog/+number-pool-inline-ranges.added.md b/changelog/+number-pool-inline-ranges.added.md new file mode 100644 index 000000000..837dcae06 --- /dev/null +++ b/changelog/+number-pool-inline-ranges.added.md @@ -0,0 +1 @@ +A `CoreNumberPool` can be created with its ranges in one call, by passing `ranges` as a list of dictionaries with `start`, `end` and `allocation_weight`, any of which can be left out. Saving with `allow_upsert=True` sets the ranges of an existing pool to the declared list. Before, the SDK sent each range as an empty object, which Infrahub read as one range without a start or an end. A single range of an existing pool is still created, changed or deleted through its `CoreNumberPoolRange` node. Requires Infrahub 1.12 or later. diff --git a/changelog/+number-pool-value-and-from-pool.fixed.md b/changelog/+number-pool-value-and-from-pool.fixed.md new file mode 100644 index 000000000..5927bce1f --- /dev/null +++ b/changelog/+number-pool-value-and-from-pool.fixed.md @@ -0,0 +1 @@ +An attribute created with both `value` and `from_pool` now sends both, so the number pool tracks the given number instead of allocating a new one. diff --git a/changelog/+pool-allocations-provenance.added.md b/changelog/+pool-allocations-provenance.added.md new file mode 100644 index 000000000..e268476ae --- /dev/null +++ b/changelog/+pool-allocations-provenance.added.md @@ -0,0 +1 @@ +Resource pool nodes have `get_pool_allocations()`, which lists the resources a pool holds and, for a number pool, whether the pool allocated each number or a user attached it. Requires Infrahub 1.12 or later. `get_pool_allocated_resources()` is unchanged. diff --git a/docs/docs/python-sdk/guides/resource-manager.mdx b/docs/docs/python-sdk/guides/resource-manager.mdx index 13a9b0af9..bd6325c1d 100644 --- a/docs/docs/python-sdk/guides/resource-manager.mdx +++ b/docs/docs/python-sdk/guides/resource-manager.mdx @@ -310,6 +310,88 @@ query { Notice that we have one IP address allocated by the Resource manager in the test branch. The query in the main branch shows us this allocation, indicating that it has been allocated and the resource cannot be allocated again. However, the IP address does not exist itself within the main branch. +## Create a number pool with ranges + +A number pool allocates from its ranges. Pass `ranges` as a list of dictionaries with `start`, `end` and `allocation_weight`. A range without a `start` or an `end` is open on that side, and ranges with a higher `allocation_weight` are allocated from first. Without `ranges`, the pool has one range with no start and no end. + + + + + ```python + pool = await client.create( + kind="CoreNumberPool", + name="VLAN IDs", + node="InfraVLAN", + node_attribute="vlan_id", + ranges=[ + {"start": 100, "end": 199, "allocation_weight": 10}, + {"start": 200, "end": 2999}, + ], + ) + await pool.save() + ``` + + + + + ```python + pool = client.create( + kind="CoreNumberPool", + name="VLAN IDs", + node="InfraVLAN", + node_attribute="vlan_id", + ranges=[ + {"start": 100, "end": 199, "allocation_weight": 10}, + {"start": 200, "end": 2999}, + ], + ) + pool.save() + ``` + + + + +The declared list is the complete set of ranges. Saving the same pool with `allow_upsert=True` sets the ranges of an existing pool to that list: Infrahub keeps the ranges whose bounds match, creates the new ones and deletes the others. + +To add, change or delete a single range of an existing pool, work with its `CoreNumberPoolRange` node. Infrahub refuses changes made through the pool's `ranges` relationship with `add()` or `remove()`, because the pool takes its ranges as bounds, not as references to range nodes. + + + + + ```python + # Add a range + new_range = await client.create(kind="CoreNumberPoolRange", start=3000, end=3999, pool=pool) + await new_range.save() + + # Change its weight + new_range.allocation_weight.value = 50 + await new_range.save() + + # Delete it + await new_range.delete() + ``` + + + + + ```python + # Add a range + new_range = client.create(kind="CoreNumberPoolRange", start=3000, end=3999, pool=pool) + new_range.save() + + # Change its weight + new_range.allocation_weight.value = 50 + new_range.save() + + # Delete it + new_range.delete() + ``` + + + + +Infrahub refuses a range that overlaps another range of the pool, and refuses a change or a deletion that would leave a number the pool tracks outside every range. The ranges of a pool generated from a schema attribute are set in the attribute's `parameters.ranges` instead, and cannot be changed through `CoreNumberPoolRange` nodes. + ## CoreNumberPool and attribute allocation `CoreNumberPool` allocates integer values (such as VLAN IDs or AS numbers) directly to node attributes. The pool assigns the integer value at the moment the node is created on the server. @@ -323,6 +405,87 @@ vlan = await client.create( await vlan.save() ``` +### Attach a number you choose to a pool + +To keep a number you choose and have the pool track it, pass both `value` and `from_pool`. The pool records the number as one a user provided, and does not allocate a new one. + + + + + ```python + vlan = await client.create( + kind="InfraVLAN", + name="VLAN-200", + vlan_id={"value": 200, "from_pool": {"id": pool_id}}, + ) + await vlan.save() + ``` + + + + + ```python + vlan = client.create( + kind="InfraVLAN", + name="VLAN-200", + vlan_id={"value": 200, "from_pool": {"id": pool_id}}, + ) + vlan.save() + ``` + + + + +On an existing node, call `attach_to_pool()` on the attribute with the pool or its ID. The pool tracks the number the attribute holds when the node is saved. + + + + + ```python + vlan = await client.get(kind="InfraVLAN", name__value="VLAN-300") + vlan.vlan_id.attach_to_pool(pool) + await vlan.save() + ``` + + + + + ```python + vlan = client.get(kind="InfraVLAN", name__value="VLAN-300") + vlan.vlan_id.attach_to_pool(pool) + vlan.save() + ``` + + + + +### Detach a number from its pool + +Call `detach_from_pool()` on the attribute. The attribute keeps its number, and the pool stops tracking it when the node is saved. + + + + + ```python + vlan = await client.get(kind="InfraVLAN", name__value="VLAN-200") + vlan.vlan_id.detach_from_pool() + await vlan.save() + ``` + + + + + ```python + vlan = client.get(kind="InfraVLAN", name__value="VLAN-200") + vlan.vlan_id.detach_from_pool() + vlan.save() + ``` + + + + +To find which pool tracks a number on a node you fetched, query the node with `property=True` and read the attribute's `source`, which is the number pool. + ### Limitation: `allow_upsert=True` with a pool-sourced HFID attribute `CoreNumberPool` assigns the integer value at server creation time, and a new value on each creation. When a node's human-friendly identifier (HFID) includes a pool-sourced attribute, the HFID is never stable, so an upsert can never match an existing node by it. Rather than updating, each `save(allow_upsert=True)` would create another node. The SDK blocks this up front to avoid silently duplicating data. diff --git a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/attribute.mdx b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/attribute.mdx index a708ac8a5..cd5b4301a 100644 --- a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/attribute.mdx +++ b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/attribute.mdx @@ -69,6 +69,35 @@ Never raises and sends no request. - was assigned since, when the attribute does not belong to a node, or when its node has no - ``id`` yet. ``False`` otherwise. +#### `attach_to_pool` + +```python +attach_to_pool(self, pool: CoreNodeBase | str) -> None +``` + +Attach the number this attribute holds to a number pool when the node is next saved. + +The pool then tracks the number as one a user provided, without allocating a new one. + +**Args:** + +- `pool`: The number pool node, or its ID. + +**Raises:** + +- `ValueError`: If the attribute holds no number, or if ``pool`` is a node that is not a resource pool or +has not been saved. + +#### `detach_from_pool` + +```python +detach_from_pool(self) -> None +``` + +Detach the number this attribute holds from its number pool when the node is next saved. + +The attribute keeps its number, and the pool stops tracking it. + #### `is_from_pool_attribute` ```python @@ -77,6 +106,9 @@ is_from_pool_attribute(self) -> bool Check whether this attribute's value is sourced from a resource pool. +This reflects only the pool requests made through this object. For a node fetched from the server, query it +with ``property=True`` and read the attribute's ``source``, which is the number pool tracking the value. + **Returns:** - True if the attribute value is a resource pool node or was explicitly allocated from a pool. @@ -94,5 +126,5 @@ A pool-backed attribute is unresolved when: - its value is a pool node object (the pool reference itself, not an allocated scalar), or - its value is None and the from_pool allocation dict is set. -An attribute whose _from_pool dict is set but whose value has already been populated -with the allocated scalar (e.g. after a prior save) is considered resolved. +An attribute whose _from_pool dict is set but which holds a number, either the one allocated by a +prior save or one given to attach to the pool, is considered resolved. diff --git a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/node.mdx b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/node.mdx index 097953667..1d7c5555d 100644 --- a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/node.mdx +++ b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/node.mdx @@ -471,6 +471,30 @@ Fetch all nodes that were allocated for the pool and a given resource. - `ValueError`: If the node is not a resource pool. +#### `get_pool_allocations` + +```python +get_pool_allocations(self, resource: InfrahubNode) -> list[PoolAllocation] +``` + +Fetch the resources the pool holds for a given resource, with how each one came to be held. + +Requires Infrahub 1.12 or later. Unlike :meth:`get_pool_allocated_resources`, it does not fetch the +allocated nodes. + +**Args:** + +- `resource`: The resource from which the nodes were allocated\: the pool itself, or one of +its ranges or prefixes. + +**Returns:** + +- list\[PoolAllocation]: One entry per resource the pool holds. + +**Raises:** + +- `ValueError`: If the node is not a resource pool. + #### `get_pool_resources_utilization` ```python @@ -1006,6 +1030,30 @@ Fetch all nodes that were allocated for the pool and a given resource. - `ValueError`: If the node is not a resource pool. +#### `get_pool_allocations` + +```python +get_pool_allocations(self, resource: InfrahubNodeSync) -> list[PoolAllocation] +``` + +Fetch the resources the pool holds for a given resource, with how each one came to be held. + +Requires Infrahub 1.12 or later. Unlike :meth:`get_pool_allocated_resources`, it does not fetch the +allocated nodes. + +**Args:** + +- `resource`: The resource from which the nodes were allocated\: the pool itself, or one +of its ranges or prefixes. + +**Returns:** + +- list\[PoolAllocation]: One entry per resource the pool holds. + +**Raises:** + +- `ValueError`: If the node is not a resource pool. + #### `get_pool_resources_utilization` ```python @@ -1097,6 +1145,18 @@ fetched via ``client.get(...)``. A server-side change to the file between the fetch and the call will not be detected unless the caller re-fetches the node first. +### `PoolAllocation` + +One resource a pool holds, as returned by :meth:`InfrahubNode.get_pool_allocations` and its sync twin. + +**Methods:** + +#### `from_graphql` + +```python +from_graphql(cls, data: dict[str, Any]) -> PoolAllocation +``` + ### `InfrahubNodeBase` Base class for :class:`InfrahubNode` and :class:`InfrahubNodeSync`. diff --git a/infrahub_sdk/constants.py b/infrahub_sdk/constants.py index 785445d9a..03e6302ac 100644 --- a/infrahub_sdk/constants.py +++ b/infrahub_sdk/constants.py @@ -9,6 +9,13 @@ class InfrahubClientMode(str, enum.Enum): # IDEMPOTENT = "idempotent" +class PoolAllocationProvenance(str, enum.Enum): + """Whether a number pool allocated a number or a user provided it and attached it to the pool.""" + + ALLOCATED = "allocated" + PROVIDED = "provided" + + class Priority(str, enum.Enum): """Request priority emitted as the ``X-Priority`` header. diff --git a/infrahub_sdk/node/__init__.py b/infrahub_sdk/node/__init__.py index eb55bfe36..46664defb 100644 --- a/infrahub_sdk/node/__init__.py +++ b/infrahub_sdk/node/__init__.py @@ -14,7 +14,7 @@ SAFE_VALUE, UPLOAD_IF_CHANGED_FEATURE_NOT_SUPPORTED_MESSAGE, ) -from .node import InfrahubNode, InfrahubNodeBase, InfrahubNodeSync, UploadResult +from .node import InfrahubNode, InfrahubNodeBase, InfrahubNodeSync, PoolAllocation, UploadResult from .parsers import parse_human_friendly_id from .property import NodeProperty from .related_node import ( @@ -43,6 +43,7 @@ "InfrahubNodeBase", "InfrahubNodeSync", "NodeProperty", + "PoolAllocation", "RelatedNode", "RelatedNodeBase", "RelatedNodeSync", diff --git a/infrahub_sdk/node/attribute.py b/infrahub_sdk/node/attribute.py index 8d296003d..bd72595ad 100644 --- a/infrahub_sdk/node/attribute.py +++ b/infrahub_sdk/node/attribute.py @@ -16,6 +16,7 @@ from .property import NodeProperty if TYPE_CHECKING: + from ..protocols_base import CoreNodeBase from ..schema import AttributeSchemaAPI from .node import InfrahubNodeBase @@ -86,16 +87,19 @@ def __init__(self, name: str, schema: AttributeSchemaAPI, data: Any | dict) -> N data (Any | dict): The data for the attribute. Either a scalar value, a dict with a ``value`` key (and optional metadata properties), or a dict with a ``from_pool`` key to allocate the value from a resource pool when saving. + A dict with both ``value`` and ``from_pool`` attaches that value to the pool. """ self.name = name self._schema = schema self._from_pool: dict[str, Any] | None = None + self._pool_write_pending = False self._node: InfrahubNodeBase | None = None self._has_data = False if isinstance(data, dict) and "from_pool" in data: self._from_pool = data.pop("from_pool") + self._pool_write_pending = self._from_pool is not None data.setdefault("value", None) elif not isinstance(data, dict) or "value" not in data: data = {"value": data} @@ -178,16 +182,82 @@ def _raise_if_not_loaded(self) -> None: if not self.is_loaded and self._node is not None and self._node._only_marker is not None: raise self._node._only_marker.not_loaded_error(kind=self._node.get_kind(), field=self.name) + def attach_to_pool(self, pool: CoreNodeBase | str) -> None: + """Attach the number this attribute holds to a number pool when the node is next saved. + + The pool then tracks the number as one a user provided, without allocating a new one. + + Args: + pool (CoreNodeBase | str): The number pool node, or its ID. + + Raises: + ValueError: If the attribute holds no number, or if ``pool`` is a node that is not a resource pool or + has not been saved. + + """ + if self.value is None or self.is_unresolved_pool_attribute(): + raise ValueError( + f"Attribute '{self.name}' holds no number to attach; " + "assign the pool to the attribute instead to allocate a number from it." + ) + if isinstance(pool, str): + pool_id = pool + elif not pool.is_resource_pool(): + raise ValueError(f"{pool.get_kind()} is not a resource pool.") + elif not pool.id: + raise ValueError(f"Save the {pool.get_kind()} before attaching a number to it.") + else: + pool_id = pool.id + self._from_pool = {"id": pool_id} + self._pool_write_pending = True + self._has_data = True + + def detach_from_pool(self) -> None: + """Detach the number this attribute holds from its number pool when the node is next saved. + + The attribute keeps its number, and the pool stops tracking it. + """ + self._from_pool = None + self._pool_write_pending = True + self._has_data = True + + def _mark_pool_write_sent(self) -> None: + self._pool_write_pending = False + def _initialize_graphql_payload(self) -> _GraphQLPayloadAttribute: """Resolve the attribute value into a GraphQL mutation payload object.""" - # Pool-based allocation (dict data or resource-pool node) - if self._from_pool is not None: - return _GraphQLPayloadAttribute(payload={"from_pool": self._from_pool}, variables={}, needs_metadata=True) + if self._pool_write_pending: + return self._pool_write_payload() if hasattr(self._value, "is_resource_pool") and self._value.is_resource_pool(): return _GraphQLPayloadAttribute( payload={"from_pool": {"id": self._value.id}}, variables={}, needs_metadata=True ) + return self._value_payload() + + def _pool_write_payload(self) -> _GraphQLPayloadAttribute: + # A pending write that names no pool is a detach. + if self._from_pool is None: + if not self.value_has_been_mutated and self._node is not None and self._node.id: + return _GraphQLPayloadAttribute(payload={"from_pool": None}, variables={}, needs_metadata=True) + # A changed number, or the number of a node that is not saved yet, must still be sent. + value_payload = self._value_payload() + return _GraphQLPayloadAttribute( + payload={**value_payload.payload, "from_pool": None}, + variables=value_payload.variables, + needs_metadata=True, + ) + + if self._value is None: + return _GraphQLPayloadAttribute(payload={"from_pool": self._from_pool}, variables={}, needs_metadata=True) + + value_payload = self._value_payload() + return _GraphQLPayloadAttribute( + payload={**value_payload.payload, "from_pool": self._from_pool}, + variables=value_payload.variables, + needs_metadata=True, + ) + def _value_payload(self) -> _GraphQLPayloadAttribute: # Null value if self._value is None: data = {"value": None} if (self._schema.optional and self.value_has_been_mutated) else {} @@ -260,6 +330,9 @@ def _generate_mutation_query(self) -> dict[str, Any]: def is_from_pool_attribute(self) -> bool: """Check whether this attribute's value is sourced from a resource pool. + This reflects only the pool requests made through this object. For a node fetched from the server, query it + with ``property=True`` and read the attribute's ``source``, which is the number pool tracking the value. + Returns: True if the attribute value is a resource pool node or was explicitly allocated from a pool. @@ -275,8 +348,8 @@ def is_unresolved_pool_attribute(self) -> bool: - its value is a pool node object (the pool reference itself, not an allocated scalar), or - its value is None and the from_pool allocation dict is set. - An attribute whose _from_pool dict is set but whose value has already been populated - with the allocated scalar (e.g. after a prior save) is considered resolved. + An attribute whose _from_pool dict is set but which holds a number, either the one allocated by a + prior save or one given to attach to the pool, is considered resolved. """ if hasattr(self._value, "is_resource_pool") and self._value.is_resource_pool(): return True diff --git a/infrahub_sdk/node/constants.py b/infrahub_sdk/node/constants.py index 192bbf6de..d383e5a1b 100644 --- a/infrahub_sdk/node/constants.py +++ b/infrahub_sdk/node/constants.py @@ -42,5 +42,12 @@ HIERARCHY_FETCH_FEATURE_NOT_SUPPORTED_MESSAGE = "Hierarchical fields are not supported for this node." +# Peer kinds whose relationship input in a mutation takes the peer's fields, not a reference to an existing peer. +INLINE_INPUT_PEER_KINDS = frozenset({"CoreNumberPoolRange"}) + +POOL_ALLOCATED_QUERY_NAME = "InfrahubResourcePoolAllocated" +POOL_ALLOCATION_FIELDS = ["id", "kind", "branch", "display_label", "identifier", "provenance"] +POOL_ALLOCATED_RESOURCE_FIELDS = ["id", "kind", "branch", "identifier"] + HFID_STR_SEPARATOR = "__" PROFILE_KIND_PREFIX = "Profile" diff --git a/infrahub_sdk/node/node.py b/infrahub_sdk/node/node.py index 536c60cd1..7be571a7f 100644 --- a/infrahub_sdk/node/node.py +++ b/infrahub_sdk/node/node.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, BinaryIO, overload -from ..constants import InfrahubClientMode, Priority +from ..constants import InfrahubClientMode, PoolAllocationProvenance, Priority from ..exceptions import ( FeatureNotSupportedError, NodeNotFoundError, @@ -31,6 +31,9 @@ ARTIFACT_GENERATE_FEATURE_NOT_SUPPORTED_MESSAGE, FILE_DOWNLOAD_FEATURE_NOT_SUPPORTED_MESSAGE, MATCHES_LOCAL_CHECKSUM_FEATURE_NOT_SUPPORTED_MESSAGE, + POOL_ALLOCATED_QUERY_NAME, + POOL_ALLOCATED_RESOURCE_FIELDS, + POOL_ALLOCATION_FIELDS, PROPERTIES_OBJECT, UPLOAD_IF_CHANGED_FEATURE_NOT_SUPPORTED_MESSAGE, ) @@ -74,6 +77,32 @@ class UploadResult: checksum: str | None +@dataclass(frozen=True) +class PoolAllocation: + """One resource a pool holds, as returned by :meth:`InfrahubNode.get_pool_allocations` and its sync twin.""" + + node_id: str + kind: str + branch: str + display_label: str + """The allocated resource; for a number pool, the number itself.""" + identifier: str | None + provenance: PoolAllocationProvenance | None + """``None`` for IP pools, which do not record it.""" + + @classmethod + def from_graphql(cls, data: dict[str, Any]) -> PoolAllocation: + provenance = data.get("provenance") + return cls( + node_id=data["id"], + kind=data["kind"], + branch=data["branch"], + display_label=data["display_label"], + identifier=data.get("identifier"), + provenance=PoolAllocationProvenance[provenance] if provenance else None, + ) + + class InfrahubNodeBase: """Base class for :class:`InfrahubNode` and :class:`InfrahubNodeSync`. @@ -432,6 +461,24 @@ def is_resource_pool(self) -> bool: """ return hasattr(self._schema, "inherit_from") and "CoreResourcePool" in self._schema.inherit_from # type: ignore[union-attr] + def _pool_allocated_query(self, edge_fields: Sequence[str]) -> str: + return Query( + query={ + POOL_ALLOCATED_QUERY_NAME: { + "@filters": { + "pool_id": "$pool_id", + "resource_id": "$resource_id", + "offset": "$offset", + "limit": "$limit", + }, + "count": None, + "edges": {"node": dict.fromkeys(edge_fields)}, + } + }, + name="GetAllocatedResourceForPool", + variables={"pool_id": str, "resource_id": str, "offset": int, "limit": int}, + ).render() + def is_file_object(self) -> bool: """Return whether this node inherits from ``CoreFileObject`` and supports file uploads. @@ -605,6 +652,14 @@ def _generate_input_data( # noqa: C901 "mutation_variables": mutation_variables, } + @staticmethod + def _keeps_unmodified_key(data_item: dict, item_key: str) -> bool: + # Related nodes typically require an ID, so it is only removed when it is the last key in the context. + if item_key == "id": + return len(data_item.keys()) > 1 + # Without the value, naming a pool over a held number asks to replace it, not attach it. + return item_key == "value" and "from_pool" in data_item + @staticmethod def _strip_unmodified_dict(data: dict, original_data: dict, variables: dict, item: str) -> None: data_item = data.get(item) @@ -618,9 +673,7 @@ def _strip_unmodified_dict(data: dict, original_data: dict, variables: dict, ite ): original_data[item][property_name] = original_data[item][property_name]["id"] if item_key in data[item]: - if item_key == "id" and len(data[item].keys()) > 1: - # Related nodes typically require an ID. So the ID is only - # removed if it's the last key in the current context + if InfrahubNodeBase._keeps_unmodified_key(data_item=data[item], item_key=item_key): continue variable_key = None @@ -1842,6 +1895,7 @@ async def _process_mutation_result( for attr_name in self._attributes: attr = getattr(self, attr_name) + attr._mark_pool_write_sent() if attr_name not in object_response or not attr.is_from_pool_attribute(): continue @@ -2102,32 +2156,52 @@ async def get_pool_allocated_resources(self, resource: InfrahubNode) -> list[Inf if not self.is_resource_pool(): raise ValueError("Allocated resources can only be fetched from resource pool nodes.") - graphql_query_name = "InfrahubResourcePoolAllocated" + rows = await self._get_pool_allocated_rows( + resource=resource, edge_fields=POOL_ALLOCATED_RESOURCE_FIELDS, tracker="get-allocated-resources" + ) node_ids_per_kind: dict[str, list[str]] = {} + for row in rows: + node_ids_per_kind.setdefault(row["kind"], []).append(row["id"]) - query = Query( - query={ - graphql_query_name: { - "@filters": { - "pool_id": "$pool_id", - "resource_id": "$resource_id", - "offset": "$offset", - "limit": "$limit", - }, - "count": None, - "edges": {"node": {"id": None, "kind": None, "branch": None, "identifier": None}}, - } - }, - name="GetAllocatedResourceForPool", - variables={"pool_id": str, "resource_id": str, "offset": int, "limit": int}, + nodes: list[InfrahubNode] = [] + for kind, node_ids in node_ids_per_kind.items(): + nodes.extend(await self._client.filters(kind=kind, branch=self._branch, ids=node_ids)) + + return nodes + + async def get_pool_allocations(self, resource: InfrahubNode) -> list[PoolAllocation]: + """Fetch the resources the pool holds for a given resource, with how each one came to be held. + + Requires Infrahub 1.12 or later. Unlike :meth:`get_pool_allocated_resources`, it does not fetch the + allocated nodes. + + Args: + resource (InfrahubNode): The resource from which the nodes were allocated: the pool itself, or one of + its ranges or prefixes. + + Returns: + list[PoolAllocation]: One entry per resource the pool holds. + + Raises: + ValueError: If the node is not a resource pool. + + """ + if not self.is_resource_pool(): + raise ValueError("Pool allocations can only be fetched from resource pool nodes.") + + rows = await self._get_pool_allocated_rows( + resource=resource, edge_fields=POOL_ALLOCATION_FIELDS, tracker="get-pool-allocations" ) - query_str = query.render() + return [PoolAllocation.from_graphql(row) for row in rows] - has_remaining_items = True + async def _get_pool_allocated_rows( + self, resource: InfrahubNode, edge_fields: Sequence[str], tracker: str + ) -> list[dict[str, Any]]: + query_str = self._pool_allocated_query(edge_fields=edge_fields) + rows: list[dict[str, Any]] = [] page_number = 1 - while has_remaining_items: + while True: page_offset = (page_number - 1) * self._client.pagination_size - response = await self._client.execute_graphql( query=query_str, variables={ @@ -2137,27 +2211,13 @@ async def get_pool_allocated_resources(self, resource: InfrahubNode) -> list[Inf "limit": self._client.pagination_size, }, branch_name=self._branch, - tracker=f"get-allocated-resources-page{page_number}", + tracker=f"{tracker}-page{page_number}", ) - - for edge in response[graphql_query_name]["edges"]: - node = edge["node"] - node_ids_per_kind.setdefault(node["kind"], []).append(node["id"]) - - remaining_items = response[graphql_query_name].get("count", 0) - ( - page_offset + self._client.pagination_size - ) - if remaining_items <= 0: - has_remaining_items = False - + rows.extend(edge["node"] for edge in response[POOL_ALLOCATED_QUERY_NAME]["edges"]) + if response[POOL_ALLOCATED_QUERY_NAME].get("count", 0) <= page_offset + self._client.pagination_size: + return rows page_number += 1 - nodes: list[InfrahubNode] = [] - for kind, node_ids in node_ids_per_kind.items(): - nodes.extend(await self._client.filters(kind=kind, branch=self._branch, ids=node_ids)) - - return nodes - async def get_pool_resources_utilization(self) -> list[dict[str, Any]]: """Fetch the utilization of each resource for the pool. @@ -3182,6 +3242,7 @@ def _process_mutation_result( for attr_name in self._attributes: attr = getattr(self, attr_name) + attr._mark_pool_write_sent() if attr_name not in object_response or not attr.is_from_pool_attribute(): continue @@ -3442,32 +3503,52 @@ def get_pool_allocated_resources(self, resource: InfrahubNodeSync) -> list[Infra if not self.is_resource_pool(): raise ValueError("Allocated resources can only be fetched from resource pool nodes.") - graphql_query_name = "InfrahubResourcePoolAllocated" + rows = self._get_pool_allocated_rows( + resource=resource, edge_fields=POOL_ALLOCATED_RESOURCE_FIELDS, tracker="get-allocated-resources" + ) node_ids_per_kind: dict[str, list[str]] = {} + for row in rows: + node_ids_per_kind.setdefault(row["kind"], []).append(row["id"]) - query = Query( - query={ - graphql_query_name: { - "@filters": { - "pool_id": "$pool_id", - "resource_id": "$resource_id", - "offset": "$offset", - "limit": "$limit", - }, - "count": None, - "edges": {"node": {"id": None, "kind": None, "branch": None, "identifier": None}}, - } - }, - name="GetAllocatedResourceForPool", - variables={"pool_id": str, "resource_id": str, "offset": int, "limit": int}, + nodes: list[InfrahubNodeSync] = [] + for kind, node_ids in node_ids_per_kind.items(): + nodes.extend(self._client.filters(kind=kind, branch=self._branch, ids=node_ids)) + + return nodes + + def get_pool_allocations(self, resource: InfrahubNodeSync) -> list[PoolAllocation]: + """Fetch the resources the pool holds for a given resource, with how each one came to be held. + + Requires Infrahub 1.12 or later. Unlike :meth:`get_pool_allocated_resources`, it does not fetch the + allocated nodes. + + Args: + resource (InfrahubNodeSync): The resource from which the nodes were allocated: the pool itself, or one + of its ranges or prefixes. + + Returns: + list[PoolAllocation]: One entry per resource the pool holds. + + Raises: + ValueError: If the node is not a resource pool. + + """ + if not self.is_resource_pool(): + raise ValueError("Pool allocations can only be fetched from resource pool nodes.") + + rows = self._get_pool_allocated_rows( + resource=resource, edge_fields=POOL_ALLOCATION_FIELDS, tracker="get-pool-allocations" ) - query_str = query.render() + return [PoolAllocation.from_graphql(row) for row in rows] - has_remaining_items = True + def _get_pool_allocated_rows( + self, resource: InfrahubNodeSync, edge_fields: Sequence[str], tracker: str + ) -> list[dict[str, Any]]: + query_str = self._pool_allocated_query(edge_fields=edge_fields) + rows: list[dict[str, Any]] = [] page_number = 1 - while has_remaining_items: + while True: page_offset = (page_number - 1) * self._client.pagination_size - response = self._client.execute_graphql( query=query_str, variables={ @@ -3477,27 +3558,13 @@ def get_pool_allocated_resources(self, resource: InfrahubNodeSync) -> list[Infra "limit": self._client.pagination_size, }, branch_name=self._branch, - tracker=f"get-allocated-resources-page{page_number}", + tracker=f"{tracker}-page{page_number}", ) - - for edge in response[graphql_query_name]["edges"]: - node = edge["node"] - node_ids_per_kind.setdefault(node["kind"], []).append(node["id"]) - - remaining_items = response[graphql_query_name].get("count", 0) - ( - page_offset + self._client.pagination_size - ) - if remaining_items <= 0: - has_remaining_items = False - + rows.extend(edge["node"] for edge in response[POOL_ALLOCATED_QUERY_NAME]["edges"]) + if response[POOL_ALLOCATED_QUERY_NAME].get("count", 0) <= page_offset + self._client.pagination_size: + return rows page_number += 1 - nodes: list[InfrahubNodeSync] = [] - for kind, node_ids in node_ids_per_kind.items(): - nodes.extend(self._client.filters(kind=kind, branch=self._branch, ids=node_ids)) - - return nodes - def get_pool_resources_utilization(self) -> list[dict[str, Any]]: """Fetch the utilization of each resource for the pool. diff --git a/infrahub_sdk/node/related_node.py b/infrahub_sdk/node/related_node.py index 06b344046..cbfebd5e8 100644 --- a/infrahub_sdk/node/related_node.py +++ b/infrahub_sdk/node/related_node.py @@ -7,7 +7,7 @@ from ..exceptions import Error from ..protocols_base import CoreNodeBase -from .constants import PROFILE_KIND_PREFIX, PROPERTIES_FLAG, PROPERTIES_OBJECT +from .constants import INLINE_INPUT_PEER_KINDS, PROFILE_KIND_PREFIX, PROPERTIES_FLAG, PROPERTIES_OBJECT from .metadata import NodeMetadata, RelationshipMetadata if TYPE_CHECKING: @@ -32,6 +32,13 @@ def _is_peer_node(data: Any) -> bool: return isinstance(data, (CoreNodeBase, InfrahubNodeBase)) +def _inline_input(schema: RelationshipSchemaAPI, data: Any) -> dict[str, Any] | None: + """Return the peer's fields when its relationship takes them as mutation input instead of a reference.""" + if schema.peer in INLINE_INPUT_PEER_KINDS and isinstance(data, dict) and not data.keys() & {"id", "hfid", "node"}: + return dict(data) + return None + + def _peer_input(data: Any) -> Any: """Narrow another related node to its peer identifiers; pass anything else through. @@ -108,12 +115,12 @@ def __init__(self, branch: str, schema: RelationshipSchemaAPI, data: Any | dict, self._peer_has_been_mutated: bool = False data = _peer_input(data) + self._inline_input = _inline_input(schema=schema, data=data) if _is_peer_node(data): self._peer = cast("InfrahubNodeBase | CoreNodeBase", data) for prop in self._properties: setattr(self, prop, None) - self._relationship_metadata = None elif isinstance(data, list): data = {"hfid": data} @@ -351,6 +358,9 @@ def get_relationship_metadata(self) -> RelationshipMetadata | None: return self._relationship_metadata def _generate_input_data(self, allocate_from_pool: bool = False) -> dict[str, Any]: + if self._inline_input is not None: + return dict(self._inline_input) + data: dict[str, Any] = {} if self.is_resource_pool and allocate_from_pool: diff --git a/infrahub_sdk/protocols_base.py b/infrahub_sdk/protocols_base.py index efb42d8c5..83e6e5f33 100644 --- a/infrahub_sdk/protocols_base.py +++ b/infrahub_sdk/protocols_base.py @@ -125,10 +125,18 @@ class EnumOptional(Attribute): class Integer(Attribute): value: int + def attach_to_pool(self, pool: CoreNodeBase | str) -> None: ... + + def detach_from_pool(self) -> None: ... + class IntegerOptional(Attribute): value: int | None + def attach_to_pool(self, pool: CoreNodeBase | str) -> None: ... + + def detach_from_pool(self) -> None: ... + class IPHost(Attribute): value: ipaddress.IPv4Interface | ipaddress.IPv6Interface diff --git a/tests/integration/test_number_pool_attach_ranges.py b/tests/integration/test_number_pool_attach_ranges.py new file mode 100644 index 000000000..e2670c5c8 --- /dev/null +++ b/tests/integration/test_number_pool_attach_ranges.py @@ -0,0 +1,140 @@ +"""Number pool ranges declared on create and upsert, attaching and detaching numbers, and allocation provenance.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pytest + +from infrahub_sdk.constants import PoolAllocationProvenance +from infrahub_sdk.testing.docker import TestInfrahubDockerClient, skip_version + +if TYPE_CHECKING: + from infrahub_sdk import InfrahubClient, InfrahubClientSync + from infrahub_sdk.node import InfrahubNode + +VLAN_KIND = "TestingPoolVlan" +VLAN_SCHEMA: dict[str, Any] = { + "version": "1.0", + "nodes": [ + { + "name": "PoolVlan", + "namespace": "Testing", + "human_friendly_id": ["name__value"], + "display_label": "{{ name__value }}", + "attributes": [ + {"name": "name", "kind": "Text", "unique": True}, + {"name": "vlan_id", "kind": "Number", "optional": True}, + ], + } + ], +} + + +def _bounds(range_nodes: list[Any]) -> list[tuple[int | None, int | None, int | None]]: + return sorted((r.start.value, r.end.value, r.allocation_weight.value) for r in range_nodes) + + +@pytest.mark.skipif(skip_version(min_infrahub_version="1.12"), reason="Requires Infrahub 1.12 or later") +class TestNumberPoolAttachRanges(TestInfrahubDockerClient): + @pytest.fixture(scope="class") + async def load_vlan_schema(self, default_branch: str, client: InfrahubClient) -> None: + await client.schema.wait_until_converged(branch=default_branch) + resp = await client.schema.load(schemas=[VLAN_SCHEMA], branch=default_branch, wait_until_converged=True) + assert resp.errors == {} + + @pytest.fixture(scope="class") + async def vlan_pool(self, client: InfrahubClient, load_vlan_schema: None) -> InfrahubNode: + pool = await client.create( + kind="CoreNumberPool", + name="VLAN ID Pool", + node=VLAN_KIND, + node_attribute="vlan_id", + ranges=[{"start": 100, "end": 199, "allocation_weight": 10}, {"start": 200, "end": 299}], + ) + await pool.save() + return pool + + async def test_create_stores_declared_ranges(self, client: InfrahubClient, vlan_pool: InfrahubNode) -> None: + ranges = await client.filters(kind="CoreNumberPoolRange", pool__ids=[vlan_pool.id]) + + assert _bounds(ranges) == [(100, 199, 10), (200, 299, None)] + + async def test_upsert_sets_declared_ranges(self, client: InfrahubClient, load_vlan_schema: None) -> None: + pool = await client.create( + kind="CoreNumberPool", + name="Upserted Pool", + node=VLAN_KIND, + node_attribute="vlan_id", + ranges=[{"start": 1000, "end": 1099, "allocation_weight": 10}, {"start": 1100, "end": 1199}], + ) + await pool.save() + upsert = await client.create( + kind="CoreNumberPool", + name="Upserted Pool", + node=VLAN_KIND, + node_attribute="vlan_id", + ranges=[{"start": 1000, "end": 1099, "allocation_weight": 20}, {"start": 1200}], + ) + + await upsert.save(allow_upsert=True) + + ranges = await client.filters(kind="CoreNumberPoolRange", pool__ids=[pool.id]) + assert _bounds(ranges) == [(1000, 1099, 20), (1200, None, None)] + + async def test_attach_detach_and_provenance(self, client: InfrahubClient, vlan_pool: InfrahubNode) -> None: + allocated = await client.create(kind=VLAN_KIND, name="allocated", vlan_id={"from_pool": {"id": vlan_pool.id}}) + await allocated.save() + provided = await client.create( + kind=VLAN_KIND, name="provided", vlan_id={"value": 150, "from_pool": {"id": vlan_pool.id}} + ) + await provided.save() + plain = await client.create(kind=VLAN_KIND, name="plain", vlan_id=160) + await plain.save() + fetched_plain = await client.get(kind=VLAN_KIND, id=plain.id) + fetched_plain.vlan_id.attach_to_pool(vlan_pool) + await fetched_plain.save() + + allocations = {a.node_id: a for a in await vlan_pool.get_pool_allocations(resource=vlan_pool)} + assert allocations[allocated.id].provenance == PoolAllocationProvenance.ALLOCATED + assert allocations[allocated.id].display_label == str(allocated.vlan_id.value) + assert allocations[provided.id].provenance == PoolAllocationProvenance.PROVIDED + assert allocations[plain.id].provenance == PoolAllocationProvenance.PROVIDED + with_properties = await client.get(kind=VLAN_KIND, id=provided.id, property=True) + assert with_properties.vlan_id.source is not None + assert with_properties.vlan_id.source.id == vlan_pool.id + + to_detach = await client.get(kind=VLAN_KIND, id=provided.id) + to_detach.vlan_id.detach_from_pool() + await to_detach.save() + + allocations = {a.node_id: a for a in await vlan_pool.get_pool_allocations(resource=vlan_pool)} + assert provided.id not in allocations + assert (await client.get(kind=VLAN_KIND, id=provided.id)).vlan_id.value == 150 + + async def test_detach_before_first_save_keeps_the_number( + self, client: InfrahubClient, vlan_pool: InfrahubNode + ) -> None: + vlan = await client.create( + kind=VLAN_KIND, name="detached-before-save", vlan_id={"value": 180, "from_pool": {"id": vlan_pool.id}} + ) + vlan.vlan_id.detach_from_pool() + + await vlan.save() + + assert (await client.get(kind=VLAN_KIND, id=vlan.id)).vlan_id.value == 180 + assert vlan.id not in {a.node_id for a in await vlan_pool.get_pool_allocations(resource=vlan_pool)} + + def test_attach_detach_sync(self, client_sync: InfrahubClientSync, vlan_pool: InfrahubNode) -> None: + vlan = client_sync.create(kind=VLAN_KIND, name="sync", vlan_id=170) + vlan.save() + vlan.vlan_id.attach_to_pool(vlan_pool.id) + vlan.save() + pool = client_sync.get(kind="CoreNumberPool", id=vlan_pool.id) + + assert vlan.id in {a.node_id for a in pool.get_pool_allocations(resource=pool)} + + vlan.vlan_id.detach_from_pool() + vlan.save() + + assert vlan.id not in {a.node_id for a in pool.get_pool_allocations(resource=pool)} diff --git a/tests/unit/sdk/pool/conftest.py b/tests/unit/sdk/pool/conftest.py index 43ab113c1..55c6b565d 100644 --- a/tests/unit/sdk/pool/conftest.py +++ b/tests/unit/sdk/pool/conftest.py @@ -116,6 +116,39 @@ async def ipprefix_pool_schema() -> NodeSchemaAPI: return NodeSchema(**data).convert_api() +@pytest.fixture +async def number_pool_schema() -> NodeSchemaAPI: + data: dict[str, Any] = { + "name": "NumberPool", + "namespace": "Core", + "description": "A pool of number resources", + "label": "Number Pool", + "include_in_menu": False, + "branch": BranchSupportType.AGNOSTIC.value, + "inherit_from": ["CoreResourcePool", "LineageSource"], + "human_friendly_id": ["name__value"], + "attributes": [ + {"name": "name", "kind": "Text", "unique": True}, + {"name": "node", "kind": "Text"}, + {"name": "node_attribute", "kind": "Text"}, + {"name": "start_range", "kind": "Number", "optional": True}, + {"name": "end_range", "kind": "Number", "optional": True}, + ], + "relationships": [ + { + "name": "ranges", + "peer": "CoreNumberPoolRange", + "kind": "Component", + "identifier": "numberpool__range", + "cardinality": "many", + "branch": BranchSupportType.AGNOSTIC.value, + "optional": True, + }, + ], + } + return NodeSchema(**data).convert_api() + + @pytest.fixture async def vlan_schema_with_pool_hfid() -> NodeSchemaAPI: """VLAN schema where vlan_id (NumberPool-sourced) is part of the human_friendly_id.""" diff --git a/tests/unit/sdk/pool/test_attribute_from_pool.py b/tests/unit/sdk/pool/test_attribute_from_pool.py index cc8c70c1d..3fa798cd3 100644 --- a/tests/unit/sdk/pool/test_attribute_from_pool.py +++ b/tests/unit/sdk/pool/test_attribute_from_pool.py @@ -16,7 +16,7 @@ import pytest -from infrahub_sdk.exceptions import ValidationError +from infrahub_sdk.exceptions import GraphQLError, ValidationError from infrahub_sdk.node import InfrahubNode, InfrahubNodeSync if TYPE_CHECKING: @@ -24,6 +24,7 @@ from infrahub_sdk import InfrahubClient, InfrahubClientSync from infrahub_sdk.schema import NodeSchemaAPI + from tests.unit.sdk.conftest import BothClients POOL_ID = "185b9728-1b76-dda7-d13d-106529b1bcd9" @@ -324,6 +325,143 @@ async def test_create_upsert_raises_when_numberpool_attr_in_hfid( await node.create(allow_upsert=True) +client_types = ["standard", "sync"] + +FETCHED_VLAN_ID = "vvvvvvvv-vvvv-vvvv-vvvv-vvvvvvvvvvvv" +FETCHED_VLAN_DATA: dict[str, Any] = { + "id": FETCHED_VLAN_ID, + "name": {"value": "Example VLAN"}, + "vlan_id": {"value": 100}, +} + + +def _make_vlan( + clients: BothClients, client_type: str, schema: NodeSchemaAPI, data: dict[str, Any] +) -> InfrahubNode | InfrahubNodeSync: + if client_type == "standard": + return InfrahubNode(client=clients.standard, schema=schema, data=data) + return InfrahubNodeSync(client=clients.sync, schema=schema, data=data) + + +async def _save(node: InfrahubNode | InfrahubNodeSync) -> None: + if isinstance(node, InfrahubNode): + await node.save() + else: + node.save() + + +@pytest.mark.parametrize("client_type", client_types) +async def test_create_with_value_and_from_pool_attaches( + clients: BothClients, vlan_schema: NodeSchemaAPI, client_type: str +) -> None: + node = _make_vlan( + clients, + client_type, + vlan_schema, + {"name": "Example VLAN", "vlan_id": {"value": 100, "from_pool": {"id": POOL_ID}}}, + ) + + input_data = node._generate_input_data()["data"]["data"] + + assert input_data["vlan_id"] == {"value": 100, "from_pool": {"id": POOL_ID}} + + +@pytest.mark.parametrize("client_type", client_types) +async def test_update_after_allocation_does_not_resend_from_pool( + clients: BothClients, vlan_schema: NodeSchemaAPI, httpx_mock: HTTPXMock, client_type: str +) -> None: + httpx_mock.add_response( + method="POST", + json={"data": {"InfraVLANCreate": {"ok": True, "object": {"id": FETCHED_VLAN_ID, "vlan_id": {"value": 100}}}}}, + match_headers={"X-Infrahub-Tracker": "mutation-infravlan-create"}, + ) + node = _make_vlan( + clients, client_type, vlan_schema, {"name": "Example VLAN", "vlan_id": {"from_pool": {"id": POOL_ID}}} + ) + + await _save(node) + + vlan_id = node._generate_input_data(exclude_unmodified=True)["data"]["data"]["vlan_id"] + assert vlan_id == {"value": 100} + assert node._get_attribute("vlan_id").is_from_pool_attribute() is True + + +@pytest.mark.parametrize("client_type", client_types) +async def test_attach_on_fetched_node_keeps_unchanged_value( + clients: BothClients, vlan_schema: NodeSchemaAPI, client_type: str +) -> None: + node = _make_vlan(clients, client_type, vlan_schema, FETCHED_VLAN_DATA) + + node._get_attribute("vlan_id").attach_to_pool(POOL_ID) + + input_data = node._generate_input_data(exclude_unmodified=True)["data"]["data"] + assert input_data == {"id": FETCHED_VLAN_ID, "vlan_id": {"value": 100, "from_pool": {"id": POOL_ID}}} + + +@pytest.mark.parametrize("client_type", client_types) +async def test_detach_on_fetched_node(clients: BothClients, vlan_schema: NodeSchemaAPI, client_type: str) -> None: + node = _make_vlan(clients, client_type, vlan_schema, FETCHED_VLAN_DATA) + + node._get_attribute("vlan_id").detach_from_pool() + + input_data = node._generate_input_data(exclude_unmodified=True)["data"]["data"] + assert input_data == {"id": FETCHED_VLAN_ID, "vlan_id": {"from_pool": None}} + assert node._get_attribute("vlan_id").value == 100 + + +@pytest.mark.parametrize("client_type", client_types) +async def test_detach_before_first_save_keeps_the_value( + clients: BothClients, vlan_schema: NodeSchemaAPI, client_type: str +) -> None: + node = _make_vlan( + clients, + client_type, + vlan_schema, + {"name": "Example VLAN", "vlan_id": {"value": 100, "from_pool": {"id": POOL_ID}}}, + ) + + node._get_attribute("vlan_id").detach_from_pool() + + input_data = node._generate_input_data()["data"]["data"] + assert input_data["vlan_id"] == {"value": 100, "from_pool": None} + + +@pytest.mark.parametrize("client_type", client_types) +async def test_saved_detach_is_not_resent( + clients: BothClients, vlan_schema: NodeSchemaAPI, httpx_mock: HTTPXMock, client_type: str +) -> None: + httpx_mock.add_response( + method="POST", + json={"data": {"InfraVLANUpdate": {"ok": True, "object": {"id": FETCHED_VLAN_ID}}}}, + match_headers={"X-Infrahub-Tracker": "mutation-infravlan-update"}, + ) + node = _make_vlan(clients, client_type, vlan_schema, FETCHED_VLAN_DATA) + node._get_attribute("vlan_id").detach_from_pool() + + await _save(node) + + assert node._generate_input_data(exclude_unmodified=True)["data"]["data"] == {"id": FETCHED_VLAN_ID} + + +@pytest.mark.parametrize("client_type", client_types) +async def test_failed_save_keeps_pool_write_pending( + clients: BothClients, vlan_schema: NodeSchemaAPI, httpx_mock: HTTPXMock, client_type: str +) -> None: + httpx_mock.add_response( + method="POST", + json={"errors": [{"message": "pool refused the number"}]}, + match_headers={"X-Infrahub-Tracker": "mutation-infravlan-update"}, + ) + node = _make_vlan(clients, client_type, vlan_schema, FETCHED_VLAN_DATA) + node._get_attribute("vlan_id").attach_to_pool(POOL_ID) + + with pytest.raises(GraphQLError, match="pool refused the number"): + await _save(node) + + vlan_id = node._generate_input_data(exclude_unmodified=True)["data"]["data"]["vlan_id"] + assert vlan_id == {"value": 100, "from_pool": {"id": POOL_ID}} + + def test_create_upsert_raises_when_numberpool_attr_in_hfid_sync( client_sync: InfrahubClientSync, vlan_schema_with_pool_hfid: NodeSchemaAPI, diff --git a/tests/unit/sdk/pool/test_number_pool_ranges.py b/tests/unit/sdk/pool/test_number_pool_ranges.py new file mode 100644 index 000000000..cd5382503 --- /dev/null +++ b/tests/unit/sdk/pool/test_number_pool_ranges.py @@ -0,0 +1,157 @@ +"""Tests that the ranges declared on a CoreNumberPool are sent as bounds, as its mutations take them.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +import pytest + +from infrahub_sdk.node import InfrahubNode, InfrahubNodeSync + +if TYPE_CHECKING: + from pytest_httpx import HTTPXMock + + from infrahub_sdk.schema import NodeSchemaAPI + from tests.unit.sdk.conftest import BothClients + +client_types = ["standard", "sync"] + +POOL_ID = "nnnnnnnn-nnnn-nnnn-nnnn-nnnnnnnnnnnn" +POOL_DATA: dict[str, Any] = {"name": "VLAN IDs", "node": "InfraVLAN", "node_attribute": "vlan_id"} +# Wrapped as a query edge: the pool's own `node` attribute would otherwise be read as the edge's node. +FETCHED_POOL_DATA: dict[str, Any] = { + "node": { + "id": POOL_ID, + "name": {"value": "VLAN IDs"}, + "node": {"value": "InfraVLAN"}, + "node_attribute": {"value": "vlan_id"}, + "ranges": { + "edges": [ + {"node": {"id": "range-1", "display_label": "64512 - 65000", "__typename": "CoreNumberPoolRange"}}, + ] + }, + } +} + + +def _make_pool( + clients: BothClients, client_type: str, schema: NodeSchemaAPI, data: dict[str, Any] +) -> InfrahubNode | InfrahubNodeSync: + if client_type == "standard": + return InfrahubNode(client=clients.standard, schema=schema, data=data) + return InfrahubNodeSync(client=clients.sync, schema=schema, data=data) + + +async def _save(node: InfrahubNode | InfrahubNodeSync, allow_upsert: bool = False) -> None: + if isinstance(node, InfrahubNode): + await node.save(allow_upsert=allow_upsert) + else: + node.save(allow_upsert=allow_upsert) + + +@pytest.mark.parametrize("client_type", client_types) +async def test_create_sends_declared_ranges( + clients: BothClients, number_pool_schema: NodeSchemaAPI, client_type: str +) -> None: + ranges = [{"start": 64512, "end": 65000, "allocation_weight": 10}, {"start": 65001}] + pool = _make_pool(clients, client_type, number_pool_schema, {**POOL_DATA, "ranges": ranges}) + + input_data = pool._generate_input_data()["data"]["data"] + + assert input_data["ranges"] == ranges + + +@pytest.mark.parametrize("client_type", client_types) +async def test_upsert_renders_declared_ranges( + clients: BothClients, number_pool_schema: NodeSchemaAPI, httpx_mock: HTTPXMock, client_type: str +) -> None: + httpx_mock.add_response( + method="POST", + json={"data": {"CoreNumberPoolUpsert": {"ok": True, "object": {"id": POOL_ID}}}}, + match_headers={"X-Infrahub-Tracker": "mutation-corenumberpool-upsert"}, + ) + pool = _make_pool( + clients, + client_type, + number_pool_schema, + {**POOL_DATA, "ranges": [{"start": 64512, "end": 65000, "allocation_weight": 10}, {"start": 65001}]}, + ) + + await _save(pool, allow_upsert=True) + + request = httpx_mock.get_request(match_headers={"X-Infrahub-Tracker": "mutation-corenumberpool-upsert"}) + assert request is not None + query = " ".join(json.loads(request.content)["query"].split()) + assert "ranges: [ { start: 64512 end: 65000 allocation_weight: 10 }, { start: 65001 }, ]" in query + + +@pytest.mark.parametrize("client_type", client_types) +async def test_empty_ranges_are_sent(clients: BothClients, number_pool_schema: NodeSchemaAPI, client_type: str) -> None: + pool = _make_pool(clients, client_type, number_pool_schema, {**POOL_DATA, "ranges": []}) + + input_data = pool._generate_input_data()["data"]["data"] + + assert input_data["ranges"] == [] + + +@pytest.mark.parametrize("client_type", client_types) +async def test_ranges_left_out_are_not_sent( + clients: BothClients, number_pool_schema: NodeSchemaAPI, client_type: str +) -> None: + pool = _make_pool(clients, client_type, number_pool_schema, POOL_DATA) + + input_data = pool._generate_input_data()["data"]["data"] + + assert "ranges" not in input_data + + +@dataclass +class RangeReferenceCase: + name: str + declared: Any + + +RANGE_REFERENCE_CASES = [ + RangeReferenceCase(name="id-string", declared="range-1"), + RangeReferenceCase(name="id-dict", declared={"id": "range-1"}), +] + + +@pytest.mark.parametrize("client_type", client_types) +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in RANGE_REFERENCE_CASES]) +async def test_range_given_by_id_is_sent_as_reference( + clients: BothClients, number_pool_schema: NodeSchemaAPI, client_type: str, case: RangeReferenceCase +) -> None: + pool = _make_pool(clients, client_type, number_pool_schema, {**POOL_DATA, "ranges": [case.declared]}) + + input_data = pool._generate_input_data()["data"]["data"] + + assert input_data["ranges"] == [{"id": "range-1"}] + + +@pytest.mark.parametrize("client_type", client_types) +async def test_declared_ranges_are_not_resent_after_save( + clients: BothClients, number_pool_schema: NodeSchemaAPI, httpx_mock: HTTPXMock, client_type: str +) -> None: + httpx_mock.add_response( + method="POST", + json={"data": {"CoreNumberPoolCreate": {"ok": True, "object": {"id": POOL_ID}}}}, + match_headers={"X-Infrahub-Tracker": "mutation-corenumberpool-create"}, + ) + pool = _make_pool(clients, client_type, number_pool_schema, {**POOL_DATA, "ranges": [{"start": 1, "end": 10}]}) + + await _save(pool) + + assert "ranges" not in pool._generate_input_data(exclude_unmodified=True)["data"]["data"] + + +@pytest.mark.parametrize("client_type", client_types) +async def test_fetched_pool_keeps_range_peers_and_sends_no_ranges( + clients: BothClients, number_pool_schema: NodeSchemaAPI, client_type: str +) -> None: + pool = _make_pool(clients, client_type, number_pool_schema, FETCHED_POOL_DATA) + + assert pool._get_relationship_many("ranges").peer_ids == ["range-1"] + assert pool._generate_input_data(exclude_unmodified=True)["data"]["data"] == {"id": POOL_ID} diff --git a/tests/unit/sdk/pool/test_pool_queries.py b/tests/unit/sdk/pool/test_pool_queries.py index b4d818a6b..f342dfa34 100644 --- a/tests/unit/sdk/pool/test_pool_queries.py +++ b/tests/unit/sdk/pool/test_pool_queries.py @@ -4,7 +4,8 @@ import pytest -from infrahub_sdk.node import InfrahubNode, InfrahubNodeSync +from infrahub_sdk.constants import PoolAllocationProvenance +from infrahub_sdk.node import InfrahubNode, InfrahubNodeSync, PoolAllocation if TYPE_CHECKING: from typing import Any @@ -115,6 +116,11 @@ async def test_get_pool_allocated_resources( "17d9bd8e-31ee-acf0-2786-179fb76f2f67", ] + # Servers older than 1.12 reject the provenance field. + allocated_request = httpx_mock.get_request(match_headers={"X-Infrahub-Tracker": "get-allocated-resources-page1"}) + assert allocated_request is not None + assert b"provenance" not in allocated_request.content + @pytest.mark.parametrize("client_type", client_types) async def test_get_pool_resources_utilization( @@ -183,3 +189,113 @@ async def test_get_pool_resources_utilization( utilizations = ip_pool.get_pool_resources_utilization() assert len(utilizations) == 1 assert utilizations[0]["utilization"] == pytest.approx(93.75) + + +NUMBER_POOL_ID = "nnnnnnnn-nnnn-nnnn-nnnn-nnnnnnnnnnnn" + + +def _allocation_row(node_id: str, number: int, provenance: str | None) -> dict[str, Any]: + return { + "node": { + "id": node_id, + "kind": "InfraVLAN", + "branch": "main", + "display_label": str(number), + "identifier": None, + "provenance": provenance, + } + } + + +@pytest.mark.parametrize("client_type", client_types) +async def test_get_pool_allocations( + httpx_mock: HTTPXMock, + clients: BothClients, + number_pool_schema: NodeSchemaAPI, + client_type: str, +) -> None: + httpx_mock.add_response( + method="POST", + json={ + "data": { + "InfrahubResourcePoolAllocated": { + "count": 4, + "edges": [ + _allocation_row(node_id="vlan-1", number=100, provenance="ALLOCATED"), + _allocation_row(node_id="vlan-2", number=101, provenance="PROVIDED"), + _allocation_row(node_id="vlan-3", number=102, provenance="ALLOCATED"), + ], + } + } + }, + match_headers={"X-Infrahub-Tracker": "get-pool-allocations-page1"}, + ) + httpx_mock.add_response( + method="POST", + json={ + "data": { + "InfrahubResourcePoolAllocated": { + "count": 4, + "edges": [_allocation_row(node_id="vlan-4", number=103, provenance=None)], + } + } + }, + match_headers={"X-Infrahub-Tracker": "get-pool-allocations-page2"}, + ) + pool_data = {"id": NUMBER_POOL_ID, "name": "VLAN IDs", "node": "InfraVLAN", "node_attribute": "vlan_id"} + + if client_type == "standard": + pool = InfrahubNode(client=clients.standard, schema=number_pool_schema, data=pool_data) + allocations = await pool.get_pool_allocations(resource=pool) + else: + pool_sync = InfrahubNodeSync(client=clients.sync, schema=number_pool_schema, data=pool_data) + allocations = pool_sync.get_pool_allocations(resource=pool_sync) + + assert allocations == [ + PoolAllocation( + node_id="vlan-1", + kind="InfraVLAN", + branch="main", + display_label="100", + identifier=None, + provenance=PoolAllocationProvenance.ALLOCATED, + ), + PoolAllocation( + node_id="vlan-2", + kind="InfraVLAN", + branch="main", + display_label="101", + identifier=None, + provenance=PoolAllocationProvenance.PROVIDED, + ), + PoolAllocation( + node_id="vlan-3", + kind="InfraVLAN", + branch="main", + display_label="102", + identifier=None, + provenance=PoolAllocationProvenance.ALLOCATED, + ), + PoolAllocation( + node_id="vlan-4", kind="InfraVLAN", branch="main", display_label="103", identifier=None, provenance=None + ), + ] + first_request = httpx_mock.get_request(match_headers={"X-Infrahub-Tracker": "get-pool-allocations-page1"}) + assert first_request is not None + assert b"provenance" in first_request.content + + +@pytest.mark.parametrize("client_type", client_types) +async def test_get_pool_allocations_requires_a_pool( + clients: BothClients, + vlan_schema: NodeSchemaAPI, + client_type: str, +) -> None: + if client_type == "standard": + vlan = InfrahubNode(client=clients.standard, schema=vlan_schema, data={"id": "vlan-1", "name": "VLAN 1"}) + with pytest.raises(ValueError, match="can only be fetched from resource pool nodes"): + await vlan.get_pool_allocations(resource=vlan) + else: + vlan_sync = InfrahubNodeSync(client=clients.sync, schema=vlan_schema, data={"id": "vlan-1", "name": "VLAN 1"}) + with pytest.raises(ValueError, match="can only be fetched from resource pool nodes"): + vlan_sync.get_pool_allocations(resource=vlan_sync) diff --git a/tests/unit/sdk/test_attribute_generate_input_data.py b/tests/unit/sdk/test_attribute_generate_input_data.py index 394623fc5..8c8457e44 100644 --- a/tests/unit/sdk/test_attribute_generate_input_data.py +++ b/tests/unit/sdk/test_attribute_generate_input_data.py @@ -327,6 +327,116 @@ def test_properties_appended_for_from_pool(self) -> None: assert result.payload["is_protected"] is True +# ────────────────────────────────────────────── +# Attaching a held number to a pool +# ────────────────────────────────────────────── + + +class TestAttachToPool: + def test_from_pool_dict_with_value_sends_both(self) -> None: + attr = Attribute( + name="vlan_id", + schema=_make_schema(AttributeKind.NUMBER), + data={"value": 100, "from_pool": {"id": "pool-uuid-1"}}, + ) + + result = attr._generate_input_data() + + assert result.payload == {"value": 100, "from_pool": {"id": "pool-uuid-1"}} + + def test_attach_to_pool_by_id(self) -> None: + attr = Attribute(name="vlan_id", schema=_make_schema(AttributeKind.NUMBER), data={"value": 100}) + + attr.attach_to_pool("pool-uuid-1") + + assert attr._generate_input_data().payload == {"value": 100, "from_pool": {"id": "pool-uuid-1"}} + assert attr.is_from_pool_attribute() is True + assert attr.is_unresolved_pool_attribute() is False + + def test_attach_to_pool_by_node(self) -> None: + attr = Attribute(name="vlan_id", schema=_make_schema(AttributeKind.NUMBER), data={"value": 100}) + + attr.attach_to_pool(_FakeNode(node_id="pool-uuid-1", is_pool=True)) + + assert attr._generate_input_data().payload == {"value": 100, "from_pool": {"id": "pool-uuid-1"}} + + def test_attach_to_pool_without_value_raises(self) -> None: + attr = Attribute(name="vlan_id", schema=_make_schema(AttributeKind.NUMBER, optional=True), data=None) + + with pytest.raises(ValueError, match="holds no number to attach"): + attr.attach_to_pool("pool-uuid-1") + + def test_attach_to_pool_with_pool_node_value_raises(self) -> None: + attr = Attribute( + name="vlan_id", + schema=_make_schema(AttributeKind.NUMBER), + data=_FakeNode(node_id="pool-uuid-1", is_pool=True), + ) + + with pytest.raises(ValueError, match="holds no number to attach"): + attr.attach_to_pool("pool-uuid-2") + + def test_attach_to_unsaved_pool_raises(self) -> None: + attr = Attribute(name="vlan_id", schema=_make_schema(AttributeKind.NUMBER), data={"value": 100}) + + with pytest.raises(ValueError, match="before attaching a number to it"): + attr.attach_to_pool(_FakeNode(node_id="", is_pool=True)) + + def test_attach_to_non_pool_node_raises(self) -> None: + attr = Attribute(name="vlan_id", schema=_make_schema(AttributeKind.NUMBER), data={"value": 100}) + + with pytest.raises(ValueError, match="is not a resource pool"): + attr.attach_to_pool(_FakeNode(node_id="node-uuid-1", is_pool=False)) + + def test_sent_attach_is_not_sent_again(self) -> None: + attr = Attribute(name="vlan_id", schema=_make_schema(AttributeKind.NUMBER), data={"value": 100}) + attr.attach_to_pool("pool-uuid-1") + + attr._mark_pool_write_sent() + + assert attr._generate_input_data().payload == {"value": 100} + assert attr.is_from_pool_attribute() is True + + +# ────────────────────────────────────────────── +# Detaching a held number from its pool +# ────────────────────────────────────────────── + + +class TestDetachFromPool: + def test_detach_without_a_saved_node_keeps_the_value(self) -> None: + attr = Attribute(name="vlan_id", schema=_make_schema(AttributeKind.NUMBER), data={"value": 100}) + + attr.detach_from_pool() + + assert attr._generate_input_data().payload == {"value": 100, "from_pool": None} + assert attr.is_from_pool_attribute() is False + + def test_detach_with_value_change_sends_both(self) -> None: + attr = Attribute(name="vlan_id", schema=_make_schema(AttributeKind.NUMBER), data={"value": 100}) + attr.value = 200 + + attr.detach_from_pool() + + assert attr._generate_input_data().payload == {"value": 200, "from_pool": None} + + def test_detach_replaces_pending_attach(self) -> None: + attr = Attribute(name="vlan_id", schema=_make_schema(AttributeKind.NUMBER), data={"value": 100}) + attr.attach_to_pool("pool-uuid-1") + + attr.detach_from_pool() + + assert attr._generate_input_data().payload == {"value": 100, "from_pool": None} + + def test_sent_detach_is_not_sent_again(self) -> None: + attr = Attribute(name="vlan_id", schema=_make_schema(AttributeKind.NUMBER), data={"value": 100}) + attr.detach_from_pool() + + attr._mark_pool_write_sent() + + assert attr._generate_input_data().payload == {"value": 100} + + # ────────────────────────────────────────────── # Return type: to_dict() integration # ──────────────────────────────────────────────