Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
4 changes: 2 additions & 2 deletions backend/infrahub/core/changelog/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -591,7 +591,7 @@ def _process_added_peers(
primary_changelog: NodeChangelog,
) -> list[NodeChangelog]:
secondaries: list[NodeChangelog] = []
peer_relation = peer_schema.get_relationship_by_identifier(id=str(rel_schema.identifier), raise_on_error=False)
peer_relation = peer_schema.get_reverse_relationship(relationship=rel_schema)
if peer_relation:
node_changelog = NodeChangelog(node_id=peer_id, node_kind=peer_kind, display_label="n/a")
if peer_relation.cardinality == RelationshipCardinality.ONE:
Expand Down Expand Up @@ -625,7 +625,7 @@ def _process_removed_peers(
primary_changelog: NodeChangelog,
) -> list[NodeChangelog]:
secondaries: list[NodeChangelog] = []
peer_relation = peer_schema.get_relationship_by_identifier(id=str(rel_schema.identifier), raise_on_error=False)
peer_relation = peer_schema.get_reverse_relationship(relationship=rel_schema)
if peer_relation:
node_changelog = NodeChangelog(node_id=peer_id, node_kind=peer_kind, display_label="n/a")
if peer_relation.cardinality == RelationshipCardinality.ONE:
Expand Down
27 changes: 26 additions & 1 deletion backend/infrahub/core/schema/basenode_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@
from pydantic import ConfigDict, ValidationError, field_validator

from infrahub.computed_attribute.jinja2 import InfrahubJinja2Template
from infrahub.core.constants import HashableModelState, RelationshipCardinality, RelationshipKind
from infrahub.core.constants import (
HashableModelState,
RelationshipCardinality,
RelationshipDirection,
RelationshipKind,
)
from infrahub.core.models import HashableModel, HashableModelDiff

from .attribute_schema import AttributeSchema, get_attribute_schema_class_for_kind
Expand Down Expand Up @@ -378,6 +383,26 @@ def get_relationships_by_identifier(self, id: str) -> list[RelationshipSchema]:

return rels

def get_reverse_relationship(self, relationship: RelationshipSchema) -> RelationshipSchema | None:
"""Return the relationship on this schema that traverses back along the provided relationship.

Sharing an identifier is not enough: two kinds can both declare the same identifier on the same
side, and a single kind can declare both sides of it. The peer must point the opposite way, and a
bidirectional relationship is never its own reverse because both sides are stored asymmetrically.

Returns None when this schema declares no relationship pointing back.
"""
expected_direction = relationship.direction.neighbor_direction

for candidate in self.get_relationships_by_identifier(id=relationship.get_identifier()):
if candidate.direction != expected_direction:
continue
if candidate.direction == RelationshipDirection.BIDIR and candidate.name == relationship.name:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When two different kinds use the same name for a bidirectional relationship, this name-only guard drops the valid peer relationship. Compare schema/relationship identity so only a genuinely self-referential relationship is skipped.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/infrahub/core/schema/basenode_schema.py, line 400:

<comment>When two different kinds use the same name for a bidirectional relationship, this name-only guard drops the valid peer relationship. Compare schema/relationship identity so only a genuinely self-referential relationship is skipped.</comment>

<file context>
@@ -378,6 +383,26 @@ def get_relationships_by_identifier(self, id: str) -> list[RelationshipSchema]:
+        for candidate in self.get_relationships_by_identifier(id=relationship.get_identifier()):
+            if candidate.direction != expected_direction:
+                continue
+            if candidate.direction == RelationshipDirection.BIDIR and candidate.name == relationship.name:
+                continue
+            return candidate
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mechanism is real but the conclusion is inverted for this codebase, so keeping the name comparison.

The reported failure is two different kinds using the same name: CoreRepositoryGroup.members and CoreGeneratorGroup.members, both inherited from CoreGroup with identifier group_member. Skipping that is the fix, not a regression.

Comparing relationship identity cannot separate the two cases. Inherited relationships get id = None (node_inheritance_handler.py:81) and, unlike attributes, carry no source_*_id, so there is no token shared between the two members. Comparing owning kinds instead only skips a same-kind pair, which leaves the reported cross-kind case emitting the spurious event. Verified: swapping the guard for an identity comparison makes the replication test fail again.

Your comment did expose a genuine gap, and thanks for it: the test used one kind on both sides, so it could not tell the two implementations apart. 41b14eb8 enrols a CoreStandardGroup into a TestTrackingGroup instead, matching the reported scenario, and it now fails under the identity-based guard.

On the residual risk: a same-name bidirectional pair across two kinds that is a genuine two-sided relationship would lose its peer-side changelog. No such pair exists in the core schema — every two-sided pair names its sides differently (checks/validator, comments/thread, roles/permissions, ip_addresses/ip_prefix), and the one identifier shared by two same-named relationships (ippool__resource) is on sibling pool kinds that are never each other's peer.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we unify this if in one and also leave a code comment explaining the logic?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in bc26b3d: one condition plus a comment on why the name is what separates an inherited same-name pair (CoreGroup.members on two kinds) from a genuine two-sided relationship, and I checked the new guard against the old two-if version across every schema/relationship pair in the core schema (33,028 comparisons, 2,830 resolved peers, 0 mismatches).

