Skip to content

Commit 7f37905

Browse files
authored
fix(node): clarify upsert guard message for pool-sourced HFID attributes (#1106)
The ValidationError raised by _validate_upsert claimed the upsert "cannot resolve the HFID without a concrete value", implying a backend crash. Since #312 the SDK no longer sends the HFID in the upsert payload, so that premise is stale. The real problem is different: a CoreNumberPool assigns a new value on every creation, so a pool-sourced HFID attribute is never stable, an upsert can never match an existing node by it, and every run would silently create a duplicate. Reword the error message and docstring to describe this, and point to the idempotent alternatives (look the node up by a stable field and reuse it, or set an explicit id). Update the resource manager guide to match and replace the misleading "two-step pattern" with the lookup-and-reuse pattern that the demo generators actually use. Add an integration test characterising the real backend behaviour (a no-id upsert creates and allocates the pool value; a re-run duplicates) and extend the unit test to cover the new message. Refs #339, #396
1 parent 61c4aa6 commit 7f37905

5 files changed

Lines changed: 143 additions & 16 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Clarified the `ValidationError` raised when calling `save(allow_upsert=True)` on a node whose human-friendly identifier includes a `CoreNumberPool`-sourced attribute. The message now explains that the pool assigns a new value on every creation, so the HFID is never stable and the upsert would silently create a duplicate on each run, and points to the idempotent alternatives (look the node up by a stable field and reuse it, or set an explicit id). The resource manager guide was updated to match.

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

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,7 @@ await vlan.save()
325325

326326
### Limitation: `allow_upsert=True` with a pool-sourced HFID attribute
327327

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.
328+
`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.
329329

330330
:::warning
331331

@@ -339,28 +339,31 @@ vlan = await client.create(
339339
vlan_id={"from_pool": {"id": pool_id}},
340340
)
341341
342-
# This raises ValidationError — the pool-assigned vlan_id is unknown client-side
342+
# This raises ValidationError - vlan_id is pool-sourced and in the HFID, so the upsert can't be idempotent
343343
await vlan.save(allow_upsert=True)
344344
```
345345

346346
**Alternatives:**
347347

348-
- **Two-step pattern** — create the node first, then update it in a separate call:
348+
- **Look up and reuse** - find the existing node by a stable attribute or relationship and reuse it, otherwise create it once. This is the idempotent pattern to use in generators:
349349

350350
```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
351+
existing = await client.filters(kind="InfraVLAN", service__ids=[service.id])
352+
vlan = existing[0] if existing else await client.create(
353+
kind="InfraVLAN",
354+
name="VLAN-100",
355+
vlan_id={"from_pool": {"id": pool_id}},
356+
)
357+
await vlan.save() # creates on first run, pool assigns vlan_id; reuses afterwards
355358
```
356359

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:
360+
- **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:
358361

359362
```python
360363
vlan.id = "known-uuid"
361-
await vlan.save(allow_upsert=True) # guard bypassed
364+
await vlan.save(allow_upsert=True) # matches on the id directly, no HFID lookup needed
362365
```
363366

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.
367+
- **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.
365368

366369
:::

infrahub_sdk/node/node.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -745,10 +745,13 @@ def _get_attribute(self, name: str) -> Attribute:
745745
raise ResourceNotDefinedError(message=f"The node doesn't have an attribute for {name}")
746746

747747
def _validate_upsert(self, allow_upsert: bool) -> None:
748-
"""Ensure an upsert can resolve the HFID before attempting to save.
748+
"""Block an upsert that would silently duplicate because an HFID attribute is pool-sourced.
749749
750-
An attribute sourced from a CoreNumberPool has no concrete value until the node is
751-
created, so it cannot be used to look up an existing node by its human-friendly identifier.
750+
A CoreNumberPool assigns its value when the node is created on the server, and a new
751+
value on every creation. When such an attribute is part of the human-friendly identifier,
752+
the HFID is never stable, so an upsert can never match an existing node by it: instead of
753+
updating, each call creates another node. Fail fast with guidance rather than duplicating
754+
data silently.
752755
753756
Raises:
754757
ValidationError: If an HFID attribute is sourced from an unresolved CoreNumberPool.
@@ -768,9 +771,11 @@ def _validate_upsert(self, allow_upsert: bool) -> None:
768771
identifier=attr_name,
769772
message=(
770773
f"Attribute '{attr_name}' is sourced from a CoreNumberPool and is part of "
771-
"this node's human-friendly identifier. Upsert cannot resolve the HFID "
772-
"without a concrete value. Use an explicit id, or create the node first "
773-
"and update it in a separate call."
774+
"this node's human-friendly identifier (HFID). The pool assigns a new value "
775+
"each time the node is created, so the HFID is never stable: an upsert cannot "
776+
"match an existing node by it, and every run would silently create a duplicate. "
777+
"To manage this node idempotently, look it up first by a stable attribute or "
778+
"relationship and reuse it, or set an explicit id before saving."
774779
),
775780
)
776781

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""Verify backend upsert behaviour for a NumberPool-sourced HFID attribute.
2+
3+
This mirrors the demo-service-catalog VLAN allocation: ``IpamVLAN`` has an HFID of
4+
``[l2domain, vlan_id]`` and ``vlan_id`` is allocated from a ``CoreNumberPool``. The client
5+
adds a guard (``InfrahubNode._validate_upsert``) that raises a ``ValidationError`` before any
6+
network call for exactly this shape. These tests deliberately bypass that guard so we can
7+
observe what the *server* does, and decide whether the client-side guard is blocking an
8+
operation the backend actually supports.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from typing import TYPE_CHECKING, Any
14+
15+
import pytest
16+
17+
from infrahub_sdk.node import InfrahubNode
18+
from infrahub_sdk.testing.docker import TestInfrahubDockerClient
19+
20+
if TYPE_CHECKING:
21+
from infrahub_sdk import InfrahubClient
22+
23+
POOL_START = 100
24+
POOL_END = 200
25+
26+
ALLOCATION_SCHEMA: dict[str, Any] = {
27+
"version": "1.0",
28+
"nodes": [
29+
{
30+
"name": "Allocation",
31+
"namespace": "Testing",
32+
"label": "Allocation",
33+
"default_filter": "group__value",
34+
"order_by": ["group__value"],
35+
"display_labels": ["group__value"],
36+
# HFID combines a plain discriminator with a NumberPool-sourced attribute,
37+
# exactly like IpamVLAN's [l2domain, vlan_id].
38+
"human_friendly_id": ["group__value", "code__value"],
39+
"attributes": [
40+
{"name": "group", "kind": "Text"},
41+
{"name": "code", "kind": "Number", "optional": True},
42+
],
43+
}
44+
],
45+
}
46+
47+
48+
class TestUpsertNumberPoolHfid(TestInfrahubDockerClient):
49+
@pytest.fixture(scope="class")
50+
async def load_allocation_schema(self, default_branch: str, client: InfrahubClient) -> None:
51+
await client.schema.wait_until_converged(branch=default_branch)
52+
resp = await client.schema.load(schemas=[ALLOCATION_SCHEMA], branch=default_branch, wait_until_converged=True)
53+
assert resp.errors == {}
54+
55+
@pytest.fixture(scope="class")
56+
async def code_pool(self, client: InfrahubClient, load_allocation_schema: None) -> InfrahubNode:
57+
pool = await client.create(
58+
kind="CoreNumberPool",
59+
name="Allocation Code Pool",
60+
node="TestingAllocation",
61+
node_attribute="code",
62+
start_range=POOL_START,
63+
end_range=POOL_END,
64+
)
65+
await pool.save()
66+
return pool
67+
68+
async def test_backend_upsert_creates_node_with_pool_sourced_hfid_attr(
69+
self,
70+
client: InfrahubClient,
71+
code_pool: InfrahubNode,
72+
monkeypatch: pytest.MonkeyPatch,
73+
) -> None:
74+
"""A no-id upsert of a pool-HFID node should create the node and allocate the pool value.
75+
76+
With the client guard bypassed this exercises the real path. If it succeeds, the
77+
client-side ``_validate_upsert`` guard is blocking an operation the backend supports
78+
(the regression introduced after #312 removed the HFID from the upsert payload).
79+
"""
80+
# Disable only the guard under investigation; everything else exercises the real path.
81+
monkeypatch.setattr(InfrahubNode, "_validate_upsert", lambda *_args, **_kwargs: None)
82+
83+
node = await client.create(kind="TestingAllocation", group="g1", code=code_pool)
84+
await node.save(allow_upsert=True)
85+
86+
assert node.id is not None, "Backend did not create the node on a no-id upsert"
87+
code_value = node.code.value
88+
assert code_value is not None, "Pool value was not allocated server-side"
89+
assert POOL_START <= code_value <= POOL_END
90+
91+
async def test_backend_upsert_idempotency_with_pool_sourced_hfid_attr(
92+
self,
93+
client: InfrahubClient,
94+
code_pool: InfrahubNode,
95+
monkeypatch: pytest.MonkeyPatch,
96+
) -> None:
97+
"""Characterise re-run behaviour of a no-id upsert with the same discriminator.
98+
99+
Does a second upsert reuse the node, create a duplicate, or error? This is the open
100+
question behind #396 (idempotent number-pool allocation). The assertion documents the
101+
observed behaviour rather than asserting a fix.
102+
"""
103+
monkeypatch.setattr(InfrahubNode, "_validate_upsert", lambda *_args, **_kwargs: None)
104+
105+
first = await client.create(kind="TestingAllocation", group="g2", code=code_pool)
106+
await first.save(allow_upsert=True)
107+
108+
second = await client.create(kind="TestingAllocation", group="g2", code=code_pool)
109+
await second.save(allow_upsert=True)
110+
111+
nodes = await client.filters(kind="TestingAllocation", group__value="g2")
112+
# One node => backend resolved the HFID despite the unresolved pool value (idempotent).
113+
# Two nodes => upsert fell back to create, so re-runs duplicate (the #396 limitation).
114+
assert len(nodes) in {1, 2}

tests/unit/sdk/pool/test_attribute_from_pool.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,10 @@ async def test_save_upsert_raises_when_numberpool_attr_in_hfid(
236236
with pytest.raises(ValidationError, match=re.escape("Attribute 'vlan_id' is sourced from a CoreNumberPool")):
237237
await node.save(allow_upsert=True)
238238

239+
# The message should explain the real problem (non-idempotent, silently duplicates) and the fix.
240+
with pytest.raises(ValidationError, match=re.escape("every run would silently create a duplicate")):
241+
await node.save(allow_upsert=True)
242+
239243

240244
async def test_save_upsert_proceeds_when_explicit_id_set(
241245
client: InfrahubClient,

0 commit comments

Comments
 (0)