infrahub-server 1.6.2__py3-none-any.whl → 1.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (253) hide show
  1. infrahub/actions/tasks.py +4 -2
  2. infrahub/api/exceptions.py +2 -2
  3. infrahub/api/schema.py +3 -1
  4. infrahub/artifacts/tasks.py +1 -0
  5. infrahub/auth.py +2 -2
  6. infrahub/cli/db.py +54 -28
  7. infrahub/computed_attribute/gather.py +3 -4
  8. infrahub/computed_attribute/tasks.py +23 -6
  9. infrahub/config.py +8 -0
  10. infrahub/constants/enums.py +12 -0
  11. infrahub/core/account.py +12 -9
  12. infrahub/core/attribute.py +106 -108
  13. infrahub/core/branch/models.py +44 -71
  14. infrahub/core/branch/tasks.py +5 -3
  15. infrahub/core/changelog/diff.py +1 -20
  16. infrahub/core/changelog/models.py +0 -7
  17. infrahub/core/constants/__init__.py +17 -0
  18. infrahub/core/constants/database.py +0 -1
  19. infrahub/core/constants/schema.py +0 -1
  20. infrahub/core/convert_object_type/repository_conversion.py +3 -4
  21. infrahub/core/diff/branch_differ.py +1 -1
  22. infrahub/core/diff/conflict_transferer.py +1 -1
  23. infrahub/core/diff/data_check_synchronizer.py +4 -3
  24. infrahub/core/diff/enricher/cardinality_one.py +2 -2
  25. infrahub/core/diff/enricher/hierarchy.py +1 -1
  26. infrahub/core/diff/enricher/labels.py +1 -1
  27. infrahub/core/diff/merger/merger.py +28 -2
  28. infrahub/core/diff/merger/serializer.py +3 -10
  29. infrahub/core/diff/model/diff.py +1 -1
  30. infrahub/core/diff/query/merge.py +376 -135
  31. infrahub/core/diff/repository/repository.py +3 -1
  32. infrahub/core/graph/__init__.py +1 -1
  33. infrahub/core/graph/constraints.py +3 -3
  34. infrahub/core/graph/schema.py +2 -12
  35. infrahub/core/ipam/reconciler.py +8 -6
  36. infrahub/core/ipam/utilization.py +8 -15
  37. infrahub/core/manager.py +133 -152
  38. infrahub/core/merge.py +1 -1
  39. infrahub/core/metadata/__init__.py +0 -0
  40. infrahub/core/metadata/interface.py +37 -0
  41. infrahub/core/metadata/model.py +31 -0
  42. infrahub/core/metadata/query/__init__.py +0 -0
  43. infrahub/core/metadata/query/node_metadata.py +301 -0
  44. infrahub/core/migrations/graph/__init__.py +4 -0
  45. infrahub/core/migrations/graph/m012_convert_account_generic.py +12 -12
  46. infrahub/core/migrations/graph/m013_convert_git_password_credential.py +7 -12
  47. infrahub/core/migrations/graph/m017_add_core_profile.py +5 -2
  48. infrahub/core/migrations/graph/m018_uniqueness_nulls.py +2 -1
  49. infrahub/core/migrations/graph/m019_restore_rels_to_time.py +0 -10
  50. infrahub/core/migrations/graph/m020_duplicate_edges.py +0 -8
  51. infrahub/core/migrations/graph/m025_uniqueness_nulls.py +2 -1
  52. infrahub/core/migrations/graph/m026_0000_prefix_fix.py +2 -1
  53. infrahub/core/migrations/graph/m029_duplicates_cleanup.py +0 -1
  54. infrahub/core/migrations/graph/m031_check_number_attributes.py +2 -2
  55. infrahub/core/migrations/graph/m038_redo_0000_prefix_fix.py +2 -1
  56. infrahub/core/migrations/graph/m041_deleted_dup_edges.py +1 -1
  57. infrahub/core/migrations/graph/m049_remove_is_visible_relationship.py +53 -0
  58. infrahub/core/migrations/graph/m050_backfill_vertex_metadata.py +168 -0
  59. infrahub/core/migrations/query/__init__.py +2 -2
  60. infrahub/core/migrations/query/attribute_add.py +17 -6
  61. infrahub/core/migrations/query/attribute_remove.py +19 -5
  62. infrahub/core/migrations/query/attribute_rename.py +21 -5
  63. infrahub/core/migrations/query/node_duplicate.py +19 -4
  64. infrahub/core/migrations/query/schema_attribute_update.py +1 -1
  65. infrahub/core/migrations/schema/attribute_kind_update.py +26 -6
  66. infrahub/core/migrations/schema/attribute_name_update.py +1 -1
  67. infrahub/core/migrations/schema/attribute_supports_profile.py +5 -3
  68. infrahub/core/migrations/schema/models.py +3 -0
  69. infrahub/core/migrations/schema/node_attribute_add.py +5 -2
  70. infrahub/core/migrations/schema/node_attribute_remove.py +1 -1
  71. infrahub/core/migrations/schema/node_kind_update.py +1 -1
  72. infrahub/core/migrations/schema/node_remove.py +24 -2
  73. infrahub/core/migrations/schema/tasks.py +4 -1
  74. infrahub/core/migrations/shared.py +13 -6
  75. infrahub/core/models.py +6 -6
  76. infrahub/core/node/__init__.py +157 -58
  77. infrahub/core/node/base.py +9 -5
  78. infrahub/core/node/create.py +7 -3
  79. infrahub/core/node/delete_validator.py +1 -1
  80. infrahub/core/node/standard.py +100 -14
  81. infrahub/core/order.py +30 -0
  82. infrahub/core/property.py +0 -1
  83. infrahub/core/protocols.py +1 -0
  84. infrahub/core/protocols_base.py +10 -2
  85. infrahub/core/query/__init__.py +11 -6
  86. infrahub/core/query/attribute.py +164 -49
  87. infrahub/core/query/branch.py +58 -70
  88. infrahub/core/query/delete.py +1 -1
  89. infrahub/core/query/diff.py +7 -7
  90. infrahub/core/query/ipam.py +104 -43
  91. infrahub/core/query/node.py +1072 -281
  92. infrahub/core/query/relationship.py +531 -325
  93. infrahub/core/query/resource_manager.py +107 -18
  94. infrahub/core/query/standard_node.py +25 -5
  95. infrahub/core/query/utils.py +2 -4
  96. infrahub/core/relationship/constraints/count.py +1 -1
  97. infrahub/core/relationship/constraints/peer_kind.py +1 -1
  98. infrahub/core/relationship/constraints/peer_parent.py +1 -1
  99. infrahub/core/relationship/constraints/peer_relatives.py +1 -1
  100. infrahub/core/relationship/constraints/profiles_kind.py +1 -1
  101. infrahub/core/relationship/constraints/profiles_removal.py +168 -0
  102. infrahub/core/relationship/model.py +293 -139
  103. infrahub/core/schema/attribute_parameters.py +28 -1
  104. infrahub/core/schema/attribute_schema.py +11 -17
  105. infrahub/core/schema/basenode_schema.py +3 -0
  106. infrahub/core/schema/definitions/core/__init__.py +8 -2
  107. infrahub/core/schema/definitions/core/account.py +10 -10
  108. infrahub/core/schema/definitions/core/artifact.py +14 -8
  109. infrahub/core/schema/definitions/core/check.py +10 -4
  110. infrahub/core/schema/definitions/core/generator.py +26 -6
  111. infrahub/core/schema/definitions/core/graphql_query.py +1 -1
  112. infrahub/core/schema/definitions/core/group.py +9 -2
  113. infrahub/core/schema/definitions/core/ipam.py +80 -10
  114. infrahub/core/schema/definitions/core/menu.py +41 -7
  115. infrahub/core/schema/definitions/core/permission.py +16 -2
  116. infrahub/core/schema/definitions/core/profile.py +16 -2
  117. infrahub/core/schema/definitions/core/propose_change.py +24 -4
  118. infrahub/core/schema/definitions/core/propose_change_comment.py +23 -11
  119. infrahub/core/schema/definitions/core/propose_change_validator.py +50 -21
  120. infrahub/core/schema/definitions/core/repository.py +10 -0
  121. infrahub/core/schema/definitions/core/resource_pool.py +8 -1
  122. infrahub/core/schema/definitions/core/template.py +19 -2
  123. infrahub/core/schema/definitions/core/transform.py +11 -5
  124. infrahub/core/schema/definitions/core/webhook.py +27 -9
  125. infrahub/core/schema/manager.py +63 -43
  126. infrahub/core/schema/relationship_schema.py +6 -2
  127. infrahub/core/schema/schema_branch.py +115 -11
  128. infrahub/core/task/task.py +4 -2
  129. infrahub/core/utils.py +3 -25
  130. infrahub/core/validators/aggregated_checker.py +1 -1
  131. infrahub/core/validators/attribute/choices.py +1 -1
  132. infrahub/core/validators/attribute/enum.py +1 -1
  133. infrahub/core/validators/attribute/kind.py +6 -3
  134. infrahub/core/validators/attribute/length.py +1 -1
  135. infrahub/core/validators/attribute/min_max.py +1 -1
  136. infrahub/core/validators/attribute/number_pool.py +1 -1
  137. infrahub/core/validators/attribute/optional.py +1 -1
  138. infrahub/core/validators/attribute/regex.py +1 -1
  139. infrahub/core/validators/determiner.py +3 -3
  140. infrahub/core/validators/node/attribute.py +1 -1
  141. infrahub/core/validators/node/relationship.py +1 -1
  142. infrahub/core/validators/relationship/peer.py +1 -1
  143. infrahub/database/__init__.py +4 -4
  144. infrahub/dependencies/builder/constraint/grouped/node_runner.py +2 -0
  145. infrahub/dependencies/builder/constraint/relationship_manager/profiles_removal.py +8 -0
  146. infrahub/dependencies/registry.py +2 -0
  147. infrahub/display_labels/tasks.py +12 -3
  148. infrahub/git/integrator.py +18 -18
  149. infrahub/git/tasks.py +1 -1
  150. infrahub/git/utils.py +1 -1
  151. infrahub/graphql/app.py +2 -2
  152. infrahub/graphql/constants.py +3 -0
  153. infrahub/graphql/context.py +1 -1
  154. infrahub/graphql/field_extractor.py +1 -1
  155. infrahub/graphql/initialization.py +11 -0
  156. infrahub/graphql/loaders/account.py +134 -0
  157. infrahub/graphql/loaders/node.py +5 -12
  158. infrahub/graphql/loaders/peers.py +5 -7
  159. infrahub/graphql/manager.py +175 -21
  160. infrahub/graphql/metadata.py +91 -0
  161. infrahub/graphql/mutations/account.py +6 -6
  162. infrahub/graphql/mutations/attribute.py +0 -2
  163. infrahub/graphql/mutations/branch.py +9 -5
  164. infrahub/graphql/mutations/computed_attribute.py +1 -1
  165. infrahub/graphql/mutations/display_label.py +1 -1
  166. infrahub/graphql/mutations/hfid.py +1 -1
  167. infrahub/graphql/mutations/ipam.py +4 -6
  168. infrahub/graphql/mutations/main.py +9 -4
  169. infrahub/graphql/mutations/profile.py +16 -22
  170. infrahub/graphql/mutations/proposed_change.py +4 -4
  171. infrahub/graphql/mutations/relationship.py +40 -10
  172. infrahub/graphql/mutations/repository.py +14 -12
  173. infrahub/graphql/mutations/schema.py +2 -2
  174. infrahub/graphql/order.py +14 -0
  175. infrahub/graphql/queries/branch.py +62 -6
  176. infrahub/graphql/queries/diff/tree.py +5 -5
  177. infrahub/graphql/queries/resource_manager.py +25 -24
  178. infrahub/graphql/resolvers/account_metadata.py +84 -0
  179. infrahub/graphql/resolvers/ipam.py +6 -8
  180. infrahub/graphql/resolvers/many_relationship.py +77 -35
  181. infrahub/graphql/resolvers/resolver.py +59 -14
  182. infrahub/graphql/resolvers/single_relationship.py +87 -23
  183. infrahub/graphql/subscription/graphql_query.py +2 -0
  184. infrahub/graphql/types/__init__.py +0 -1
  185. infrahub/graphql/types/attribute.py +10 -5
  186. infrahub/graphql/types/branch.py +40 -53
  187. infrahub/graphql/types/enums.py +3 -0
  188. infrahub/graphql/types/metadata.py +28 -0
  189. infrahub/graphql/types/node.py +22 -2
  190. infrahub/graphql/types/relationship.py +10 -2
  191. infrahub/graphql/types/standard_node.py +12 -7
  192. infrahub/hfid/tasks.py +12 -3
  193. infrahub/lock.py +7 -0
  194. infrahub/menu/repository.py +1 -1
  195. infrahub/patch/queries/base.py +1 -1
  196. infrahub/pools/number.py +1 -8
  197. infrahub/profiles/gather.py +56 -0
  198. infrahub/profiles/mandatory_fields_checker.py +116 -0
  199. infrahub/profiles/models.py +66 -0
  200. infrahub/profiles/node_applier.py +154 -13
  201. infrahub/profiles/queries/get_profile_data.py +143 -31
  202. infrahub/profiles/tasks.py +79 -27
  203. infrahub/profiles/triggers.py +22 -0
  204. infrahub/proposed_change/action_checker.py +1 -1
  205. infrahub/proposed_change/tasks.py +4 -1
  206. infrahub/services/__init__.py +1 -1
  207. infrahub/services/adapters/cache/nats.py +1 -1
  208. infrahub/services/adapters/cache/redis.py +7 -0
  209. infrahub/tasks/artifact.py +1 -0
  210. infrahub/transformations/tasks.py +2 -2
  211. infrahub/trigger/catalogue.py +2 -0
  212. infrahub/trigger/models.py +1 -0
  213. infrahub/trigger/setup.py +3 -3
  214. infrahub/trigger/tasks.py +3 -0
  215. infrahub/validators/tasks.py +1 -0
  216. infrahub/webhook/gather.py +1 -1
  217. infrahub/webhook/models.py +1 -1
  218. infrahub/webhook/tasks.py +23 -7
  219. infrahub/workers/dependencies.py +9 -3
  220. infrahub/workers/infrahub_async.py +13 -4
  221. infrahub/workflows/catalogue.py +19 -0
  222. infrahub_sdk/analyzer.py +2 -2
  223. infrahub_sdk/branch.py +12 -39
  224. infrahub_sdk/checks.py +4 -4
  225. infrahub_sdk/client.py +36 -0
  226. infrahub_sdk/ctl/cli_commands.py +2 -1
  227. infrahub_sdk/ctl/graphql.py +15 -4
  228. infrahub_sdk/ctl/utils.py +2 -2
  229. infrahub_sdk/enums.py +6 -0
  230. infrahub_sdk/graphql/renderers.py +21 -0
  231. infrahub_sdk/graphql/utils.py +85 -0
  232. infrahub_sdk/node/attribute.py +12 -2
  233. infrahub_sdk/node/constants.py +12 -0
  234. infrahub_sdk/node/metadata.py +69 -0
  235. infrahub_sdk/node/node.py +65 -14
  236. infrahub_sdk/node/property.py +3 -0
  237. infrahub_sdk/node/related_node.py +37 -5
  238. infrahub_sdk/node/relationship.py +18 -1
  239. infrahub_sdk/operation.py +2 -2
  240. infrahub_sdk/schema/repository.py +1 -2
  241. infrahub_sdk/transforms.py +2 -2
  242. infrahub_sdk/types.py +18 -2
  243. {infrahub_server-1.6.2.dist-info → infrahub_server-1.7.0.dist-info}/METADATA +17 -16
  244. {infrahub_server-1.6.2.dist-info → infrahub_server-1.7.0.dist-info}/RECORD +252 -231
  245. infrahub_testcontainers/container.py +3 -3
  246. infrahub_testcontainers/docker-compose-cluster.test.yml +7 -7
  247. infrahub_testcontainers/docker-compose.test.yml +13 -5
  248. infrahub_testcontainers/models.py +3 -3
  249. infrahub_testcontainers/performance_test.py +1 -1
  250. infrahub/graphql/models.py +0 -6
  251. {infrahub_server-1.6.2.dist-info → infrahub_server-1.7.0.dist-info}/WHEEL +0 -0
  252. {infrahub_server-1.6.2.dist-info → infrahub_server-1.7.0.dist-info}/entry_points.txt +0 -0
  253. {infrahub_server-1.6.2.dist-info → infrahub_server-1.7.0.dist-info}/licenses/LICENSE.txt +0 -0
