From 05d636b43215eebe1dbf49bedd4d5a36e7b8140a Mon Sep 17 00:00:00 2001 From: Pete Crocker Date: Sat, 16 May 2026 15:45:55 +0100 Subject: [PATCH 1/2] fix(repository): detect existing repo when .git is a worktree gitlink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitRepoManager.initialize_repo() checked for `.git` with `.is_dir()`, which misses git worktrees (`.git` is a file containing `gitdir: ...`). The existing-repo branch was skipped and `Repo.init` then crashed with `FileExistsError` trying to mkdir the gitlink file. This made `infrahubctl transform` (and any other CLI relying on branch auto-detection) unusable from a worktree. Switch the check to `.exists()` — dulwich's `Repo(path)` already resolves the gitlink to the real controldir, and `porcelain.active_branch` returns the worktree's branch correctly. --- infrahub_sdk/repository.py | 3 ++- tests/unit/sdk/test_repository.py | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/infrahub_sdk/repository.py b/infrahub_sdk/repository.py index 2ad2080a..5bcd1387 100644 --- a/infrahub_sdk/repository.py +++ b/infrahub_sdk/repository.py @@ -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")) diff --git a/tests/unit/sdk/test_repository.py b/tests/unit/sdk/test_repository.py index 756b2e36..eca1c9a6 100644 --- a/tests/unit/sdk/test_repository.py +++ b/tests/unit/sdk/test_repository.py @@ -41,6 +41,26 @@ def test_initialize_repo_uses_existing_repo(temp_dir: str) -> None: assert (Path(temp_dir) / ".git").is_dir() +def test_initialize_repo_uses_existing_repo_in_worktree(temp_dir: str) -> None: + """A git worktree has a `.git` file (gitlink), not a directory. + + GitRepoManager must open the existing repo via the gitlink instead of trying + to re-initialize one, which would crash on `mkdir` because the file already exists. + """ + main_path = Path(temp_dir) / "main" + worktree_path = Path(temp_dir) / "worktree" + main_path.mkdir() + worktree_path.mkdir() + Repo.init(str(main_path), default_branch=b"main") + (worktree_path / ".git").write_text(f"gitdir: {main_path}/.git\n") + + manager = GitRepoManager(str(worktree_path)) + + assert manager.git is not None + assert isinstance(manager.git, Repo) + assert (worktree_path / ".git").is_file() + + def test_active_branch_returns_correct_branch(temp_dir: str) -> None: """Test that the active branch is correctly returned.""" manager = GitRepoManager(temp_dir, branch="develop") From 05ac99774b1e17c39d04ec07613a331115c5b528 Mon Sep 17 00:00:00 2001 From: Babatunde Olusola Date: Thu, 11 Jun 2026 18:00:39 +0100 Subject: [PATCH 2/2] IHS-119: Fix upsert not working for numberpool and attribute in hfid (#1014) --- changelog/339.fixed.md | 1 + .../python-sdk/guides/resource-manager.mdx | 55 ++++++++ .../sdk_ref/infrahub_sdk/node/attribute.mdx | 16 +++ infrahub_sdk/node/attribute.py | 14 ++ infrahub_sdk/node/node.py | 42 +++++- tests/unit/sdk/pool/conftest.py | 20 +++ .../unit/sdk/pool/test_attribute_from_pool.py | 129 ++++++++++++++++++ 7 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 changelog/339.fixed.md diff --git a/changelog/339.fixed.md b/changelog/339.fixed.md new file mode 100644 index 00000000..4543386c --- /dev/null +++ b/changelog/339.fixed.md @@ -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. diff --git a/docs/docs/python-sdk/guides/resource-manager.mdx b/docs/docs/python-sdk/guides/resource-manager.mdx index 97cb4b90..8a42b6e9 100644 --- a/docs/docs/python-sdk/guides/resource-manager.mdx +++ b/docs/docs/python-sdk/guides/resource-manager.mdx @@ -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. + +::: 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 d08c7fc5..92a0ab98 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 @@ -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. diff --git a/infrahub_sdk/node/attribute.py b/infrahub_sdk/node/attribute.py index c1a424a8..c72802d7 100644 --- a/infrahub_sdk/node/attribute.py +++ b/infrahub_sdk/node/attribute.py @@ -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 diff --git a/infrahub_sdk/node/node.py b/infrahub_sdk/node/node.py index c39ac293..8213c686 100644 --- a/infrahub_sdk/node/node.py +++ b/infrahub_sdk/node/node.py @@ -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 ( @@ -591,6 +597,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, @@ -1265,6 +1301,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 " @@ -2237,6 +2275,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 " diff --git a/tests/unit/sdk/pool/conftest.py b/tests/unit/sdk/pool/conftest.py index 9d0fd245..43ab113c 100644 --- a/tests/unit/sdk/pool/conftest.py +++ b/tests/unit/sdk/pool/conftest.py @@ -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() diff --git a/tests/unit/sdk/pool/test_attribute_from_pool.py b/tests/unit/sdk/pool/test_attribute_from_pool.py index a11f2c63..28b00f81 100644 --- a/tests/unit/sdk/pool/test_attribute_from_pool.py +++ b/tests/unit/sdk/pool/test_attribute_from_pool.py @@ -11,11 +11,17 @@ from __future__ import annotations +import re from typing import TYPE_CHECKING, Any +import pytest + +from infrahub_sdk.exceptions import ValidationError from infrahub_sdk.node import InfrahubNode, InfrahubNodeSync if TYPE_CHECKING: + from pytest_httpx import HTTPXMock + from infrahub_sdk import InfrahubClient, InfrahubClientSync from infrahub_sdk.schema import NodeSchemaAPI @@ -204,3 +210,126 @@ async def test_attribute_with_pool_node_generates_mutation_query( mutation_query = vlan._generate_mutation_query() assert mutation_query["object"]["vlan_id"] == {"value": None} + + +UPSERT_MOCK_RESPONSE = { + "data": { + "InfraVLANUpsert": { + "ok": True, + "object": {"id": "mock-vlan-uuid", "vlan_id": {"value": 100}}, + } + } +} + + +async def test_save_upsert_raises_when_numberpool_attr_in_hfid( + client: InfrahubClient, + vlan_schema_with_pool_hfid: NodeSchemaAPI, +) -> None: + """save(allow_upsert=True) raises ValidationError naming the pool-sourced HFID attribute.""" + node = InfrahubNode( + client=client, + schema=vlan_schema_with_pool_hfid, + data={"name": "Test VLAN", "vlan_id": {"from_pool": {"id": POOL_ID}}}, + ) + + with pytest.raises(ValidationError, match=re.escape("Attribute 'vlan_id' is sourced from a CoreNumberPool")): + await node.save(allow_upsert=True) + + +async def test_save_upsert_proceeds_when_explicit_id_set( + client: InfrahubClient, + vlan_schema_with_pool_hfid: NodeSchemaAPI, + httpx_mock: HTTPXMock, +) -> None: + """Upsert proceeds when an explicit node id is already set.""" + httpx_mock.add_response(method="POST", json=UPSERT_MOCK_RESPONSE) + node = InfrahubNode( + client=client, + schema=vlan_schema_with_pool_hfid, + data={"name": "Test VLAN", "vlan_id": {"from_pool": {"id": POOL_ID}}}, + ) + node.id = "existing-node-uuid" + + await node.save(allow_upsert=True) + + assert node.id == "mock-vlan-uuid" + + +async def test_save_upsert_proceeds_when_numberpool_attr_not_in_hfid( + client: InfrahubClient, + vlan_schema: NodeSchemaAPI, + httpx_mock: HTTPXMock, +) -> None: + """Upsert proceeds when the pool-sourced attribute is not part of the HFID.""" + httpx_mock.add_response(method="POST", json=UPSERT_MOCK_RESPONSE) + node = InfrahubNode( + client=client, + schema=vlan_schema, + data={"name": "Test VLAN", "vlan_id": {"from_pool": {"id": POOL_ID}}}, + ) + + await node.save(allow_upsert=True) + + assert node.id == "mock-vlan-uuid" + + +async def test_save_upsert_raises_when_pool_node_object_in_hfid( + client: InfrahubClient, + vlan_schema_with_pool_hfid: NodeSchemaAPI, + ipaddress_pool_schema: NodeSchemaAPI, + ipam_ipprefix_schema: NodeSchemaAPI, + ipam_ipprefix_data: dict[str, Any], +) -> None: + """save(allow_upsert=True) raises ValidationError when vlan_id is set to a pool node object (not a from_pool dict).""" + ip_prefix = InfrahubNode(client=client, schema=ipam_ipprefix_schema, data=ipam_ipprefix_data) + ip_pool = InfrahubNode( + client=client, + schema=ipaddress_pool_schema, + data={ + "id": NODE_POOL_ID, + "name": "Core loopbacks", + "default_address_type": "IpamIPAddress", + "default_prefix_length": 32, + "ip_namespace": "ip_namespace", + "resources": [ip_prefix], + }, + ) + node = InfrahubNode( + client=client, + schema=vlan_schema_with_pool_hfid, + data={"name": "Test VLAN", "vlan_id": ip_pool}, + ) + + with pytest.raises(ValidationError, match=re.escape("Attribute 'vlan_id' is sourced from a CoreNumberPool")): + await node.save(allow_upsert=True) + + +async def test_create_upsert_raises_when_numberpool_attr_in_hfid( + client: InfrahubClient, + vlan_schema_with_pool_hfid: NodeSchemaAPI, +) -> None: + """create(allow_upsert=True) raises ValidationError directly, not only through save().""" + node = InfrahubNode( + client=client, + schema=vlan_schema_with_pool_hfid, + data={"name": "Test VLAN", "vlan_id": {"from_pool": {"id": POOL_ID}}}, + ) + + with pytest.raises(ValidationError, match=re.escape("Attribute 'vlan_id' is sourced from a CoreNumberPool")): + await node.create(allow_upsert=True) + + +def test_create_upsert_raises_when_numberpool_attr_in_hfid_sync( + client_sync: InfrahubClientSync, + vlan_schema_with_pool_hfid: NodeSchemaAPI, +) -> None: + """Sync create(allow_upsert=True) raises ValidationError directly, not only through save().""" + node = InfrahubNodeSync( + client=client_sync, + schema=vlan_schema_with_pool_hfid, + data={"name": "Test VLAN", "vlan_id": {"from_pool": {"id": POOL_ID}}}, + ) + + with pytest.raises(ValidationError, match=re.escape("Attribute 'vlan_id' is sourced from a CoreNumberPool")): + node.create(allow_upsert=True)