Skip to content

Commit 05ac997

Browse files
authored
IHS-119: Fix upsert not working for numberpool and attribute in hfid (#1014)
1 parent 59af0f5 commit 05ac997

7 files changed

Lines changed: 276 additions & 1 deletion

File tree

changelog/339.fixed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Calling `.save(allow_upsert=True)` on a node whose human-friendly identifier contains a CoreNumberPool-sourced attribute now raises a clear `ValidationError` instead of crashing with an opaque backend error.

docs/docs/python-sdk/guides/resource-manager.mdx

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,3 +309,58 @@ query {
309309
```
310310

311311
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.
312+
313+
## CoreNumberPool and attribute allocation
314+
315+
`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.
316+
317+
```python
318+
vlan = await client.create(
319+
kind="InfraVLAN",
320+
name="VLAN-100",
321+
vlan_id={"from_pool": {"id": pool_id}},
322+
)
323+
await vlan.save()
324+
```
325+
326+
### Limitation: `allow_upsert=True` with a pool-sourced HFID attribute
327+
328+
`CoreNumberPool` assigns the integer value at server creation time. The SDK does not know the assigned value before the node exists. When a node's human-friendly identifier (HFID) includes a pool-sourced attribute, the SDK cannot construct the HFID needed to look up an existing node for upsert.
329+
330+
:::warning
331+
332+
Calling `save(allow_upsert=True)` on a node whose HFID contains a `CoreNumberPool`-sourced attribute raises `ValidationError` before any network call is made.
333+
334+
```python
335+
# Schema has human_friendly_id: ["vlan_id__value"]
336+
vlan = await client.create(
337+
kind="InfraVLAN",
338+
name="VLAN-100",
339+
vlan_id={"from_pool": {"id": pool_id}},
340+
)
341+
342+
# This raises ValidationError — the pool-assigned vlan_id is unknown client-side
343+
await vlan.save(allow_upsert=True)
344+
```
345+
346+
**Alternatives:**
347+
348+
- **Two-step pattern** — create the node first, then update it in a separate call:
349+
350+
```python
351+
await vlan.save() # creates node, pool assigns vlan_id
352+
# later, if you need to update:
353+
vlan.name.value = "VLAN-100-updated"
354+
await vlan.save() # now _existing=True, calls update
355+
```
356+
357+
- **Explicit id** — if you already know the node's UUID, set `node.id` before saving. The upsert will use the UUID directly and skip HFID lookup:
358+
359+
```python
360+
vlan.id = "known-uuid"
361+
await vlan.save(allow_upsert=True) # guard bypassed
362+
```
363+
364+
- **Deterministic identifier** — if possible, design your schema so the HFID uses a non-pool attribute (for example, a human-assigned `name`) and keep `vlan_id` out of the HFID.
365+
366+
:::

docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/attribute.mdx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,19 @@ Check whether this attribute's value is sourced from a resource pool.
3636
**Returns:**
3737

3838
- True if the attribute value is a resource pool node or was explicitly allocated from a pool.
39+
40+
#### `is_unresolved_pool_attribute`
41+
42+
```python
43+
is_unresolved_pool_attribute(self) -> bool
44+
```
45+
46+
Return True when pool-backed but no concrete scalar value is available yet.
47+
48+
A pool-backed attribute is unresolved when:
49+
50+
- its value is a pool node object (the pool reference itself, not an allocated scalar), or
51+
- its value is None and the from_pool allocation dict is set.
52+
53+
An attribute whose _from_pool dict is set but whose value has already been populated
54+
with the allocated scalar (e.g. after a prior save) is considered resolved.

infrahub_sdk/node/attribute.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,3 +191,17 @@ def is_from_pool_attribute(self) -> bool:
191191
192192
"""
193193
return (isinstance(self.value, CoreNodeBase) and self.value.is_resource_pool()) or self._from_pool is not None
194+
195+
def is_unresolved_pool_attribute(self) -> bool:
196+
"""Return True when pool-backed but no concrete scalar value is available yet.
197+
198+
A pool-backed attribute is unresolved when:
199+
- its value is a pool node object (the pool reference itself, not an allocated scalar), or
200+
- its value is None and the from_pool allocation dict is set.
201+
202+
An attribute whose _from_pool dict is set but whose value has already been populated
203+
with the allocated scalar (e.g. after a prior save) is considered resolved.
204+
"""
205+
if isinstance(self.value, CoreNodeBase) and self.value.is_resource_pool():
206+
return True
207+
return self._from_pool is not None and self.value is None

infrahub_sdk/node/node.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@
77
from typing import TYPE_CHECKING, Any, BinaryIO, overload
88

99
from ..constants import InfrahubClientMode
10-
from ..exceptions import FeatureNotSupportedError, NodeNotFoundError, ResourceNotDefinedError, SchemaNotFoundError
10+
from ..exceptions import (
11+
FeatureNotSupportedError,
12+
NodeNotFoundError,
13+
ResourceNotDefinedError,
14+
SchemaNotFoundError,
15+
ValidationError,
16+
)
1117
from ..file_handler import FileHandler, FileHandlerBase, FileHandlerSync, PreparedFile, sha1_of_source
1218
from ..graphql import Mutation, Query
1319
from ..schema import (
@@ -591,6 +597,36 @@ def _get_attribute(self, name: str) -> Attribute:
591597

592598
raise ResourceNotDefinedError(message=f"The node doesn't have an attribute for {name}")
593599

600+
def _validate_upsert(self, allow_upsert: bool) -> None:
601+
"""Ensure an upsert can resolve the HFID before attempting to save.
602+
603+
An attribute sourced from a CoreNumberPool has no concrete value until the node is
604+
created, so it cannot be used to look up an existing node by its human-friendly identifier.
605+
606+
Raises:
607+
ValidationError: If an HFID attribute is sourced from an unresolved CoreNumberPool.
608+
609+
"""
610+
if not (allow_upsert and not self.id):
611+
return
612+
613+
for hfid_path in self._schema.human_friendly_id or []:
614+
attr_name = hfid_path.split("__")[0]
615+
try:
616+
attr = self._get_attribute(attr_name)
617+
except ResourceNotDefinedError:
618+
continue
619+
if attr.is_unresolved_pool_attribute():
620+
raise ValidationError(
621+
identifier=attr_name,
622+
message=(
623+
f"Attribute '{attr_name}' is sourced from a CoreNumberPool and is part of "
624+
"this node's human-friendly identifier. Upsert cannot resolve the HFID "
625+
"without a concrete value. Use an explicit id, or create the node first "
626+
"and update it in a separate call."
627+
),
628+
)
629+
594630
@staticmethod
595631
def _build_rel_query_data(
596632
rel_schema: RelationshipSchemaAPI,
@@ -1265,6 +1301,8 @@ async def _process_mutation_result(
12651301
async def create(
12661302
self, allow_upsert: bool = False, timeout: int | None = None, request_context: RequestContext | None = None
12671303
) -> None:
1304+
self._validate_upsert(allow_upsert=allow_upsert)
1305+
12681306
if self._file_object_support and self._file_content is None:
12691307
raise ValueError(
12701308
f"Cannot create {self._schema.kind} without file content. Use upload_from_path() or upload_from_bytes() to provide "
@@ -2237,6 +2275,8 @@ def _process_mutation_result(
22372275
def create(
22382276
self, allow_upsert: bool = False, timeout: int | None = None, request_context: RequestContext | None = None
22392277
) -> None:
2278+
self._validate_upsert(allow_upsert=allow_upsert)
2279+
22402280
if self._file_object_support and self._file_content is None:
22412281
raise ValueError(
22422282
f"Cannot create {self._schema.kind} without file content. Use upload_from_path() or upload_from_bytes() to provide "

tests/unit/sdk/pool/conftest.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,23 @@ async def ipprefix_pool_schema() -> NodeSchemaAPI:
114114
],
115115
}
116116
return NodeSchema(**data).convert_api()
117+
118+
119+
@pytest.fixture
120+
async def vlan_schema_with_pool_hfid() -> NodeSchemaAPI:
121+
"""VLAN schema where vlan_id (NumberPool-sourced) is part of the human_friendly_id."""
122+
data: dict[str, Any] = {
123+
"name": "VLAN",
124+
"namespace": "Infra",
125+
"label": "VLAN",
126+
"default_filter": "name__value",
127+
"order_by": ["name__value"],
128+
"display_labels": ["name__value"],
129+
"human_friendly_id": ["vlan_id__value"],
130+
"attributes": [
131+
{"name": "name", "kind": "Text", "unique": True},
132+
{"name": "vlan_id", "kind": "Number"},
133+
],
134+
"relationships": [],
135+
}
136+
return NodeSchema(**data).convert_api()

tests/unit/sdk/pool/test_attribute_from_pool.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,17 @@
1111

1212
from __future__ import annotations
1313

14+
import re
1415
from typing import TYPE_CHECKING, Any
1516

17+
import pytest
18+
19+
from infrahub_sdk.exceptions import ValidationError
1620
from infrahub_sdk.node import InfrahubNode, InfrahubNodeSync
1721

1822
if TYPE_CHECKING:
23+
from pytest_httpx import HTTPXMock
24+
1925
from infrahub_sdk import InfrahubClient, InfrahubClientSync
2026
from infrahub_sdk.schema import NodeSchemaAPI
2127

@@ -204,3 +210,126 @@ async def test_attribute_with_pool_node_generates_mutation_query(
204210
mutation_query = vlan._generate_mutation_query()
205211

206212
assert mutation_query["object"]["vlan_id"] == {"value": None}
213+
214+
215+
UPSERT_MOCK_RESPONSE = {
216+
"data": {
217+
"InfraVLANUpsert": {
218+
"ok": True,
219+
"object": {"id": "mock-vlan-uuid", "vlan_id": {"value": 100}},
220+
}
221+
}
222+
}
223+
224+
225+
async def test_save_upsert_raises_when_numberpool_attr_in_hfid(
226+
client: InfrahubClient,
227+
vlan_schema_with_pool_hfid: NodeSchemaAPI,
228+
) -> None:
229+
"""save(allow_upsert=True) raises ValidationError naming the pool-sourced HFID attribute."""
230+
node = InfrahubNode(
231+
client=client,
232+
schema=vlan_schema_with_pool_hfid,
233+
data={"name": "Test VLAN", "vlan_id": {"from_pool": {"id": POOL_ID}}},
234+
)
235+
236+
with pytest.raises(ValidationError, match=re.escape("Attribute 'vlan_id' is sourced from a CoreNumberPool")):
237+
await node.save(allow_upsert=True)
238+
239+
240+
async def test_save_upsert_proceeds_when_explicit_id_set(
241+
client: InfrahubClient,
242+
vlan_schema_with_pool_hfid: NodeSchemaAPI,
243+
httpx_mock: HTTPXMock,
244+
) -> None:
245+
"""Upsert proceeds when an explicit node id is already set."""
246+
httpx_mock.add_response(method="POST", json=UPSERT_MOCK_RESPONSE)
247+
node = InfrahubNode(
248+
client=client,
249+
schema=vlan_schema_with_pool_hfid,
250+
data={"name": "Test VLAN", "vlan_id": {"from_pool": {"id": POOL_ID}}},
251+
)
252+
node.id = "existing-node-uuid"
253+
254+
await node.save(allow_upsert=True)
255+
256+
assert node.id == "mock-vlan-uuid"
257+
258+
259+
async def test_save_upsert_proceeds_when_numberpool_attr_not_in_hfid(
260+
client: InfrahubClient,
261+
vlan_schema: NodeSchemaAPI,
262+
httpx_mock: HTTPXMock,
263+
) -> None:
264+
"""Upsert proceeds when the pool-sourced attribute is not part of the HFID."""
265+
httpx_mock.add_response(method="POST", json=UPSERT_MOCK_RESPONSE)
266+
node = InfrahubNode(
267+
client=client,
268+
schema=vlan_schema,
269+
data={"name": "Test VLAN", "vlan_id": {"from_pool": {"id": POOL_ID}}},
270+
)
271+
272+
await node.save(allow_upsert=True)
273+
274+
assert node.id == "mock-vlan-uuid"
275+
276+
277+
async def test_save_upsert_raises_when_pool_node_object_in_hfid(
278+
client: InfrahubClient,
279+
vlan_schema_with_pool_hfid: NodeSchemaAPI,
280+
ipaddress_pool_schema: NodeSchemaAPI,
281+
ipam_ipprefix_schema: NodeSchemaAPI,
282+
ipam_ipprefix_data: dict[str, Any],
283+
) -> None:
284+
"""save(allow_upsert=True) raises ValidationError when vlan_id is set to a pool node object (not a from_pool dict)."""
285+
ip_prefix = InfrahubNode(client=client, schema=ipam_ipprefix_schema, data=ipam_ipprefix_data)
286+
ip_pool = InfrahubNode(
287+
client=client,
288+
schema=ipaddress_pool_schema,
289+
data={
290+
"id": NODE_POOL_ID,
291+
"name": "Core loopbacks",
292+
"default_address_type": "IpamIPAddress",
293+
"default_prefix_length": 32,
294+
"ip_namespace": "ip_namespace",
295+
"resources": [ip_prefix],
296+
},
297+
)
298+
node = InfrahubNode(
299+
client=client,
300+
schema=vlan_schema_with_pool_hfid,
301+
data={"name": "Test VLAN", "vlan_id": ip_pool},
302+
)
303+
304+
with pytest.raises(ValidationError, match=re.escape("Attribute 'vlan_id' is sourced from a CoreNumberPool")):
305+
await node.save(allow_upsert=True)
306+
307+
308+
async def test_create_upsert_raises_when_numberpool_attr_in_hfid(
309+
client: InfrahubClient,
310+
vlan_schema_with_pool_hfid: NodeSchemaAPI,
311+
) -> None:
312+
"""create(allow_upsert=True) raises ValidationError directly, not only through save()."""
313+
node = InfrahubNode(
314+
client=client,
315+
schema=vlan_schema_with_pool_hfid,
316+
data={"name": "Test VLAN", "vlan_id": {"from_pool": {"id": POOL_ID}}},
317+
)
318+
319+
with pytest.raises(ValidationError, match=re.escape("Attribute 'vlan_id' is sourced from a CoreNumberPool")):
320+
await node.create(allow_upsert=True)
321+
322+
323+
def test_create_upsert_raises_when_numberpool_attr_in_hfid_sync(
324+
client_sync: InfrahubClientSync,
325+
vlan_schema_with_pool_hfid: NodeSchemaAPI,
326+
) -> None:
327+
"""Sync create(allow_upsert=True) raises ValidationError directly, not only through save()."""
328+
node = InfrahubNodeSync(
329+
client=client_sync,
330+
schema=vlan_schema_with_pool_hfid,
331+
data={"name": "Test VLAN", "vlan_id": {"from_pool": {"id": POOL_ID}}},
332+
)
333+
334+
with pytest.raises(ValidationError, match=re.escape("Attribute 'vlan_id' is sourced from a CoreNumberPool")):
335+
node.create(allow_upsert=True)

0 commit comments

Comments
 (0)