@@ -6,7 +6,7 @@ from .models import MenuDict, MenuItemDefinition, MenuItemDict
6
6
 
7
7
 
8
8
  class MenuRepository:
9
- def __init__(self, db: InfrahubDatabase):
9
+ def __init__(self, db: InfrahubDatabase) -> None:
10
10
  self.db = db
11
11
 
12
12
  async def get_menu(self, nodes: dict[str, CoreMenuItem] | None = None) -> MenuDict:
@@ -6,7 +6,7 @@ from ..models import PatchPlan
6
6
 
7
7
 
8
8
  class PatchQuery(ABC):
9
- def __init__(self, db: InfrahubDatabase):
9
+ def __init__(self, db: InfrahubDatabase) -> None:
10
10
  self.db = db
11
11
 
12
12
  @abstractmethod
infrahub/pools/number.py CHANGED
@@ -37,14 +37,7 @@ class NumberUtilizationGetter:
37
37
  query = await NumberPoolGetAllocated.init(db=self.db, pool=self.pool, branch=self.branch, branch_agnostic=True)
38
38
  await query.execute(db=self.db)
39
39
 
40
- self.used = [
41
- UsedNumber(
42
- number=result.get_as_type(label="value", return_type=int),
43
- branch=result.get_as_type(label="branch", return_type=str),
44
- )
45
- for result in query.results
46
- if result.get_as_optional_type(label="value", return_type=int) is not None
47
- ]
40
+ self.used = [UsedNumber(number=item.value, branch=item.branch) for item in query.get_data()]
48
41
 
