Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/1080.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Calling `.save(allow_upsert=True)` on a node hydrated by `from_graphql` no longer silently clears optional one-cardinality relationships that the GraphQL response didn't fetch. Explicitly assigning `node.rel = None` still clears the relationship.
1 change: 1 addition & 0 deletions changelog/339.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +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.
55 changes: 55 additions & 0 deletions docs/docs/python-sdk/guides/resource-manager.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -309,3 +309,58 @@ 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.

## 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.

```python
vlan = await client.create(
kind="InfraVLAN",
name="VLAN-100",
vlan_id={"from_pool": {"id": pool_id}},
)
await vlan.save()
```

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

`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.

:::warning

Calling `save(allow_upsert=True)` on a node whose HFID contains a `CoreNumberPool`-sourced attribute raises `ValidationError` before any network call is made.

```python
# Schema has human_friendly_id: ["vlan_id__value"]
vlan = await client.create(
kind="InfraVLAN",
name="VLAN-100",
vlan_id={"from_pool": {"id": pool_id}},
)

# This raises ValidationError — the pool-assigned vlan_id is unknown client-side
await vlan.save(allow_upsert=True)
```

**Alternatives:**

- **Two-step pattern** — create the node first, then update it in a separate call:

```python
await vlan.save() # creates node, pool assigns vlan_id
# later, if you need to update:
vlan.name.value = "VLAN-100-updated"
await vlan.save() # now _existing=True, calls update
```

- **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:

```python
vlan.id = "known-uuid"
await vlan.save(allow_upsert=True) # guard bypassed
```

- **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.

:::
16 changes: 16 additions & 0 deletions docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/attribute.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,19 @@ Check whether this attribute's value is sourced from a resource pool.
**Returns:**

- True if the attribute value is a resource pool node or was explicitly allocated from a pool.

#### `is_unresolved_pool_attribute`

```python
is_unresolved_pool_attribute(self) -> bool
```

Return True when pool-backed but no concrete scalar value is available yet.

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.
14 changes: 14 additions & 0 deletions infrahub_sdk/node/attribute.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,17 @@ def is_from_pool_attribute(self) -> bool:

"""
return (isinstance(self.value, CoreNodeBase) and self.value.is_resource_pool()) or self._from_pool is not None

def is_unresolved_pool_attribute(self) -> bool:
"""Return True when pool-backed but no concrete scalar value is available yet.

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.
"""
if isinstance(self.value, CoreNodeBase) and self.value.is_resource_pool():
return True
return self._from_pool is not None and self.value is None
58 changes: 52 additions & 6 deletions infrahub_sdk/node/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@
from typing import TYPE_CHECKING, Any, BinaryIO, overload

from ..constants import InfrahubClientMode
from ..exceptions import FeatureNotSupportedError, NodeNotFoundError, ResourceNotDefinedError, SchemaNotFoundError
from ..exceptions import (
FeatureNotSupportedError,
NodeNotFoundError,
ResourceNotDefinedError,
SchemaNotFoundError,
ValidationError,
)
from ..file_handler import FileHandler, FileHandlerBase, FileHandlerSync, PreparedFile, sha1_of_source
from ..graphql import Mutation, Query
from ..schema import (
Expand Down Expand Up @@ -344,9 +350,11 @@ def _generate_input_data( # noqa: C901
rel: RelatedNodeBase | RelationshipManagerBase = getattr(self, item_name)

if rel_schema.cardinality == RelationshipCardinality.ONE and rel_schema.optional and not rel.initialized:
# Only include None for existing nodes to allow clearing relationships
# For new nodes, omit the field to allow object template defaults to be applied
if self._existing:
# Emit `None` only when the caller has explicitly cleared the relationship
# (tracked by `_peer_has_been_mutated`). Without this guard, a node hydrated
# from a partial GraphQL payload — one that didn't fetch this relationship —
# would silently clear it on save.
if self._existing and isinstance(rel, RelatedNodeBase) and rel._peer_has_been_mutated:
data[item_name] = None
continue

Expand Down Expand Up @@ -591,6 +599,36 @@ def _get_attribute(self, name: str) -> Attribute:

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

def _validate_upsert(self, allow_upsert: bool) -> None:
"""Ensure an upsert can resolve the HFID before attempting to save.

An attribute sourced from a CoreNumberPool has no concrete value until the node is
created, so it cannot be used to look up an existing node by its human-friendly identifier.

Raises:
ValidationError: If an HFID attribute is sourced from an unresolved CoreNumberPool.

"""
if not (allow_upsert and not self.id):
return

for hfid_path in self._schema.human_friendly_id or []:
attr_name = hfid_path.split("__")[0]
try:
attr = self._get_attribute(attr_name)
except ResourceNotDefinedError:
continue
if attr.is_unresolved_pool_attribute():
raise ValidationError(
identifier=attr_name,
message=(
f"Attribute '{attr_name}' is sourced from a CoreNumberPool and is part of "
"this node's human-friendly identifier. Upsert cannot resolve the HFID "
"without a concrete value. Use an explicit id, or create the node first "
"and update it in a separate call."
),
)

@staticmethod
def _build_rel_query_data(
rel_schema: RelationshipSchemaAPI,
Expand Down Expand Up @@ -752,9 +790,11 @@ def __setattr__(self, name: str, value: Any) -> None:
message=f"Unable to find relationship schema for '{name}' on {self._schema.kind}",
)
rel_schema = rel_schemas[0]
self._relationship_cardinality_one_data[name] = RelatedNode(
new_rel = RelatedNode(
name=rel_schema.name, branch=self._branch, client=self._client, schema=rel_schema, data=value
)
new_rel._peer_has_been_mutated = True
self._relationship_cardinality_one_data[name] = new_rel
return

super().__setattr__(name, value)
Expand Down Expand Up @@ -1265,6 +1305,8 @@ async def _process_mutation_result(
async def create(
self, allow_upsert: bool = False, timeout: int | None = None, request_context: RequestContext | None = None
) -> None:
self._validate_upsert(allow_upsert=allow_upsert)

if self._file_object_support and self._file_content is None:
raise ValueError(
f"Cannot create {self._schema.kind} without file content. Use upload_from_path() or upload_from_bytes() to provide "
Expand Down Expand Up @@ -1728,9 +1770,11 @@ def __setattr__(self, name: str, value: Any) -> None:
message=f"Unable to find relationship schema for '{name}' on {self._schema.kind}",
)
rel_schema = rel_schemas[0]
self._relationship_cardinality_one_data[name] = RelatedNodeSync(
new_rel = RelatedNodeSync(
name=rel_schema.name, branch=self._branch, client=self._client, schema=rel_schema, data=value
)
new_rel._peer_has_been_mutated = True
self._relationship_cardinality_one_data[name] = new_rel
return

super().__setattr__(name, value)
Expand Down Expand Up @@ -2237,6 +2281,8 @@ def _process_mutation_result(
def create(
self, allow_upsert: bool = False, timeout: int | None = None, request_context: RequestContext | None = None
) -> None:
self._validate_upsert(allow_upsert=allow_upsert)

if self._file_object_support and self._file_content is None:
raise ValueError(
f"Cannot create {self._schema.kind} without file content. Use upload_from_path() or upload_from_bytes() to provide "
Expand Down
4 changes: 4 additions & 0 deletions infrahub_sdk/node/related_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ def __init__(self, branch: str, schema: RelationshipSchemaAPI, data: Any | dict,
self._kind: str | None = None
self._source_typename: str | None = None
self._relationship_metadata: RelationshipMetadata | None = None
# True once the user has assigned to this relationship via Node.__setattr__.
# Distinguishes "never loaded" (partial GraphQL payload) from "explicitly cleared"
# so we don't silently null-clear unfetched relationships on save.
self._peer_has_been_mutated: bool = False

if isinstance(data, (CoreNodeBase)):
self._peer = data
Expand Down
3 changes: 2 additions & 1 deletion infrahub_sdk/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ def initialize_repo(self) -> Repo:

root_path = Path(self.root_directory)

if root_path.exists() and (root_path / ".git").is_dir():
if root_path.exists() and (root_path / ".git").exists():
# `.git` is a directory in a regular clone and a gitlink file in a worktree.
repo = Repo(self.root_directory) # Open existing repo
else:
repo = Repo.init(self.root_directory, default_branch=self.branch.encode("utf-8"))
Expand Down
20 changes: 20 additions & 0 deletions tests/unit/sdk/pool/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,23 @@ async def ipprefix_pool_schema() -> NodeSchemaAPI:
],
}
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."""
data: dict[str, Any] = {
"name": "VLAN",
"namespace": "Infra",
"label": "VLAN",
"default_filter": "name__value",
"order_by": ["name__value"],
"display_labels": ["name__value"],
"human_friendly_id": ["vlan_id__value"],
"attributes": [
{"name": "name", "kind": "Text", "unique": True},
{"name": "vlan_id", "kind": "Number"},
],
"relationships": [],
}
return NodeSchema(**data).convert_api()
Loading