Skip to content
Closed
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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,23 @@ This project uses [*towncrier*](https://towncrier.readthedocs.io/) and the chang

<!-- towncrier release notes start -->

## [Infrahub - v1.10.6](https://github.com/opsmill/infrahub/tree/infrahub-v1.10.6) - 2026-07-28

### Changed

- Node creation no longer issues a redundant database query to resolve peers for empty many-cardinality relationships, since a newly created node has none stored yet. This removes hundreds of round-trips during the first-time database initialization and speeds up node creation.

### Fixed

- Deleting a repository now cascades to the objects it manages (transforms, checks, GraphQL queries, generators, and their artifact definitions, artifacts, and validators), so a repository can be removed in a single operation instead of deleting each dependent object by hand. ([#3076](https://github.com/opsmill/infrahub/issues/3076))
- Following an IP prefix or address link from the relationship tabs of an IPAM namespace now keeps you in that namespace, instead of redirecting to the default namespace. ([#9892](https://github.com/opsmill/infrahub/issues/9892))
- Fixed git-sync repository import getting stuck on the previous commit when a check definition removed from the repository was still referenced by a proposed change. ([#9934](https://github.com/opsmill/infrahub/issues/9934))
- Fixed the branch and proposed change diff view not showing changed objects in the tree when their parent object had no changes of its own. ([#10010](https://github.com/opsmill/infrahub/issues/10010))
- Fixed the object creation form when adding a component from its parent's tab (e.g. adding an interface from a device): the parent is now pre-selected and the create form opens directly, even when the component's parent relationship points to a generic rather than a concrete node.
- The proposed changes list is now ordered by creation date with the newest first, on both the open and closed tabs.
- Fixed schema updates that remove a node with an object template leaving the branch schema permanently out of sync across workers ([#10049](https://github.com/opsmill/infrahub/issues/10049))
- Branch rebase and merge no longer validate schema constraints for attributes and relationships that were not actually modified on the branch. The query collecting the changed fields of a diff could match nodes belonging to other branches' diffs and to already-merged diffs, which would inflate the set of constraints to check.

## [Infrahub - v1.10.5](https://github.com/opsmill/infrahub/tree/infrahub-v1.10.5) - 2026-07-15

### Fixed
Expand Down
12 changes: 6 additions & 6 deletions backend/infrahub/core/diff/query/field_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,17 +42,17 @@ async def query_init(self, db: InfrahubDatabase, **kwargs: Any) -> None: # noqa
AND (diff_root.uuid = $diff_id OR $diff_id IS NULL)
OPTIONAL MATCH (diff_root)-[:DIFF_HAS_NODE]->(n:DiffNode)
WHERE n.action <> $unchanged_str
WITH DISTINCT n.kind AS kind
CALL (kind) {
OPTIONAL MATCH (n:DiffNode {kind: kind})-[:DIFF_HAS_ATTRIBUTE]->(a:DiffAttribute)
WITH DISTINCT diff_root, n.kind AS kind
CALL (diff_root, kind) {
OPTIONAL MATCH (diff_root)-[:DIFF_HAS_NODE]->(n:DiffNode {kind: kind})-[:DIFF_HAS_ATTRIBUTE]->(a:DiffAttribute)
WHERE n.action <> $unchanged_str
AND a.action <> $unchanged_str
WITH DISTINCT a.name AS attr_name
RETURN collect(attr_name) AS attr_names
}
WITH kind, attr_names
CALL (kind) {
OPTIONAL MATCH (n:DiffNode {kind: kind})-[:DIFF_HAS_RELATIONSHIP]->(r:DiffRelationship)
WITH diff_root, kind, attr_names
CALL (diff_root, kind) {
OPTIONAL MATCH (diff_root)-[:DIFF_HAS_NODE]->(n:DiffNode {kind: kind})-[:DIFF_HAS_RELATIONSHIP]->(r:DiffRelationship)
WHERE n.action <> $unchanged_str
AND r.action <> $unchanged_str
WITH DISTINCT r.name AS rel_name
Expand Down
21 changes: 20 additions & 1 deletion backend/infrahub/core/schema/schema_branch.py
Original file line number Diff line number Diff line change
Expand Up @@ -480,8 +480,10 @@ def get_node_or_generic_schema(self, name: str, duplicate: bool = True) -> NodeS
def delete(self, name: str) -> None:
if name in self.nodes:
del self.nodes[name]
self._delete_generated_kinds(node_kind=name)
elif name in self.generics:
del self.generics[name]
self._delete_generated_kinds(node_kind=name)
elif name in self.profiles:
del self.profiles[name]
elif name in self.templates:
Expand All @@ -491,6 +493,19 @@ def delete(self, name: str) -> None:
branch_name=self.name, identifier=name, message=f"Unable to find the schema {name!r} in the registry"
)

def _delete_generated_kinds(self, node_kind: str) -> None:
"""Generated profile and template schemas must not outlive the node they derive from."""
profile_kind = self._get_profile_kind(node_kind=node_kind)
if profile_kind in self.profiles:
del self.profiles[profile_kind]

template_kind = self._get_object_template_kind(node_kind=node_kind)
if template_kind in self.templates:
del self.templates[template_kind]
elif template_kind in self.generics:
# The object template generated for a generic is itself a generic
del self.generics[template_kind]

def get_by_id(self, id: str, duplicate: bool = True) -> MainSchemaTypes:
for name in self.all_names:
node = self.get(name=name, duplicate=False)
Expand Down Expand Up @@ -2147,7 +2162,11 @@ def _generate_weight_templates(self) -> None:
"""
for name in self.template_names:
template = self.get(name=name, duplicate=True)
node = self.get(name=template.name, duplicate=False)
try:
node = self.get(name=template.name, duplicate=False)
except SchemaNotFoundError:
# An orphaned template is removed when the template schemas are managed
continue

node_weights = {
item.name: item.order_weight
Expand Down
21 changes: 21 additions & 0 deletions backend/infrahub/git/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from pydantic import Field

from infrahub import config
from infrahub.core.branch.enums import TERMINAL_BRANCH_STATUSES
from infrahub.core.constants import InfrahubKind, RepositoryInternalStatus, RepositoryOperationalStatus
from infrahub.exceptions import (
CommitNotFoundError,
Expand Down Expand Up @@ -159,6 +160,10 @@ async def collect_pending_imports(self, staging_branch: str | None = None) -> Co

# TODO need to handle properly the situation when a branch is not valid.
if self.internal_status == RepositoryInternalStatus.ACTIVE.value:
# A branch that has been merged (or is being deleted) is read-only: recording its commit
# would be rejected by the graph and abort the whole sync, so drop those branches here.
new_branches, updated_branches = await self._exclude_read_only_branches(new_branches, updated_branches)

for branch_name in new_branches:
is_valid = self.validate_remote_branch(branch_name=branch_name)
if not is_valid:
Expand Down Expand Up @@ -226,6 +231,22 @@ async def collect_pending_imports(self, staging_branch: str | None = None) -> Co
)
return CollectedImports(imports=imports, failed_imports=failed_imports)

async def _exclude_read_only_branches(
self, new_branches: list[str], updated_branches: list[str]
) -> tuple[list[str], list[str]]:
"""Drop branches whose Infrahub branch is in a terminal status (merged or being deleted).

Such branches are read-only, so recording their commit is rejected by the graph. The default
branch is never terminal, so filtering here does not affect the staging-import path.
"""
terminal_status_values = {status.value for status in TERMINAL_BRANCH_STATUSES}
graph_branches = await self.sdk.branch.all()
read_only = {name for name, branch in graph_branches.items() if branch.status.value in terminal_status_values}
return (
[name for name in new_branches if self._get_mapped_target_branch(branch_name=name) not in read_only],
[name for name in updated_branches if self._get_mapped_target_branch(branch_name=name) not in read_only],
)

async def _collect_staging_imports(
self, staging_branch: str | None, updated_branches: list[str]
) -> list[PendingObjectImport]:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import random
from typing import Generator

import pytest

from infrahub import config
from infrahub.core.constants import DiffAction
from infrahub.core.diff.model.path import (
BranchTrackingId,
EnrichedDiffNode,
NameTrackingId,
NodeDiffFieldSummary,
)
from infrahub.core.diff.parent_node_adder import DiffParentNodeAdder
from infrahub.core.diff.repository.deserializer import EnrichedDiffDeserializer
from infrahub.core.diff.repository.repository import DiffRepository
from infrahub.core.timestamp import Timestamp
from infrahub.database import InfrahubDatabase

from ..factories import (
EnrichedAttributeFactory,
EnrichedNodeFactory,
EnrichedRelationshipGroupFactory,
EnrichedRootFactory,
)
from .base import DiffRepositoryTestBase


class TestDiffNodeFieldSummaries(DiffRepositoryTestBase):
base_branch_name: str = "main"
diff_branch_name: str = "diff"
diff_from_time = Timestamp("2024-06-15T18:35:20Z")
diff_to_time = Timestamp("2024-06-15T18:49:40Z")

@pytest.fixture
def diff_repository(self, db: InfrahubDatabase) -> Generator[DiffRepository, None, None]:
original_depth = config.SETTINGS.database.max_depth_search_hierarchy
original_size = config.SETTINGS.database.query_size_limit
config.SETTINGS.database.max_depth_search_hierarchy = 10
config.SETTINGS.database.query_size_limit = 50
diff_repository = DiffRepository(
db=db, deserializer=EnrichedDiffDeserializer(DiffParentNodeAdder()), max_save_batch_size=30
)
yield diff_repository
config.SETTINGS.database.max_depth_search_hierarchy = original_depth
config.SETTINGS.database.query_size_limit = original_size

def _build_named_field_node(
self,
kind: str,
node_action: DiffAction,
attribute_actions: dict[str, DiffAction],
relationship_actions: dict[str, DiffAction],
) -> EnrichedDiffNode:
return EnrichedNodeFactory.build(
kind=kind,
action=node_action,
attributes={
EnrichedAttributeFactory.build(name=name, action=action, properties=set())
for name, action in attribute_actions.items()
},
relationships={
EnrichedRelationshipGroupFactory.build(name=name, action=action, relationships=set(), nodes=set())
for name, action in relationship_actions.items()
},
)

async def test_get_node_field_summaries(self, diff_repository: DiffRepository) -> None:
diff_nodes = self._build_nodes(num_nodes=5, num_sub_fields=2)
for diff_node in list(diff_nodes)[:3]:
same_kind_diff_node = self.build_diff_node(num_sub_fields=3, no_recurse=True)
same_kind_diff_node.identifier.kind = diff_node.identifier.kind
same_attr_names = random.sample([a.name for a in diff_node.attributes], k=min(len(diff_node.attributes), 2))
for attr_diff, attr_name in zip(list(same_kind_diff_node.attributes)[:2], same_attr_names, strict=False):
attr_diff.name = attr_name
same_rel_names = random.sample(
[r.name for r in diff_node.relationships], k=min(len(diff_node.relationships), 2)
)
for rel_diff, rel_name in zip(list(same_kind_diff_node.relationships)[:2], same_rel_names, strict=False):
rel_diff.name = rel_name
diff_nodes.add(same_kind_diff_node)
diff_root = EnrichedRootFactory.build(nodes=diff_nodes)
diff_root.tracking_id = BranchTrackingId(name=diff_root.diff_branch_name)
await self._save_single_diff(diff_repository=diff_repository, enriched_diff=diff_root, do_summary_counts=False)

expected_map: dict[str, NodeDiffFieldSummary] = {}
for node in diff_root.nodes:
if node.action is DiffAction.UNCHANGED:
continue
if node.kind not in expected_map:
expected_map[node.kind] = NodeDiffFieldSummary(kind=node.kind)
field_summary = expected_map[node.kind]
attr_names = {a.name for a in node.attributes if a.action is not DiffAction.UNCHANGED}
field_summary.attribute_names.update(attr_names)
rel_names = {r.name for r in node.relationships if r.action is not DiffAction.UNCHANGED}
field_summary.relationship_names.update(rel_names)
expected_map = {k: v for k, v in expected_map.items() if v.relationship_names or v.attribute_names}

retrieved_node_field_summaries = await diff_repository.get_node_field_summaries(
diff_branch_name=diff_root.diff_branch_name, tracking_id=diff_root.tracking_id
)
retrieved_map = {summary.kind: summary for summary in retrieved_node_field_summaries}
assert expected_map == retrieved_map

retrieved_node_field_summaries = await diff_repository.get_node_field_summaries(
diff_branch_name=diff_root.diff_branch_name, diff_id=diff_root.uuid
)
retrieved_map = {summary.kind: summary for summary in retrieved_node_field_summaries}
assert expected_map == retrieved_map

async def test_get_node_field_summaries_excludes_other_diffs(
self, diff_repository: DiffRepository, reset_database: None
) -> None:
"""Only the changed fields of the requested diff are summarized.

Other diffs covering the same node kind must not contribute field names, and must not make a field
name that is unchanged in the requested diff look changed.
"""
shared_kind = "TestingSharedKind"
requested_branch_name = "requested-branch"
requested_tracking_id = BranchTrackingId(name=requested_branch_name)

requested_diff = EnrichedRootFactory.build(
base_branch_name=self.base_branch_name,
diff_branch_name=requested_branch_name,
from_time=self.diff_from_time,
to_time=self.diff_to_time,
nodes={
self._build_named_field_node(
kind=shared_kind,
node_action=DiffAction.UPDATED,
attribute_actions={"changed_attr": DiffAction.UPDATED, "quiet_attr": DiffAction.UNCHANGED},
relationship_actions={"changed_rel": DiffAction.ADDED, "quiet_rel": DiffAction.UNCHANGED},
),
# an unchanged node of the same kind contributes nothing, even with changed fields of its own
self._build_named_field_node(
kind=shared_kind,
node_action=DiffAction.UNCHANGED,
attribute_actions={"quiet_node_attr": DiffAction.UPDATED},
relationship_actions={"quiet_node_rel": DiffAction.REMOVED},
),
},
tracking_id=requested_tracking_id,
)
await self._save_single_diff(
diff_repository=diff_repository, enriched_diff=requested_diff, do_summary_counts=False
)

other_branch_diff = EnrichedRootFactory.build(
base_branch_name=self.base_branch_name,
diff_branch_name="other-branch",
from_time=self.diff_from_time,
to_time=self.diff_to_time,
nodes={
self._build_named_field_node(
kind=shared_kind,
node_action=DiffAction.UPDATED,
attribute_actions={
# changed here, unchanged in the requested diff
"quiet_attr": DiffAction.REMOVED,
# unchanged here, changed in the requested diff
"changed_attr": DiffAction.UNCHANGED,
"other_branch_attr": DiffAction.ADDED,
},
relationship_actions={
"quiet_rel": DiffAction.UPDATED,
"changed_rel": DiffAction.UNCHANGED,
"other_branch_rel": DiffAction.ADDED,
},
)
},
tracking_id=BranchTrackingId(name="other-branch"),
)
await self._save_single_diff(
diff_repository=diff_repository, enriched_diff=other_branch_diff, do_summary_counts=False
)

merged_tracking_id = NameTrackingId(name="already-merged")
merged_diff = EnrichedRootFactory.build(
base_branch_name=self.base_branch_name,
diff_branch_name=requested_branch_name,
from_time=self.diff_from_time,
to_time=self.diff_to_time,
nodes={
self._build_named_field_node(
kind=shared_kind,
node_action=DiffAction.UPDATED,
attribute_actions={
"quiet_node_attr": DiffAction.UPDATED,
"merged_attr": DiffAction.UPDATED,
},
relationship_actions={
"quiet_node_rel": DiffAction.UPDATED,
"merged_rel": DiffAction.UPDATED,
},
)
},
tracking_id=merged_tracking_id,
)
await self._save_single_diff(
diff_repository=diff_repository, enriched_diff=merged_diff, do_summary_counts=False
)
await diff_repository.mark_tracking_ids_merged(tracking_ids=[merged_tracking_id])

expected_summaries = [
NodeDiffFieldSummary(kind=shared_kind, attribute_names={"changed_attr"}, relationship_names={"changed_rel"})
]

retrieved_by_tracking_id = await diff_repository.get_node_field_summaries(
diff_branch_name=requested_branch_name, tracking_id=requested_tracking_id
)
assert retrieved_by_tracking_id == expected_summaries

retrieved_by_diff_id = await diff_repository.get_node_field_summaries(
diff_branch_name=requested_branch_name, diff_id=requested_diff.uuid
)
assert retrieved_by_diff_id == expected_summaries
Loading
Loading