49
42
  self.used_default_branch = {entry.number for entry in self.used if entry.branch == registry.default_branch}
50
43
  used_branches = {entry.number for entry in self.used if entry.branch != registry.default_branch}
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from prefect import task
4
+ from prefect.cache_policies import NONE
5
+
6
+ from infrahub.core.constants import RelationshipKind
7
+ from infrahub.core.registry import registry
8
+ from infrahub.database import InfrahubDatabase # noqa: TC001 needed for prefect flow
9
+ from infrahub.workflows.catalogue import PROFILE_REFRESH_PROCESS
10
+
11
+ from .models import ProfileRefreshTriggerDefinition
12
+
13
+
14
+ @task(name="gather-trigger-profile-refresh", cache_policy=NONE)
15
+ async def gather_trigger_profile_refresh(
16
+ db: InfrahubDatabase | None = None, # noqa: ARG001 Needed to have a common function signature for gathering functions
17
+ ) -> list[ProfileRefreshTriggerDefinition]:
18
+ """Gather profile refresh triggers for all profile schemas.
19
+
20
+ This function creates trigger definitions for each profile schema that will
21
+ listen for `NodeUpdatedEvent` on profiles. When a profile's attributes or
22
+ relationships change, the trigger will fire and execute the profile refresh
23
+ workflow to re-apply profiles to all related nodes.
24
+ """
25
+ branches_with_diff_from_main = registry.get_altered_schema_branches()
26
+ branches_to_process: list[tuple[str, list[str]]] = [(branch, []) for branch in branches_with_diff_from_main]
27
+ branches_to_process.append((registry.default_branch, branches_with_diff_from_main))
28
+
29
+ triggers: list[ProfileRefreshTriggerDefinition] = []
30
+
31
+ for branch_scope, branches_out_of_scope in branches_to_process:
32
+ schema_branch = registry.schema.get_schema_branch(name=branch_scope)
33
+
34
+ for profile_name in schema_branch.profile_names:
35
+ profile_schema = schema_branch.get_profile(name=profile_name, duplicate=False)
36
+
37
+ trigger_attr = [attr.name for attr in profile_schema.attributes if attr.name != "profile_name"]
38
+ trigger_rels = [
39
+ rel.name
40
+ for rel in profile_schema.relationships
41
+ if rel.kind in {RelationshipKind.GENERIC, RelationshipKind.ATTRIBUTE} and rel.name != "related_nodes"
42
+ ]
43
+ trigger_fields = sorted(trigger_attr + trigger_rels)
44
+
45
+ if trigger_fields:
46
+ triggers.append(
47
+ ProfileRefreshTriggerDefinition.from_profile_schema(
48
+ branch=branch_scope,
49
+ profile_kind=profile_schema.kind,
50
+ trigger_fields=trigger_fields,
51
+ workflow=PROFILE_REFRESH_PROCESS,
52
+ branches_out_of_scope=branches_out_of_scope,
53
+ )
54
+ )
55
+
56
+ return triggers
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from infrahub.core.query.node import NodeGetByHFIDQuery
7
+
8
+ from .queries.get_profile_data import GetProfileDataQuery, RelationshipFilter
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Sequence
12
+
13
+ from infrahub.core.branch import Branch
14
+ from infrahub.core.schema import NodeSchema
15
+ from infrahub.database import InfrahubDatabase
16
+
17
+
18
+ @dataclass
19
+ class ProfileIdentifiers:
20
+ ids: list[str]
21
+ hfids: list[list[str]]
22
+
23
+
24
+ def _extract_profile_identifiers_from_input(profiles_data: Sequence[Any] | None) -> ProfileIdentifiers:
25
+ """Extract profile IDs and HFIDs from input data."""
26
+ if not profiles_data:
27
+ return ProfileIdentifiers(ids=[], hfids=[])
28
+
29
+ ids: list[str] = []
30
+ hfids: list[list[str]] = []
31
+ for item in profiles_data:
32
+ if isinstance(item, str):
33
+ # IDs
34
+ ids.append(item)
35
+ elif isinstance(item, dict):
36
+ # Dicts with `id` or `hfid` keys
37
+ if profile_id := item.get("id"):
38
+ ids.append(profile_id)
39
+ elif profile_hfid := item.get("hfid"):
40
+ hfids.append(profile_hfid)
41
+ elif hasattr(item, "id"):
42
+ # Avoid circular import by not importing Node directly
43
+ ids.append(item.id)
44
+
45
+ return ProfileIdentifiers(ids=ids, hfids=hfids)
46
+
47
+
48
+ async def _resolve_hfids_to_ids(
49
+ db: InfrahubDatabase, branch: Branch, profile_kind: str, hfids: list[list[str]]
50
+ ) -> list[str]:
51
+ query = await NodeGetByHFIDQuery.init(db=db, branch=branch, node_kind=profile_kind, hfids=hfids)
52
+ await query.execute(db=db)
53
+ return query.get_node_uuids()
54
+
55
+
56
+ class ProfilesMandatoryFieldGetter:
57
+ def __init__(self, db: InfrahubDatabase, branch: Branch) -> None:
58
+ self.db = db
59
+ self.branch = branch
60
+
61
+ async def get_mandatory_fields_from_profiles(
62
+ self,
63
+ schema: NodeSchema,
64
+ profiles_data: Sequence[Any] | None,
65
+ mandatory_attr_names: list[str],
66
+ mandatory_rel_names: list[str],
67
+ ) -> tuple[set[str], set[str]]:
68
+ """Get mandatory attributes and relationships that are provided by profiles."""
69
+ identifiers = _extract_profile_identifiers_from_input(profiles_data=profiles_data)
70
+
71
+ profile_ids = list(identifiers.ids)
72
+ if identifiers.hfids:
73
+ resolved_ids = await _resolve_hfids_to_ids(
74
+ db=self.db, branch=self.branch, profile_kind=f"Profile{schema.kind}", hfids=identifiers.hfids
75
+ )
76
+ profile_ids.extend(resolved_ids)
77
+
78
+ if not profile_ids:
79
+ return set(), set()
80
+
81
+ rel_filters: list[RelationshipFilter] = []
82
+ rel_name_to_filter: dict[str, RelationshipFilter] = {}
83
+ for rel_name in mandatory_rel_names:
84
+ rel_schema = schema.get_relationship(name=rel_name)
85
+ if not rel_schema.support_profiles:
86
+ continue
87
+
88
+ rel_filter = RelationshipFilter(
89
+ relationship_identifier=f"profile_{rel_schema.get_identifier()}", direction=rel_schema.direction
90
+ )
91
+ rel_filters.append(rel_filter)
92
+ rel_name_to_filter[rel_name] = rel_filter
93
+
94
+ query = await GetProfileDataQuery.init(
95
+ db=self.db,
96
+ branch=self.branch,
97
+ profile_ids=profile_ids,
98
+ attr_names=mandatory_attr_names,
99
+ relationship_filters=rel_filters,
100
+ )
101
+ await query.execute(db=self.db)
102
+ profile_data_list = query.get_profile_data()
103
+
104
+ provided_attrs: set[str] = set()
105
+ provided_rels: set[str] = set()
106
+
107
+ for profile_data in profile_data_list:
108
+ for attr_name in mandatory_attr_names:
109
+ if profile_data.attribute_values.get(attr_name) is not None:
110
+ provided_attrs.add(attr_name)
111
+
112
+ for rel_name, rel_filter in rel_name_to_filter.items():
113
+ if profile_data.relationship_peers.get(rel_filter):
114
+ provided_rels.add(rel_name)
115
+
116
+ return provided_attrs, provided_rels
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Self
4
+
5
+ from infrahub.core.registry import registry
6
+ from infrahub.events import NodeUpdatedEvent
7
+ from infrahub.trigger.constants import NAME_SEPARATOR
8
+ from infrahub.trigger.models import EventTrigger, ExecuteWorkflow, TriggerBranchDefinition, TriggerType
9
+
10
+ if TYPE_CHECKING:
11
+ from infrahub.workflows.models import WorkflowDefinition
12
+
13
+
14
+ class ProfileRefreshTriggerDefinition(TriggerBranchDefinition):
15
+ """Trigger definition for profile refresh when profile attributes/relationships change."""
16
+
17
+ type: TriggerType = TriggerType.PROFILE
18
+ profile_kind: str
19
+
20
+ @classmethod
21
+ def from_profile_schema(
22
+ cls,
23
+ branch: str,
24
+ profile_kind: str,
25
+ trigger_fields: list[str],
26
+ workflow: WorkflowDefinition,
27
+ branches_out_of_scope: list[str] | None = None,
28
+ ) -> Self:
29
+ """Create a trigger definition for profile refresh when profile attributes/relationships change."""
30
+ event_trigger = EventTrigger()
31
+ event_trigger.events.add(NodeUpdatedEvent.event_name)
32
+ event_trigger.match = {"infrahub.node.kind": profile_kind}
33
+
34
+ if branches_out_of_scope:
35
+ event_trigger.match["infrahub.branch.name"] = [f"!{b}" for b in branches_out_of_scope]
36
+ elif branch != registry.default_branch:
37
+ event_trigger.match["infrahub.branch.name"] = branch
38
+
39
+ event_trigger.match_related = {
40
+ "prefect.resource.role": ["infrahub.node.attribute_update", "infrahub.node.relationship_update"],
41
+ "infrahub.field.name": trigger_fields,
42
+ }
43
+
44
+ workflow_action = ExecuteWorkflow(
45
+ workflow=workflow,
46
+ parameters={
47
+ "branch_name": "{{ event.resource['infrahub.branch.name'] }}",
48
+ "profile_kind": profile_kind,
49
+ "profile_id": "{{ event.resource['infrahub.node.id'] }}",
50
+ "context": {
51
+ "__prefect_kind": "json",
52
+ "value": {
53
+ "__prefect_kind": "jinja",
54
+ "template": "{{ event.payload['context'] | tojson }}",
55
+ },
56
+ },
57
+ },
58
+ )
59
+
60
+ return cls(
61
+ name=f"{profile_kind}{NAME_SEPARATOR}refresh",
62
+ branch=branch,
63
+ profile_kind=profile_kind,
64
+ trigger=event_trigger,
65
+ actions=[workflow_action],
66
+ )
@@ -2,10 +2,13 @@ from typing import Any
2
2
 
