From b662240e5e83370503fe5b9fa6553b1341f04b30 Mon Sep 17 00:00:00 2001 From: Aaron McCarty Date: Fri, 6 Mar 2026 18:45:07 -0800 Subject: [PATCH 1/4] don't allocate from pools on templates --- infrahub_sdk/node/node.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/infrahub_sdk/node/node.py b/infrahub_sdk/node/node.py index a47209dc..83201e47 100644 --- a/infrahub_sdk/node/node.py +++ b/infrahub_sdk/node/node.py @@ -311,7 +311,17 @@ def _generate_input_data( # noqa: C901 if graphql_payload.variables: variables.update(graphql_payload.variables) + # For template schemas, skip _from_resource_pool relationships in mutation data. + # Pool references use from_pool syntax on the regular relationship field instead. + is_template = isinstance(self._schema, TemplateSchemaAPI) + pool_rel_names: set[str] = set() + if is_template: + pool_rel_names = {r.name for r in self._schema.relationships if r.name.endswith("_from_resource_pool")} + for item_name in self._relationships: + if item_name in pool_rel_names: + continue + allocate_from_pool = False rel_schema = self._schema.get_relationship(name=item_name) if not rel_schema or rel_schema.read_only: @@ -1097,7 +1107,10 @@ def _generate_mutation_query(self) -> dict[str, Any]: attr: Attribute = getattr(self, attr_name) query_result["object"].update(attr._generate_mutation_query()) + is_template = isinstance(self._schema, TemplateSchemaAPI) for rel_name in self._relationships: + if is_template: + continue rel = getattr(self, rel_name) if not isinstance(rel, RelatedNode): continue @@ -1113,11 +1126,17 @@ async def _process_mutation_result( self.id = object_response["id"] self._existing = True + is_template = isinstance(self._schema, TemplateSchemaAPI) + for attr_name in self._attributes: attr = getattr(self, attr_name) if attr_name not in object_response or not attr.is_from_pool_attribute(): continue + # Templates store pool references without allocating values + if is_template: + continue + # Process allocated resource from a pool and update attribute attr.value = object_response[attr_name]["value"] @@ -1126,6 +1145,10 @@ async def _process_mutation_result( if rel_name not in object_response or not isinstance(rel, RelatedNode) or not rel.is_resource_pool: continue + # Templates store pool references without allocating + if is_template: + continue + # Process allocated resource from a pool and update related node allocated_resource = object_response[rel_name] related_node = RelatedNode( @@ -1980,7 +2003,10 @@ def _generate_mutation_query(self) -> dict[str, Any]: attr: Attribute = getattr(self, attr_name) query_result["object"].update(attr._generate_mutation_query()) + is_template = isinstance(self._schema, TemplateSchemaAPI) for rel_name in self._relationships: + if is_template: + continue rel = getattr(self, rel_name) if not isinstance(rel, RelatedNodeSync): continue @@ -1996,11 +2022,17 @@ def _process_mutation_result( self.id = object_response["id"] self._existing = True + is_template = isinstance(self._schema, TemplateSchemaAPI) + for attr_name in self._attributes: attr = getattr(self, attr_name) if attr_name not in object_response or not attr.is_from_pool_attribute(): continue + # Templates store pool references without allocating values + if is_template: + continue + # Process allocated resource from a pool and update attribute attr.value = object_response[attr_name]["value"] @@ -2009,6 +2041,10 @@ def _process_mutation_result( if rel_name not in object_response or not isinstance(rel, RelatedNodeSync) or not rel.is_resource_pool: continue + # Templates store pool references without allocating + if is_template: + continue + # Process allocated resource from a pool and update related node allocated_resource = object_response[rel_name] related_node = RelatedNodeSync( From 9b7b5fc867857e87a30f6f882b8f739689e19621 Mon Sep 17 00:00:00 2001 From: Aaron McCarty Date: Fri, 6 Mar 2026 18:49:41 -0800 Subject: [PATCH 2/4] add ruff exception --- infrahub_sdk/node/node.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrahub_sdk/node/node.py b/infrahub_sdk/node/node.py index 83201e47..4a556d11 100644 --- a/infrahub_sdk/node/node.py +++ b/infrahub_sdk/node/node.py @@ -286,7 +286,7 @@ def _get_file_for_upload_sync(self) -> PreparedFile: def get_raw_graphql_data(self) -> dict | None: return self._data - def _generate_input_data( # noqa: C901 + def _generate_input_data( # noqa: C901,PLR0915 self, exclude_unmodified: bool = False, exclude_hfid: bool = False, From c36bef8f249e2c92b8bf504ad233413770726096 Mon Sep 17 00:00:00 2001 From: Aaron McCarty Date: Sat, 7 Mar 2026 15:28:09 -0800 Subject: [PATCH 3/4] remove hopefully unnecessary changes --- infrahub_sdk/node/node.py | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/infrahub_sdk/node/node.py b/infrahub_sdk/node/node.py index 4a556d11..599816e6 100644 --- a/infrahub_sdk/node/node.py +++ b/infrahub_sdk/node/node.py @@ -286,7 +286,7 @@ def _get_file_for_upload_sync(self) -> PreparedFile: def get_raw_graphql_data(self) -> dict | None: return self._data - def _generate_input_data( # noqa: C901,PLR0915 + def _generate_input_data( # noqa: C901 self, exclude_unmodified: bool = False, exclude_hfid: bool = False, @@ -311,17 +311,7 @@ def _generate_input_data( # noqa: C901,PLR0915 if graphql_payload.variables: variables.update(graphql_payload.variables) - # For template schemas, skip _from_resource_pool relationships in mutation data. - # Pool references use from_pool syntax on the regular relationship field instead. - is_template = isinstance(self._schema, TemplateSchemaAPI) - pool_rel_names: set[str] = set() - if is_template: - pool_rel_names = {r.name for r in self._schema.relationships if r.name.endswith("_from_resource_pool")} - for item_name in self._relationships: - if item_name in pool_rel_names: - continue - allocate_from_pool = False rel_schema = self._schema.get_relationship(name=item_name) if not rel_schema or rel_schema.read_only: @@ -1107,10 +1097,7 @@ def _generate_mutation_query(self) -> dict[str, Any]: attr: Attribute = getattr(self, attr_name) query_result["object"].update(attr._generate_mutation_query()) - is_template = isinstance(self._schema, TemplateSchemaAPI) for rel_name in self._relationships: - if is_template: - continue rel = getattr(self, rel_name) if not isinstance(rel, RelatedNode): continue @@ -2003,10 +1990,7 @@ def _generate_mutation_query(self) -> dict[str, Any]: attr: Attribute = getattr(self, attr_name) query_result["object"].update(attr._generate_mutation_query()) - is_template = isinstance(self._schema, TemplateSchemaAPI) for rel_name in self._relationships: - if is_template: - continue rel = getattr(self, rel_name) if not isinstance(rel, RelatedNodeSync): continue From cad0ac0bb638391ce62d397cdab8a21bb4cafc50 Mon Sep 17 00:00:00 2001 From: Pol Michel Date: Sun, 8 Mar 2026 17:24:42 +0100 Subject: [PATCH 4/4] new unit tests: checking that template relationships or attribute ignore value retrieved from the API --- .../test_template_skip_pool_allocation.py | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 tests/unit/sdk/pool/test_template_skip_pool_allocation.py diff --git a/tests/unit/sdk/pool/test_template_skip_pool_allocation.py b/tests/unit/sdk/pool/test_template_skip_pool_allocation.py new file mode 100644 index 00000000..dd02b55f --- /dev/null +++ b/tests/unit/sdk/pool/test_template_skip_pool_allocation.py @@ -0,0 +1,216 @@ +"""Templates store pool references without allocating values. + +When a template node is saved, _process_mutation_result should NOT overwrite +pool-sourced attributes or relationships with server-allocated values. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pytest + +from infrahub_sdk.node import InfrahubNode, InfrahubNodeSync +from infrahub_sdk.schema import AttributeSchemaAPI, RelationshipSchemaAPI, TemplateSchemaAPI +from infrahub_sdk.schema.main import AttributeKind, RelationshipKind + +if TYPE_CHECKING: + from infrahub_sdk import InfrahubClient, InfrahubClientSync + from infrahub_sdk.schema import NodeSchemaAPI + +POOL_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" +NODE_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + +VLAN_MUTATION = "TemplateIpamVLANCreate" +DEVICE_MUTATION = "TemplateInfraDeviceCreate" + + +class TestTemplateAttributeSkipsPoolAllocation: + async def test_async_template_preserves_pool_reference_on_attribute( + self, + client: InfrahubClient, + vlan_template_schema: TemplateSchemaAPI, + ) -> None: + """After save, a template's from_pool attribute should keep the pool reference, not the allocated value.""" + node = InfrahubNode(client=client, schema=vlan_template_schema, data=_vlan_data_with_pool()) + + # Act + await node._process_mutation_result(VLAN_MUTATION, _vlan_mutation_response()) + + assert node.vlan_id.value != 42 + assert node.id == NODE_ID + + async def test_sync_template_preserves_pool_reference_on_attribute( + self, + client_sync: InfrahubClientSync, + vlan_template_schema: TemplateSchemaAPI, + ) -> None: + """After save, a template's from_pool attribute should keep the pool reference, not the allocated value.""" + node = InfrahubNodeSync(client=client_sync, schema=vlan_template_schema, data=_vlan_data_with_pool()) + + # Act + node._process_mutation_result(VLAN_MUTATION, _vlan_mutation_response()) + + assert node.vlan_id.value != 42 + assert node.id == NODE_ID + + async def test_async_regular_node_does_allocate_from_pool( + self, + client: InfrahubClient, + vlan_schema: NodeSchemaAPI, + ) -> None: + """Contrast: a regular node SHOULD update its attribute with the allocated value.""" + node = InfrahubNode(client=client, schema=vlan_schema, data=_vlan_data_with_pool()) + + # Act + await node._process_mutation_result("InfraVLANCreate", _node_vlan_mutation_response()) + + assert node.vlan_id.value == 42 + assert node.id == NODE_ID + + async def test_sync_regular_node_does_allocate_from_pool( + self, + client_sync: InfrahubClientSync, + vlan_schema: NodeSchemaAPI, + ) -> None: + """Contrast: a regular node SHOULD update its attribute with the allocated value.""" + node = InfrahubNodeSync(client=client_sync, schema=vlan_schema, data=_vlan_data_with_pool()) + + # Act + node._process_mutation_result("InfraVLANCreate", _node_vlan_mutation_response()) + + assert node.vlan_id.value == 42 + assert node.id == NODE_ID + + +class TestTemplateRelationshipSkipsPoolAllocation: + async def test_async_template_preserves_pool_reference_on_relationship( + self, + client: InfrahubClient, + device_template_schema: TemplateSchemaAPI, + ipaddress_pool_schema: NodeSchemaAPI, + ipam_ipprefix_schema: NodeSchemaAPI, + ipam_ipprefix_data: dict[str, Any], + ) -> None: + """After save, a template's pool relationship should not be replaced with the allocated resource.""" + ip_prefix = InfrahubNode(client=client, schema=ipam_ipprefix_schema, data=ipam_ipprefix_data) + ip_pool = InfrahubNode(client=client, schema=ipaddress_pool_schema, data=_pool_node_data(ip_prefix)) + node = InfrahubNode( + client=client, schema=device_template_schema, data={"name": "device-template", "primary_address": ip_pool} + ) + original_rel = node.primary_address + + # Act + await node._process_mutation_result(DEVICE_MUTATION, _device_mutation_response()) + + assert node.primary_address is original_rel + assert node.id == NODE_ID + + async def test_sync_template_preserves_pool_reference_on_relationship( + self, + client_sync: InfrahubClientSync, + device_template_schema: TemplateSchemaAPI, + ipaddress_pool_schema: NodeSchemaAPI, + ipam_ipprefix_schema: NodeSchemaAPI, + ipam_ipprefix_data: dict[str, Any], + ) -> None: + """After save, a template's pool relationship should not be replaced with the allocated resource.""" + ip_prefix = InfrahubNodeSync(client=client_sync, schema=ipam_ipprefix_schema, data=ipam_ipprefix_data) + ip_pool = InfrahubNodeSync(client=client_sync, schema=ipaddress_pool_schema, data=_pool_node_data(ip_prefix)) + node = InfrahubNodeSync( + client=client_sync, + schema=device_template_schema, + data={"name": "device-template", "primary_address": ip_pool}, + ) + original_rel = node.primary_address + + # Act + node._process_mutation_result(DEVICE_MUTATION, _device_mutation_response()) + + assert node.primary_address is original_rel + assert node.id == NODE_ID + + +# ────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────── + + +def _vlan_data_with_pool() -> dict[str, Any]: + return {"name": "Standard VLAN", "vlan_id": {"from_pool": {"id": POOL_ID, "identifier": "vlan-id"}}} + + +def _vlan_mutation_response() -> dict[str, Any]: + return {VLAN_MUTATION: {"ok": True, "object": {"id": NODE_ID, "vlan_id": {"value": 42}}}} + + +def _node_vlan_mutation_response() -> dict[str, Any]: + return {"InfraVLANCreate": {"ok": True, "object": {"id": NODE_ID, "vlan_id": {"value": 42}}}} + + +def _device_mutation_response() -> dict[str, Any]: + return { + DEVICE_MUTATION: { + "ok": True, + "object": { + "id": NODE_ID, + "primary_address": { + "node": { + "__typename": "IpamIPAddress", + "id": "cccccccc-cccc-cccc-cccc-cccccccccccc", + "display_label": "10.0.0.1/32", + }, + }, + }, + }, + } + + +def _pool_node_data(ip_prefix: InfrahubNode | InfrahubNodeSync) -> dict[str, Any]: + return { + "id": POOL_ID, + "name": "Core loopbacks", + "default_address_type": "IpamIPAddress", + "default_prefix_length": 32, + "ip_namespace": "ip_namespace", + "resources": [ip_prefix], + } + + +# ────────────────────────────────────────────── +# Fixtures +# ────────────────────────────────────────────── + + +@pytest.fixture +async def vlan_template_schema() -> TemplateSchemaAPI: + return TemplateSchemaAPI( + name="VLAN", + namespace="TemplateIpam", + attributes=[ + AttributeSchemaAPI(name="name", kind=AttributeKind.TEXT, unique=True), + AttributeSchemaAPI(name="vlan_id", kind=AttributeKind.NUMBER), + ], + relationships=[], + ) + + +@pytest.fixture +async def device_template_schema() -> TemplateSchemaAPI: + return TemplateSchemaAPI( + name="Device", + namespace="TemplateInfra", + attributes=[ + AttributeSchemaAPI(name="name", kind=AttributeKind.TEXT, unique=True), + ], + relationships=[ + RelationshipSchemaAPI( + name="primary_address", + peer="IpamIPAddress", + label="Primary IP Address", + optional=True, + cardinality="one", + kind=RelationshipKind.ATTRIBUTE, + ), + ], + )