continue
return candidate

return None

def get_relationships_of_kind(self, relationship_kinds: Iterable[RelationshipKind]) -> list[RelationshipSchema]:
return [r for r in self.relationships if r.kind in relationship_kinds]

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import pytest

from infrahub.auth.session import AccountSession
from infrahub.core import registry
from infrahub.core.branch import Branch
from infrahub.core.constants import InfrahubKind
from infrahub.core.manager import NodeManager
from infrahub.core.node import Node
from infrahub.core.schema import AttributeSchema, NodeSchema, SchemaRoot
from infrahub.core.schema.schema_branch import SchemaBranch
from infrahub.database import InfrahubDatabase
from infrahub.events.group_action import GroupMemberAddedEvent, GroupMemberRemovedEvent
Expand Down Expand Up @@ -184,3 +190,88 @@ async def test_node_mutation_to_group_event(
assert len(orphan_group_event.members) == 1
assert EventNode(id=person_id, kind="TestPerson") in orphan_group_event.members
assert len(orphan_group_event.ancestors) == 0


@pytest.fixture
async def tracking_group_schema(
db: InfrahubDatabase, default_branch: Branch, node_group_schema: None, standard_group_schema: None
) -> None:
"""Register a second group kind, so group-to-group enrolment can be exercised across two kinds."""
schema = SchemaRoot(
nodes=[
NodeSchema(
name="TrackingGroup",
namespace="Test",
inherit_from=[InfrahubKind.GENERICGROUP],
attributes=[AttributeSchema(name="name", kind="Text", unique=True)],
)
]
)
registry.schema.register_schema(schema=schema, branch=default_branch.name)


async def test_group_enrolled_into_group_only_emits_event_for_enclosing_group(
db: InfrahubDatabase,
default_branch: Branch,
tracking_group_schema: None,
session_first_account: AccountSession,
) -> None:
"""Enrolling a group into another group reports the enclosing group as the one that gained a member.

The enrolled group gains nothing, so it must not be reported as having gained a member itself. The two
groups are of different kinds, which is how a repository import enrols one group into another.
"""
enclosing_group = await Node.init(db=db, schema="TestTrackingGroup", branch=default_branch)
await enclosing_group.new(db=db, name="enclosing_group")
await enclosing_group.save(db=db)
enrolled_group = await Node.init(db=db, schema="CoreStandardGroup", branch=default_branch)
await enrolled_group.new(db=db, name="enrolled_group")
await enrolled_group.save(db=db)

memory_event = MemoryInfrahubEvent()
service = await InfrahubServices.new(event=memory_event)
default_branch.update_schema_hash()
gql_params = await prepare_graphql_params(
db=db, branch=default_branch, service=service, account_session=session_first_account
)

update_query = """
mutation($group: String!, $member: String!) {
TestTrackingGroupUpdate(data:
{
id: $group,
members: [{id: $member}]
}
) {
ok
}
}
"""
result = await graphql(
schema=gql_params.schema,
source=update_query,
context_value=gql_params.context,
root_value=None,
variable_values={"group": enclosing_group.get_id(), "member": enrolled_group.get_id()},
)

assert not result.errors
assert gql_params.context.background
await gql_params.context.background()

member_added_events = [event for event in memory_event.events if isinstance(event, GroupMemberAddedEvent)]
assert [event.node_id for event in member_added_events] == [enclosing_group.get_id()]
assert member_added_events[0].members == [EventNode(id=enrolled_group.get_id(), kind="CoreStandardGroup")]
assert member_added_events[0].kind == "TestTrackingGroup"

node_updated_events = [event for event in memory_event.events if isinstance(event, NodeUpdatedEvent)]
assert [event.node_id for event in node_updated_events] == [enclosing_group.get_id()]

assert len(memory_event.events) == 2
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

reloaded_enclosing = await NodeManager.get_one(db=db, id=enclosing_group.get_id(), prefetch_relationships=True)
reloaded_enrolled = await NodeManager.get_one(db=db, id=enrolled_group.get_id(), prefetch_relationships=True)
assert list(await reloaded_enclosing.members.get_peers(db=db)) == [enrolled_group.get_id()]
# Both group kinds read the one stored membership through the same relationship, so it is visible
# from either side and cannot say which group gained a member: only the mutation says that.
assert list(await reloaded_enrolled.members.get_peers(db=db)) == [enclosing_group.get_id()]
1 change: 1 addition & 0 deletions changelog/10314.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Adding a group as a member of another group no longer reports the added group as the one that gained a member, which previously made every repository import start a Generator run that failed.
Loading