3
3
  from infrahub.core.attribute import BaseAttribute
4
4
  from infrahub.core.branch import Branch
5
+ from infrahub.core.constants import InfrahubKind
5
6
  from infrahub.core.node import Node
7
+ from infrahub.core.relationship import RelationshipManager
8
+ from infrahub.core.relationship.model import Relationship
6
9
  from infrahub.database import InfrahubDatabase
7
10
 
8
- from .queries.get_profile_data import GetProfileDataQuery, ProfileData
11
+ from .queries.get_profile_data import GetProfileDataQuery, ProfileData, RelationshipFilter
9
12
 
10
13
 
11
14
  class NodeProfilesApplier:
@@ -18,7 +21,7 @@ class NodeProfilesApplier:
18
21
  3. Profile priority determines which profile wins when multiple profiles set the same attribute
19
22
  """
20
23
 
21
- def __init__(self, db: InfrahubDatabase, branch: Branch):
24
+ def __init__(self, db: InfrahubDatabase, branch: Branch) -> None:
22
25
  self.db = db
23
26
  self.branch = branch
24
27
 
@@ -42,13 +45,51 @@ class NodeProfilesApplier:
42
45
  attr_names_for_profiles.append(attr_name)
43
46
  return attr_names_for_profiles
44
47
 
48
+ async def _get_rel_names_for_profiles(self, node: Node) -> list[str]:
49
+ node_schema = node.get_schema()
50
+
51
+ rel_names_for_profiles: list[str] = []
52
+ for rel_schema in node_schema.relationships:
53
+ if not rel_schema.support_profiles:
54
+ continue
55
+
56
+ rel_name = rel_schema.name
57
+ node_rel = node.get_relationship(rel_name)
58
+
59
+ current_rels = await node_rel.get_relationships(db=self.db)
60
+ if node_rel.is_from_profile or len(current_rels) == 0:
61
+ rel_names_for_profiles.append(rel_name)
62
+
63
+ return rel_names_for_profiles
64
+
65
+ async def _get_rel_filters_for_profiles(self, node: Node, rel_names: list[str]) -> list[RelationshipFilter]:
66
+ node_schema = node.get_schema()
67
+
68
+ identifiers: list[RelationshipFilter] = []
69
+ for rel_name in rel_names:
70
+ rel_schema = node_schema.get_relationship(name=rel_name)
71
+ identifiers.append(
72
+ RelationshipFilter(
73
+ relationship_identifier=f"profile_{rel_schema.get_identifier()}", direction=rel_schema.direction
74
+ )
75
+ )
76
+
77
+ return identifiers
78
+
45
79
  async def _get_sorted_profile_data(
46
- self, profile_ids: list[str], attr_names_for_profiles: list[str]
80
+ self,
81
+ profile_ids: list[str],
82
+ attr_names_for_profiles: list[str],
83
+ relationship_filters: list[RelationshipFilter] | None = None,
47
84
  ) -> list[ProfileData]:
48
85
  if not profile_ids:
49
86
  return []
50
87
  query = await GetProfileDataQuery.init(
51
- db=self.db, branch=self.branch, profile_ids=profile_ids, attr_names=attr_names_for_profiles
88
+ db=self.db,
89
+ branch=self.branch,
90
+ profile_ids=profile_ids,
91
+ attr_names=attr_names_for_profiles,
92
+ relationship_filters=relationship_filters,
52
93
  )
53
94
  await query.execute(db=self.db)
54
95
  profile_data_list = query.get_profile_data()
@@ -76,35 +117,135 @@ class NodeProfilesApplier:
76
117
  node_attr.is_default = True
77
118
  node_attr.is_from_profile = False
78
119
 
120
+ async def _apply_profile_to_relationship(
121
+ self, node: Node, node_rel: RelationshipManager, peer_ids: list[str], profile_id: str
122
+ ) -> bool:
123
+ """Apply profile relationship peers to a node relationship.
124
+
125
+ Profile relationships are only applied if the node has no user-set peers for this relationship.
126
+ User-set peers (not from profile) take precedence over profile values.
127
+ """
128
+ is_changed = False
129
+
130
+ current_rels = await node_rel.get_relationships(db=self.db)
131
+ profile_peer_ids = set(peer_ids)
132
+
133
+ user_set_peer_ids: set[str] = set()
134
+ profile_set_rels: list[Relationship] = []
135
+ for rel in current_rels:
136
+ if rel.peer_id:
137
+ if rel.is_from_profile:
138
+ profile_set_rels.append(rel)
139
+ else:
140
+ user_set_peer_ids.add(rel.peer_id)
141
+
142
+ # If user has set any peers, they override profile values entirely
143
+ if user_set_peer_ids:
144
+ for rel in profile_set_rels:
145
+ if rel.peer_id and rel.peer_id not in user_set_peer_ids:
146
+ await node_rel.remove_locally(peer_id=rel.peer_id, db=self.db)
147
+ is_changed = True
148
+ elif rel.peer_id and rel.peer_id in user_set_peer_ids:
149
+ rel.clear_source()
150
+ is_changed = True
151
+
152
+ if is_changed:
153
+ node_rel.is_from_profile = False
154
+ await node_rel.save(db=self.db)
155
+ return is_changed
156
+
157
+ profile_set_peer_ids = {rel.peer_id for rel in profile_set_rels if rel.peer_id}
158
+ relationships_to_replace: dict[str, list[Relationship]] = {"insert": [], "remove": []}
159
+
160
+ # Remove relationships that are from profile but not in the new profile value
161
+ for rel in profile_set_rels:
162
+ if rel.peer_id and rel.peer_id not in profile_peer_ids:
163
+ relationships_to_replace["remove"].append(rel)
164
+
165
+ # Add relationships that are in profile but not present
166
+ for peer_id in profile_peer_ids:
167
+ if peer_id not in profile_set_peer_ids:
168
+ new_rel = Relationship(
169
+ schema=node_rel.schema,
170
+ branch=self.branch,
171
+ source_kind=InfrahubKind.PROFILE,
172
+ node=node,
173
+ )
174
+ await new_rel.new(db=self.db, data=peer_id)
175
+ new_rel.set_source(value=profile_id)
176
+ relationships_to_replace["insert"].append(new_rel)
177
+
178
+ is_changed |= await node_rel.update_relationships(
179
+ db=self.db,
180
+ relationships_to_remove=relationships_to_replace["remove"],
181
+ relationships_to_insert=relationships_to_replace["insert"],
182
+ )
183
+
184
+ if profile_peer_ids:
185
+ node_rel.is_from_profile = True
186
+
187
+ if is_changed:
188
+ await node_rel.save(db=self.db)
189
+
190
+ return is_changed
191
+
192
+ async def _remove_profile_from_relationship(self, relationship_manager: RelationshipManager) -> None:
193
+ relationship_manager.is_from_profile = False
194
+ await relationship_manager.delete(db=self.db)
195
+
79
196
  async def apply_profiles(self, node: Node) -> list[str]:
80
197
  profile_ids = await self._get_profile_ids(node=node)
81
198
  attr_names_for_profiles = await self._get_attr_names_for_profiles(node=node)
82
-
83
- if not attr_names_for_profiles:
199
+ rel_names_for_profiles = await self._get_rel_names_for_profiles(node=node)
200
+ rel_filters_for_profiles = await self._get_rel_filters_for_profiles(node=node, rel_names=rel_names_for_profiles)
201
+ if not attr_names_for_profiles and not rel_filters_for_profiles:
84
202
  return []
85
203
 
86
- # get profiles priorities and attribute values on branch
204
+ # get profiles priorities, attribute values, and relationship peers on branch
87
205
  sorted_profile_data = await self._get_sorted_profile_data(
88
- profile_ids=profile_ids, attr_names_for_profiles=attr_names_for_profiles
206
+ profile_ids=profile_ids,
207
+ attr_names_for_profiles=attr_names_for_profiles,
208
+ relationship_filters=rel_filters_for_profiles,
89
209
  )
90
210
 
91
- updated_field_names = []
211
+ updated_field_names: list[str] = []
92
212
  # set attribute values/is_default/is_from_profile on nodes
93
213
  for attr_name in attr_names_for_profiles:
94
- has_profile_data = False
214
+ has_profile_attr_data = False
95
215
  node_attr = node.get_attribute(attr_name)
96
216
  for profile_data in sorted_profile_data:
97
217
  profile_value = profile_data.attribute_values.get(attr_name)
98
218
  if profile_value is not None:
99
- has_profile_data = True
100
- is_changed = False
219
+ has_profile_attr_data = True
101
220
  is_changed = self._apply_profile_to_attribute(
102
221
  node_attr=node_attr, profile_value=profile_value, profile_id=profile_data.uuid
103
222
  )
104
223
  if is_changed:
105
224
  updated_field_names.append(attr_name)
106
225
  break
107
- if not has_profile_data and node_attr.is_from_profile:
226
+ if not has_profile_attr_data and node_attr.is_from_profile:
108
227
  self._remove_profile_from_attribute(node_attr=node_attr)
109
228
  updated_field_names.append(attr_name)
229
+
230
+ for rel_filter in rel_filters_for_profiles:
231
+ has_profile_rel_data = False
232
+ node_rel = node.get_relationship_by_identifier(rel_filter.relationship_identifier.removeprefix("profile_"))
233
+
234
+ for profile_data in sorted_profile_data:
235
+ profile_peers = profile_data.relationship_peers.get(rel_filter)
236
+ if profile_peers:
237
+ has_profile_rel_data = True
238
+ is_changed = await self._apply_profile_to_relationship(
239
+ node=node, node_rel=node_rel, peer_ids=profile_peers, profile_id=profile_data.uuid
240
+ )
241
+ if is_changed:
242
+ updated_field_names.append(node_rel.name)
243
+ break
244
+
245
+ # Refresh the relationship manager to update the is_from_profile property
246
+ await node_rel.fetch_relationship_ids(db=self.db)
247
+ if not has_profile_rel_data and node_rel.is_from_profile:
248
+ await self._remove_profile_from_relationship(relationship_manager=node_rel)
249
+ updated_field_names.append(node_rel.name)
250
+
110
251
  return updated_field_names