Skip to content

Commit 2406fae

Browse files
authored
Add per-instance group membership fields on Object Templates (#9142)
Object templates now expose member_of_groups_for_instances and subscriber_of_groups_for_instances. Groups assigned through these are copied onto each object created from the template, while the template's own member_of_groups keeps its existing meaning.
1 parent 2391aac commit 2406fae

11 files changed

Lines changed: 518 additions & 1 deletion

File tree

backend/infrahub/core/schema/schema_branch.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2133,9 +2133,48 @@ def add_groups(self) -> None:
21332133
)
21342134
)
21352135

2136+
# On auto-generated templates, also expose template-side fields whose peers drive instance group membership at template application time
2137+
if isinstance(schema, TemplateSchema):
2138+
schema, changed = self._add_template_group_for_instances_relationships(
2139+
schema=schema, schema_duplicated=changed
2140+
)
2141+
21362142
if changed:
21372143
self.set(name=node_name, schema=schema)
21382144

2145+
def _add_template_group_for_instances_relationships(
2146+
self, schema: MainSchemaTypes, schema_duplicated: bool
2147+
) -> tuple[MainSchemaTypes, bool]:
2148+
"""Append `member_of_groups_for_instances` / `subscriber_of_groups_for_instances` on a template.
2149+
2150+
These are generic relationships using distinct identifiers so adding peers does not put the template object
2151+
into a group.
2152+
2153+
`schema_duplicated` reflects whether the caller has already duplicated `schema`. The helper duplicates on
2154+
first mutation if needed and returns the updated state so the caller knows whether anything was changed.
2155+
"""
2156+
for rel_name, identifier in (
2157+
("member_of_groups_for_instances", "template_group_member_for_instances"),
2158+
("subscriber_of_groups_for_instances", "template_group_subscriber_for_instances"),
2159+
):
2160+
if rel_name in schema.relationship_names:
2161+
continue
2162+
if not schema_duplicated:
2163+
schema = schema.duplicate()
2164+
schema_duplicated = True
2165+
schema.relationships.append(
2166+
RelationshipSchema(
2167+
name=rel_name,
2168+
identifier=identifier,
2169+
peer=InfrahubKind.GENERICGROUP,
2170+
kind=RelationshipKind.GENERIC,
2171+
cardinality=RelationshipCardinality.MANY,
2172+
optional=True,
2173+
branch=BranchSupportType.AWARE,
2174+
)
2175+
)
2176+
return schema, schema_duplicated
2177+
21392178
def _get_hierarchy_child_rel(self, peer: str, hierarchical: str | None, read_only: bool) -> RelationshipSchema:
21402179
return RelationshipSchema(
21412180
name="children",

backend/infrahub/templates/node_applier.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@
1818
from infrahub.pools.allocator import PoolAllocator
1919

2020

21+
TEMPLATE_GROUP_FOR_INSTANCES_REL_MAP: dict[str, str] = {
22+
"member_of_groups_for_instances": "member_of_groups",
23+
"subscriber_of_groups_for_instances": "subscriber_of_groups",
24+
}
25+
26+
2127
class NodeTemplateApplier:
2228
"""Applies a template to produce field data for a new node."""
2329

@@ -74,6 +80,15 @@ async def _apply_relationships(
7480
)
7581
continue
7682

83+
if rel_name in TEMPLATE_GROUP_FOR_INSTANCES_REL_MAP:
84+
await self._handle_group_for_instances_relationship(
85+
template=template,
86+
template_rel_name=rel_name,
87+
instance_rel_name=TEMPLATE_GROUP_FOR_INSTANCES_REL_MAP[rel_name],
88+
fields=fields,
89+
)
90+
continue
91+
7792
if rel_name in fields:
7893
continue
7994

@@ -90,6 +105,20 @@ async def _apply_relationships(
90105
elif peers := await relationship.get_peers(db=self.db):
91106
fields[rel_name] = [{"id": peer_id} for peer_id in peers]
92107

108+
async def _handle_group_for_instances_relationship(
109+
self, template: CoreObjectTemplate, template_rel_name: str, instance_rel_name: str, fields: dict[str, Any]
110+
) -> None:
111+
"""Translate a template-side `*_for_instances` field into instance group membership.
112+
113+
Reads peers set on the template's _for_instances relationship and writes them under the matching real
114+
group-membership relationship name on the new instance.
115+
"""
116+
if instance_rel_name in fields:
117+
return
118+
relationship = template.get_relationship(name=template_rel_name)
119+
if peers := await relationship.get_peers(db=self.db):
120+
fields[instance_rel_name] = [{"id": peer_id} for peer_id in peers]
121+
93122
async def _handle_pool_relationship(
94123
self,
95124
template: CoreObjectTemplate,

backend/tests/component/templates/test_template_applier.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,3 +576,166 @@ async def test_applier_preserves_user_value_over_pool_reference(
576576
)
577577

578578
_validate_template_fields(fields=fields, user_fields=user_fields)
579+
580+
581+
class TestNodeTemplateApplierGroupForInstances:
582+
@pytest.fixture
583+
async def standard_group(self, db: InfrahubDatabase, default_branch: Branch, device_schema: None) -> Node:
584+
group_schema = registry.schema.get_node_schema(name=InfrahubKind.STANDARDGROUP, branch=default_branch)
585+
group = await Node.init(db=db, schema=group_schema)
586+
await group.new(db=db, name="staged-devices")
587+
await group.save(db=db)
588+
return group
589+
590+
@pytest.fixture
591+
async def second_standard_group(self, db: InfrahubDatabase, default_branch: Branch, device_schema: None) -> Node:
592+
group_schema = registry.schema.get_node_schema(name=InfrahubKind.STANDARDGROUP, branch=default_branch)
593+
group = await Node.init(db=db, schema=group_schema)
594+
await group.new(db=db, name="prod-devices")
595+
await group.save(db=db)
596+
return group
597+
598+
async def test_member_of_groups_for_instances_propagates_to_instance(
599+
self,
600+
db: InfrahubDatabase,
601+
default_branch: Branch,
602+
device_schema: None,
603+
standard_group: Node,
604+
second_standard_group: Node,
605+
) -> None:
606+
template_schema = registry.schema.get_template_schema(name=f"Template{TestKind.DEVICE}", branch=default_branch)
607+
template = await Node.init(schema=template_schema, db=db, branch=default_branch)
608+
await template.new(
609+
db=db,
610+
template_name="grouped-template",
611+
manufacturer="Acme",
612+
member_of_groups_for_instances=[{"id": standard_group.id}, {"id": second_standard_group.id}],
613+
)
614+
await template.save(db=db)
615+
616+
applier = NodeTemplateApplier(db=db, branch=default_branch, pool_allocator=NoOpPoolAllocator())
617+
target_schema = registry.schema.get_node_schema(name=TestKind.DEVICE, branch=default_branch)
618+
user_fields = {"name": "my-device", "weight": 100, "airflow": "Front to rear"}
619+
620+
fields = await applier.apply(
621+
template=template, target_schema=target_schema, target_id="new-device-id", user_fields=user_fields
622+
)
623+
624+
_validate_template_fields(
625+
fields=fields,
626+
expected_relationships=[
627+
ExpectedTemplateRelationship(
628+
name="member_of_groups", peer_ids=[standard_group.id, second_standard_group.id]
629+
),
630+
],
631+
user_fields=user_fields,
632+
excluded_fields=["member_of_groups_for_instances"],
633+
)
634+
635+
async def test_subscriber_of_groups_for_instances_propagates_to_instance(
636+
self, db: InfrahubDatabase, default_branch: Branch, device_schema: None, standard_group: Node
637+
) -> None:
638+
template_schema = registry.schema.get_template_schema(name=f"Template{TestKind.DEVICE}", branch=default_branch)
639+
template = await Node.init(schema=template_schema, db=db, branch=default_branch)
640+
await template.new(
641+
db=db,
642+
template_name="subscribed-template",
643+
manufacturer="Acme",
644+
subscriber_of_groups_for_instances=[{"id": standard_group.id}],
645+
)
646+
await template.save(db=db)
647+
648+
applier = NodeTemplateApplier(db=db, branch=default_branch, pool_allocator=NoOpPoolAllocator())
649+
target_schema = registry.schema.get_node_schema(name=TestKind.DEVICE, branch=default_branch)
650+
user_fields = {"name": "my-device", "weight": 100, "airflow": "Front to rear"}
651+
652+
fields = await applier.apply(
653+
template=template, target_schema=target_schema, target_id="new-device-id", user_fields=user_fields
654+
)
655+
656+
_validate_template_fields(
657+
fields=fields,
658+
expected_relationships=[
659+
ExpectedTemplateRelationship(name="subscriber_of_groups", peer_ids=[standard_group.id]),
660+
],
661+
user_fields=user_fields,
662+
excluded_fields=["subscriber_of_groups_for_instances"],
663+
)
664+
665+
async def test_template_member_of_groups_does_not_propagate(
666+
self, db: InfrahubDatabase, default_branch: Branch, device_schema: None, standard_group: Node
667+
) -> None:
668+
template_schema = registry.schema.get_template_schema(name=f"Template{TestKind.DEVICE}", branch=default_branch)
669+
template = await Node.init(schema=template_schema, db=db, branch=default_branch)
670+
await template.new(
671+
db=db,
672+
template_name="self-member-template",
673+
manufacturer="Acme",
674+
member_of_groups=[{"id": standard_group.id}],
675+
)
676+
await template.save(db=db)
677+
678+
applier = NodeTemplateApplier(db=db, branch=default_branch, pool_allocator=NoOpPoolAllocator())
679+
target_schema = registry.schema.get_node_schema(name=TestKind.DEVICE, branch=default_branch)
680+
user_fields = {"name": "my-device", "weight": 100, "airflow": "Front to rear"}
681+
682+
fields = await applier.apply(
683+
template=template, target_schema=target_schema, target_id="new-device-id", user_fields=user_fields
684+
)
685+
686+
_validate_template_fields(
687+
fields=fields, user_fields=user_fields, excluded_fields=["member_of_groups", "subscriber_of_groups"]
688+
)
689+
690+
async def test_user_member_of_groups_takes_precedence(
691+
self,
692+
db: InfrahubDatabase,
693+
default_branch: Branch,
694+
device_schema: None,
695+
standard_group: Node,
696+
second_standard_group: Node,
697+
) -> None:
698+
template_schema = registry.schema.get_template_schema(name=f"Template{TestKind.DEVICE}", branch=default_branch)
699+
template = await Node.init(schema=template_schema, db=db, branch=default_branch)
700+
await template.new(
701+
db=db,
702+
template_name="grouped-template",
703+
manufacturer="Acme",
704+
member_of_groups_for_instances=[{"id": standard_group.id}],
705+
)
706+
await template.save(db=db)
707+
708+
applier = NodeTemplateApplier(db=db, branch=default_branch, pool_allocator=NoOpPoolAllocator())
709+
target_schema = registry.schema.get_node_schema(name=TestKind.DEVICE, branch=default_branch)
710+
user_fields = {
711+
"name": "my-device",
712+
"weight": 100,
713+
"airflow": "Front to rear",
714+
"member_of_groups": [{"id": second_standard_group.id}],
715+
}
716+
717+
fields = await applier.apply(
718+
template=template, target_schema=target_schema, target_id="new-device-id", user_fields=user_fields
719+
)
720+
721+
_validate_template_fields(fields=fields, user_fields=user_fields)
722+
723+
async def test_empty_for_instances_does_not_set_field(
724+
self, db: InfrahubDatabase, default_branch: Branch, device_schema: None
725+
) -> None:
726+
template_schema = registry.schema.get_template_schema(name=f"Template{TestKind.DEVICE}", branch=default_branch)
727+
template = await Node.init(schema=template_schema, db=db, branch=default_branch)
728+
await template.new(db=db, template_name="empty-groups-template", manufacturer="Acme")
729+
await template.save(db=db)
730+
731+
applier = NodeTemplateApplier(db=db, branch=default_branch, pool_allocator=NoOpPoolAllocator())
732+
target_schema = registry.schema.get_node_schema(name=TestKind.DEVICE, branch=default_branch)
733+
user_fields = {"name": "my-device", "weight": 100, "airflow": "Front to rear"}
734+
735+
fields = await applier.apply(
736+
template=template, target_schema=target_schema, target_id="new-device-id", user_fields=user_fields
737+
)
738+
739+
_validate_template_fields(
740+
fields=fields, user_fields=user_fields, excluded_fields=["member_of_groups", "subscriber_of_groups"]
741+
)
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import pytest
2+
3+
from infrahub.core.constants import InfrahubKind, RelationshipCardinality, RelationshipKind
4+
from infrahub.core.schema import SchemaRoot, core_models
5+
from infrahub.core.schema.schema_branch import SchemaBranch
6+
from tests.constants import TestKind
7+
from tests.helpers.schema.device import DEVICE, INTERFACE, INTERFACE_HOLDER
8+
9+
10+
@pytest.fixture
11+
def device_schema_branch() -> SchemaBranch:
12+
schema_branch = SchemaBranch(cache={}, name="test")
13+
schema = SchemaRoot(generics=[INTERFACE_HOLDER, INTERFACE], nodes=[DEVICE])
14+
schema_branch.load_schema(schema=SchemaRoot(**core_models).merge(schema=schema))
15+
schema_branch.process()
16+
return schema_branch
17+
18+
19+
async def test_template_keeps_member_of_groups(device_schema_branch: SchemaBranch) -> None:
20+
device_template = device_schema_branch.get_template(name=f"Template{TestKind.DEVICE}", duplicate=False)
21+
22+
member_rel = device_template.get_relationship(name="member_of_groups")
23+
assert member_rel.kind == RelationshipKind.GROUP
24+
assert member_rel.identifier == "group_member"
25+
26+
subscriber_rel = device_template.get_relationship(name="subscriber_of_groups")
27+
assert subscriber_rel.kind == RelationshipKind.GROUP
28+
assert subscriber_rel.identifier == "group_subscriber"
29+
30+
31+
async def test_template_gets_for_instances_group_relationships(device_schema_branch: SchemaBranch) -> None:
32+
device_template = device_schema_branch.get_template(name=f"Template{TestKind.DEVICE}", duplicate=False)
33+
34+
member_rel = device_template.get_relationship(name="member_of_groups_for_instances")
35+
assert member_rel.peer == InfrahubKind.GENERICGROUP
36+
assert member_rel.kind == RelationshipKind.GENERIC
37+
assert member_rel.cardinality == RelationshipCardinality.MANY
38+
assert member_rel.optional is True
39+
40+
subscriber_rel = device_template.get_relationship(name="subscriber_of_groups_for_instances")
41+
assert subscriber_rel.peer == InfrahubKind.GENERICGROUP
42+
assert subscriber_rel.kind == RelationshipKind.GENERIC
43+
assert subscriber_rel.cardinality == RelationshipCardinality.MANY
44+
assert subscriber_rel.optional is True
45+
46+
47+
async def test_regular_node_does_not_get_for_instances_relationships(device_schema_branch: SchemaBranch) -> None:
48+
device = device_schema_branch.get_node(name=TestKind.DEVICE, duplicate=False)
49+
assert "member_of_groups" in device.relationship_names
50+
assert "subscriber_of_groups" in device.relationship_names
51+
assert "member_of_groups_for_instances" not in device.relationship_names
52+
assert "subscriber_of_groups_for_instances" not in device.relationship_names

changelog/9094.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Object Templates now expose `member_of_groups_for_instances` and `subscriber_of_groups_for_instances` relationships. Groups assigned through these fields are propagated to every object created from the template, mirroring the resource-pool pattern. The existing `member_of_groups` and `subscriber_of_groups` on a template continue to apply to the template itself only.

dev/knowledge/backend/architecture.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ Similar to pull requests, proposed changes allow reviewing and approving data mo
6060
- [Async Tasks](async-tasks.md) - Background task processing
6161
- [Message Bus](message-bus.md) - Inter-service communication
6262
- [Computed Attributes](computed-attributes.md) - Jinja2 evaluation paths and schema registry
63+
- [Object Templates](templates.md) - Template generation, application, and resource pool integration
6364

6465
### Guidelines
6566

dev/knowledge/backend/schema-definitions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ core_standard_webhook = NodeSchema(
6464
|------|---------|
6565
| `GENERIC` | Standard association between nodes |
6666
| `ATTRIBUTE` | Peer is semantically part of the parent (e.g., headers on a webhook) |
67-
| `COMPONENT` | Triggers template generation for the peer |
67+
| `COMPONENT` | Triggers template generation for the peer (see [Object Templates](templates.md)) |
6868
| `PARENT` | Hierarchical parent-child relationship |
6969
| `GROUP` | Group membership |
7070

0 commit comments

Comments
 (0)