infrahub-server 1.6.3__py3-none-any.whl → 1.7.0b0__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 (161) hide show
  1. infrahub/actions/tasks.py +4 -2
  2. infrahub/api/schema.py +3 -1
  3. infrahub/artifacts/tasks.py +1 -0
  4. infrahub/auth.py +2 -2
  5. infrahub/cli/db.py +6 -6
  6. infrahub/computed_attribute/gather.py +3 -4
  7. infrahub/computed_attribute/tasks.py +23 -6
  8. infrahub/config.py +8 -0
  9. infrahub/constants/enums.py +12 -0
  10. infrahub/core/account.py +5 -8
  11. infrahub/core/attribute.py +106 -108
  12. infrahub/core/branch/models.py +44 -71
  13. infrahub/core/branch/tasks.py +5 -3
  14. infrahub/core/changelog/diff.py +1 -20
  15. infrahub/core/changelog/models.py +0 -7
  16. infrahub/core/constants/__init__.py +17 -0
  17. infrahub/core/constants/database.py +0 -1
  18. infrahub/core/constants/schema.py +0 -1
  19. infrahub/core/convert_object_type/repository_conversion.py +3 -4
  20. infrahub/core/diff/data_check_synchronizer.py +3 -2
  21. infrahub/core/diff/enricher/cardinality_one.py +1 -1
  22. infrahub/core/diff/merger/merger.py +27 -1
  23. infrahub/core/diff/merger/serializer.py +3 -10
  24. infrahub/core/diff/model/diff.py +1 -1
  25. infrahub/core/diff/query/merge.py +376 -135
  26. infrahub/core/graph/__init__.py +1 -1
  27. infrahub/core/graph/constraints.py +2 -2
  28. infrahub/core/graph/schema.py +2 -12
  29. infrahub/core/manager.py +132 -126
  30. infrahub/core/metadata/__init__.py +0 -0
  31. infrahub/core/metadata/interface.py +37 -0
  32. infrahub/core/metadata/model.py +31 -0
  33. infrahub/core/metadata/query/__init__.py +0 -0
  34. infrahub/core/metadata/query/node_metadata.py +301 -0
  35. infrahub/core/migrations/graph/__init__.py +4 -0
  36. infrahub/core/migrations/graph/m013_convert_git_password_credential.py +3 -8
  37. infrahub/core/migrations/graph/m017_add_core_profile.py +5 -2
  38. infrahub/core/migrations/graph/m018_uniqueness_nulls.py +2 -1
  39. infrahub/core/migrations/graph/m019_restore_rels_to_time.py +0 -10
  40. infrahub/core/migrations/graph/m020_duplicate_edges.py +0 -8
  41. infrahub/core/migrations/graph/m025_uniqueness_nulls.py +2 -1
  42. infrahub/core/migrations/graph/m026_0000_prefix_fix.py +2 -1
  43. infrahub/core/migrations/graph/m029_duplicates_cleanup.py +0 -1
  44. infrahub/core/migrations/graph/m031_check_number_attributes.py +2 -2
  45. infrahub/core/migrations/graph/m038_redo_0000_prefix_fix.py +2 -1
  46. infrahub/core/migrations/graph/m049_remove_is_visible_relationship.py +38 -0
  47. infrahub/core/migrations/graph/m050_backfill_vertex_metadata.py +168 -0
  48. infrahub/core/migrations/query/attribute_add.py +17 -6
  49. infrahub/core/migrations/query/attribute_remove.py +19 -5
  50. infrahub/core/migrations/query/attribute_rename.py +21 -5
  51. infrahub/core/migrations/query/node_duplicate.py +19 -4
  52. infrahub/core/migrations/schema/attribute_kind_update.py +25 -7
  53. infrahub/core/migrations/schema/attribute_supports_profile.py +3 -1
  54. infrahub/core/migrations/schema/models.py +3 -0
  55. infrahub/core/migrations/schema/node_attribute_add.py +4 -1
  56. infrahub/core/migrations/schema/node_remove.py +24 -2
  57. infrahub/core/migrations/schema/tasks.py +4 -1
  58. infrahub/core/migrations/shared.py +13 -6
  59. infrahub/core/models.py +6 -6
  60. infrahub/core/node/__init__.py +156 -57
  61. infrahub/core/node/create.py +7 -3
  62. infrahub/core/node/standard.py +100 -14
  63. infrahub/core/property.py +0 -1
  64. infrahub/core/protocols_base.py +6 -2
  65. infrahub/core/query/__init__.py +6 -7
  66. infrahub/core/query/attribute.py +161 -46
  67. infrahub/core/query/branch.py +57 -69
  68. infrahub/core/query/diff.py +4 -4
  69. infrahub/core/query/node.py +618 -180
  70. infrahub/core/query/relationship.py +449 -300
  71. infrahub/core/query/standard_node.py +25 -5
  72. infrahub/core/query/utils.py +2 -4
  73. infrahub/core/relationship/constraints/profiles_removal.py +168 -0
  74. infrahub/core/relationship/model.py +293 -139
  75. infrahub/core/schema/attribute_parameters.py +1 -28
  76. infrahub/core/schema/attribute_schema.py +17 -11
  77. infrahub/core/schema/manager.py +63 -43
  78. infrahub/core/schema/relationship_schema.py +6 -2
  79. infrahub/core/schema/schema_branch.py +48 -76
  80. infrahub/core/task/task.py +4 -2
  81. infrahub/core/utils.py +0 -22
  82. infrahub/core/validators/attribute/kind.py +2 -5
  83. infrahub/core/validators/determiner.py +3 -3
  84. infrahub/database/__init__.py +3 -3
  85. infrahub/dependencies/builder/constraint/grouped/node_runner.py +2 -0
  86. infrahub/dependencies/builder/constraint/relationship_manager/profiles_removal.py +8 -0
  87. infrahub/dependencies/registry.py +2 -0
  88. infrahub/display_labels/tasks.py +12 -3
  89. infrahub/git/integrator.py +18 -18
  90. infrahub/git/tasks.py +1 -1
  91. infrahub/graphql/app.py +2 -2
  92. infrahub/graphql/constants.py +3 -0
  93. infrahub/graphql/context.py +1 -1
  94. infrahub/graphql/initialization.py +11 -0
  95. infrahub/graphql/loaders/account.py +134 -0
  96. infrahub/graphql/loaders/node.py +5 -12
  97. infrahub/graphql/loaders/peers.py +5 -7
  98. infrahub/graphql/manager.py +158 -18
  99. infrahub/graphql/metadata.py +91 -0
  100. infrahub/graphql/models.py +33 -3
  101. infrahub/graphql/mutations/account.py +5 -5
  102. infrahub/graphql/mutations/attribute.py +0 -2
  103. infrahub/graphql/mutations/branch.py +9 -5
  104. infrahub/graphql/mutations/computed_attribute.py +1 -1
  105. infrahub/graphql/mutations/display_label.py +1 -1
  106. infrahub/graphql/mutations/hfid.py +1 -1
  107. infrahub/graphql/mutations/ipam.py +4 -6
  108. infrahub/graphql/mutations/main.py +9 -4
  109. infrahub/graphql/mutations/profile.py +16 -22
  110. infrahub/graphql/mutations/proposed_change.py +4 -4
  111. infrahub/graphql/mutations/relationship.py +40 -10
  112. infrahub/graphql/mutations/repository.py +14 -12
  113. infrahub/graphql/mutations/schema.py +2 -2
  114. infrahub/graphql/queries/branch.py +62 -6
  115. infrahub/graphql/queries/diff/tree.py +5 -5
  116. infrahub/graphql/resolvers/account_metadata.py +84 -0
  117. infrahub/graphql/resolvers/ipam.py +6 -8
  118. infrahub/graphql/resolvers/many_relationship.py +77 -35
  119. infrahub/graphql/resolvers/resolver.py +16 -12
  120. infrahub/graphql/resolvers/single_relationship.py +87 -23
  121. infrahub/graphql/subscription/graphql_query.py +2 -0
  122. infrahub/graphql/types/__init__.py +0 -1
  123. infrahub/graphql/types/attribute.py +10 -5
  124. infrahub/graphql/types/branch.py +40 -53
  125. infrahub/graphql/types/enums.py +3 -0
  126. infrahub/graphql/types/metadata.py +28 -0
  127. infrahub/graphql/types/node.py +22 -2
  128. infrahub/graphql/types/relationship.py +10 -2
  129. infrahub/graphql/types/standard_node.py +4 -3
  130. infrahub/hfid/tasks.py +12 -3
  131. infrahub/profiles/gather.py +56 -0
  132. infrahub/profiles/mandatory_fields_checker.py +116 -0
  133. infrahub/profiles/models.py +66 -0
  134. infrahub/profiles/node_applier.py +153 -12
  135. infrahub/profiles/queries/get_profile_data.py +143 -31
  136. infrahub/profiles/tasks.py +79 -27
  137. infrahub/profiles/triggers.py +22 -0
  138. infrahub/proposed_change/tasks.py +4 -1
  139. infrahub/tasks/artifact.py +1 -0
  140. infrahub/transformations/tasks.py +2 -2
  141. infrahub/trigger/catalogue.py +2 -0
  142. infrahub/trigger/models.py +1 -0
  143. infrahub/trigger/setup.py +3 -3
  144. infrahub/trigger/tasks.py +3 -0
  145. infrahub/validators/tasks.py +1 -0
  146. infrahub/webhook/models.py +1 -1
  147. infrahub/webhook/tasks.py +1 -1
  148. infrahub/workers/dependencies.py +9 -3
  149. infrahub/workers/infrahub_async.py +13 -4
  150. infrahub/workflows/catalogue.py +19 -0
  151. infrahub_sdk/node/constants.py +1 -0
  152. infrahub_sdk/node/related_node.py +13 -4
  153. infrahub_sdk/node/relationship.py +8 -0
  154. {infrahub_server-1.6.3.dist-info → infrahub_server-1.7.0b0.dist-info}/METADATA +17 -16
  155. {infrahub_server-1.6.3.dist-info → infrahub_server-1.7.0b0.dist-info}/RECORD +161 -143
  156. infrahub_testcontainers/container.py +3 -3
  157. infrahub_testcontainers/docker-compose-cluster.test.yml +7 -7
  158. infrahub_testcontainers/docker-compose.test.yml +13 -5
  159. {infrahub_server-1.6.3.dist-info → infrahub_server-1.7.0b0.dist-info}/WHEEL +0 -0
  160. {infrahub_server-1.6.3.dist-info → infrahub_server-1.7.0b0.dist-info}/entry_points.txt +0 -0
  161. {infrahub_server-1.6.3.dist-info → infrahub_server-1.7.0b0.dist-info}/licenses/LICENSE.txt +0 -0
@@ -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:
@@ -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
@@ -1,33 +1,67 @@
1
1
  from dataclasses import dataclass
2
2
  from typing import Any
3
3
 
4
- from infrahub.core.constants import NULL_VALUE
4
+ from infrahub.core.constants import NULL_VALUE, RelationshipDirection
5
5
  from infrahub.core.query import Query, QueryType
6
6
  from infrahub.database import InfrahubDatabase
7
7
 
8
8
 
9
+ @dataclass
10
+ class RelationshipFilter:
11
+ relationship_identifier: str
12
+ direction: RelationshipDirection
13
+
14
+ def __hash__(self) -> int:
15
+ return hash((self.relationship_identifier, self.direction))
16
+
17
+
9
18
  @dataclass
10
19
  class ProfileData:
11
20
  uuid: str
12
21
  priority: float | int
13
22
  attribute_values: dict[str, Any]
23
+ relationship_peers: dict[RelationshipFilter, list[str]]
14
24
 
15
25
 
16
26
  class GetProfileDataQuery(Query):
17
27
  type: QueryType = QueryType.READ
18
28
  insert_return: bool = False
19
29
 
20
- def __init__(self, *args: Any, profile_ids: list[str], attr_names: list[str], **kwargs: Any):
30
+ def __init__(
31
+ self,
32
+ *args: Any,
33
+ profile_ids: list[str],
34
+ attr_names: list[str],
35
+ relationship_filters: list[RelationshipFilter] | None = None,
36
+ **kwargs: Any,
37
+ ):
21
38
  super().__init__(*args, **kwargs)
22
39
  self.profile_ids = profile_ids
23
40
  self.attr_names = attr_names
41
+ self.relationship_filters = relationship_filters or []
24
42
 
25
43
  async def query_init(self, db: InfrahubDatabase, **kwargs: dict[str, Any]) -> None: # noqa: ARG002
26
- branch_filter, branch_params = self.branch.get_query_filter_path(at=self.at.to_string())
44
+ branch_filter, branch_params = self.branch.get_query_filter_path(at=self.at)
27
45
  self.params.update(branch_params)
28
46
  self.params["profile_ids"] = self.profile_ids
29
47
  self.params["attr_names"] = self.attr_names + ["profile_priority"]
30
48
 
49
+ # Prepare relationship filters
50
+ outbound_identifiers = []
51
+ inbound_identifiers = []
52
+ bidirectional_identifiers = []
53
+ for rf in self.relationship_filters:
54
+ if rf.direction == RelationshipDirection.OUTBOUND:
55
+ outbound_identifiers.append(rf.relationship_identifier)
56
+ elif rf.direction == RelationshipDirection.INBOUND:
57
+ inbound_identifiers.append(rf.relationship_identifier)
58
+ elif rf.direction == RelationshipDirection.BIDIR:
59
+ bidirectional_identifiers.append(rf.relationship_identifier)
60
+
61
+ self.params["outbound_identifiers"] = outbound_identifiers
62
+ self.params["inbound_identifiers"] = inbound_identifiers
63
+ self.params["bidirectional_identifiers"] = bidirectional_identifiers
64
+
31
65
  query = """
32
66
  // --------------
33
67
  // get the Profile nodes
@@ -48,51 +82,129 @@ WHERE is_active = TRUE
48
82
  // --------------
49
83
  // get the attributes that we care about
50
84
  // --------------
51
- MATCH (profile)-[:HAS_ATTRIBUTE]-(attr:Attribute)
85
+ OPTIONAL MATCH (profile)-[:HAS_ATTRIBUTE]-(attr:Attribute)
52
86
  WHERE attr.name IN $attr_names
53
87
  WITH DISTINCT profile, attr
54
88
  CALL (profile, attr) {
55
- MATCH (profile)-[r:HAS_ATTRIBUTE]->(attr)
89
+ OPTIONAL MATCH (profile)-[r:HAS_ATTRIBUTE]->(attr)
56
90
  WHERE %(branch_filter)s
57
91
  ORDER BY r.branch_level DESC, r.from DESC, r.status ASC
58
- RETURN r.status = "active" AS is_active
92
+ RETURN r.status = "active" AS r1_is_active
59
93
  }
60
- WITH profile, attr
61
- WHERE is_active = TRUE
62
94
  // --------------
63
95
  // get the attribute values
64
96
  // --------------
65
97
  CALL (attr) {
66
- MATCH (attr)-[r:HAS_VALUE]->(av)
98
+ OPTIONAL MATCH (attr)-[r:HAS_VALUE]->(av)
67
99
  WHERE %(branch_filter)s
68
100
  ORDER BY r.branch_level DESC, r.from DESC, r.status ASC
69
- RETURN av, r.status = "active" AS is_active
101
+ RETURN av, r.status = "active" AS r2_is_active
70
102
  LIMIT 1
71
103
  }
72
- WITH profile, attr, av
73
- WHERE is_active = TRUE
74
- RETURN profile.uuid AS profile_uuid, attr.name AS attr_name, av.value AS attr_value
104
+ // --------------
105
+ // filter out null and inactive attributes
106
+ // --------------
107
+ WITH profile, CASE
108
+ WHEN attr IS NOT NULL AND av IS NOT NULL AND r1_is_active = TRUE AND r2_is_active = TRUE THEN [attr.name, av.value]
109
+ ELSE NULL
110
+ END AS attribute_details
111
+ WITH profile, collect(attribute_details) AS attributes
112
+ // --------------
113
+ // get all possible relationships we might want for this profile
114
+ // --------------
115
+ OPTIONAL MATCH (profile)-[r:IS_RELATED]-(rel:Relationship)
116
+ WHERE rel.name IN $outbound_identifiers + $bidirectional_identifiers + $inbound_identifiers
117
+ AND %(branch_filter)s
118
+ WITH DISTINCT profile, attributes, rel
119
+ // --------------
120
+ // filter to active near-side relationships with names and directions we want
121
+ // --------------
122
+ CALL (profile, rel) {
123
+ OPTIONAL MATCH (profile)-[r:IS_RELATED]-(rel)
124
+ WHERE (
125
+ (rel.name IN $outbound_identifiers AND startNode(r) = profile)
126
+ OR (rel.name IN $bidirectional_identifiers AND startNode(r) = profile)
127
+ OR (rel.name IN $inbound_identifiers AND startNode(r) = rel)
128
+ )
129
+ AND %(branch_filter)s
130
+ RETURN r AS r1
131
+ ORDER BY r1.branch_level DESC, r1.from DESC, r1.status ASC
132
+ LIMIT 1
133
+ }
134
+ WITH profile, attributes, r1, rel
135
+ // --------------
136
+ // filter to active far-side relationships with names and directions we want
137
+ // --------------
138
+ CALL (profile, rel) {
139
+ OPTIONAL MATCH (rel)-[r:IS_RELATED]-(peer)
140
+ WHERE peer <> profile
141
+ AND (
142
+ (rel.name IN $outbound_identifiers AND startNode(r) = rel)
143
+ OR (rel.name IN $bidirectional_identifiers AND startNode(r) = peer)
144
+ OR (rel.name IN $inbound_identifiers AND startNode(r) = peer)
145
+ )
146
+ AND %(branch_filter)s
147
+ RETURN r AS r2, peer
148
+ ORDER BY r2.branch_level DESC, r2.from DESC, r2.status ASC
149
+ LIMIT 1
150
+ }
151
+ WITH profile, attributes, r1, rel, r2, peer
152
+ // --------------
153
+ // save the direction of the relationship
154
+ // --------------
155
+ WITH *, CASE
156
+ WHEN r1 IS NULL OR r2 IS NULL THEN NULL
157
+ WHEN startNode(r1) = profile AND startNode(r2) = rel THEN "outbound"
158
+ WHEN startNode(r1) = rel AND startNode(r2) = peer THEN "inbound"
159
+ ELSE "bidirectional"
160
+ END AS direction
161
+ // --------------
162
+ // filter out null and inactive relationships
163
+ // --------------
164
+ WITH profile, attributes, CASE
165
+ WHEN rel IS NOT NULL AND peer IS NOT NULL AND r1.status = "active" AND r2.status = "active" THEN [rel.name, direction, peer.uuid]
166
+ ELSE NULL
167
+ END AS relationship_details
168
+ WITH profile, attributes, collect(relationship_details) AS relationships
169
+ RETURN profile.uuid AS profile_uuid, attributes, relationships
75
170
  """ % {"branch_filter": branch_filter}
76
171
  self.add_to_query(query)
77
- self.return_labels = ["profile_uuid", "attr_name", "attr_value"]
172
+ self.return_labels = ["profile_uuid", "attributes", "relationships"]
78
173
 
79
174
  def get_profile_data(self) -> list[ProfileData]:
80
- profile_data_by_uuid: dict[str, ProfileData] = {}
175
+ profile_data_list: list[ProfileData] = []
81
176
  for result in self.results:
82
177
  profile_uuid = result.get_as_type(label="profile_uuid", return_type=str)
83
- if profile_uuid not in profile_data_by_uuid:
84
- profile_data_by_uuid[profile_uuid] = ProfileData(
85
- uuid=profile_uuid, priority=float("inf"), attribute_values={}
86
- )
87
- profile_data = profile_data_by_uuid[profile_uuid]
88
- attr_name = result.get_as_type(label="attr_name", return_type=str)
89
- attr_value: Any = result.get(label="attr_value")
90
- if attr_value == NULL_VALUE:
91
- attr_value = None
92
- if attr_name == "profile_priority":
93
- if attr_value is not None and not isinstance(attr_value, int):
94
- attr_value = int(attr_value)
95
- profile_data.priority = attr_value
96
- else:
97
- profile_data.attribute_values[attr_name] = attr_value
98
- return list(profile_data_by_uuid.values())
178
+ attributes = result.get(label="attributes")
179
+ relationships = result.get(label="relationships")
180
+
181
+ profile_data = ProfileData(
182
+ uuid=profile_uuid, priority=float("inf"), attribute_values={}, relationship_peers={}
183
+ )
184
+
185
+ for attr_pair in attributes:
186
+ if not isinstance(attr_pair, list) or len(attr_pair) != 2:
187
+ continue
188
+ attr_name, attr_value = attr_pair
189
+ if attr_value == NULL_VALUE:
190
+ attr_value = None
191
+ if attr_name == "profile_priority":
192
+ if attr_value is not None and not isinstance(attr_value, int):
193
+ attr_value = int(attr_value)
194
+ profile_data.priority = attr_value
195
+ else:
196
+ profile_data.attribute_values[attr_name] = attr_value
197
+
198
+ # Parse relationships
199
+ for rel_tuple in relationships:
200
+ if not isinstance(rel_tuple, list) or len(rel_tuple) != 3:
201
+ continue
202
+ rel_name, direction_str, peer_uuid = rel_tuple
203
+ direction = RelationshipDirection(direction_str)
204
+ rel_filter = RelationshipFilter(relationship_identifier=rel_name, direction=direction)
205
+ if rel_filter not in profile_data.relationship_peers:
206
+ profile_data.relationship_peers[rel_filter] = []
207
+ profile_data.relationship_peers[rel_filter].append(peer_uuid)
208
+
209
+ profile_data_list.append(profile_data)
210
+ return profile_data_list
@@ -1,11 +1,21 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from typing import TYPE_CHECKING
4
+
3
5
  from prefect import flow
4
6
  from prefect.logging import get_run_logger
5
7
 
6
- from infrahub.workers.dependencies import get_client, get_workflow
8
+ from infrahub.context import InfrahubContext # noqa: TC001 needed for prefect flow
9
+ from infrahub.trigger.models import TriggerSetupReport, TriggerType
10
+ from infrahub.trigger.setup import setup_triggers_specific
11
+ from infrahub.workers.dependencies import get_client, get_component, get_database, get_workflow
7
12
  from infrahub.workflows.catalogue import PROFILE_REFRESH
8
- from infrahub.workflows.utils import add_tags
13
+ from infrahub.workflows.utils import add_tags, wait_for_schema_to_converge
14
+
15
+ from .gather import gather_trigger_profile_refresh
16
+
17
+ if TYPE_CHECKING:
18
+ from infrahub_sdk.node.relationship import RelationshipManager
9
19
 
10
20
  REFRESH_PROFILES_MUTATION = """
11
21
  mutation RefreshProfiles(
@@ -20,44 +30,86 @@ mutation RefreshProfiles(
20
30
  """
21
31
 
22
32
 
23
- @flow(
24
- name="object-profiles-refresh",
25
- flow_run_name="Refresh profiles for {node_id}",
26
- )
27
- async def object_profiles_refresh(
28
- branch_name: str,
29
- node_id: str,
30
- ) -> None:
33
+ @flow(name="object-profiles-refresh", flow_run_name="Refresh profiles for {node_id}")
34
+ async def object_profiles_refresh(branch_name: str, node_id: str) -> None:
31
35
  log = get_run_logger()
32
36
  client = get_client()
33
37
 
34
38
  await add_tags(branches=[branch_name], nodes=[node_id], db_change=True)
35
- await client.execute_graphql(
36
- query=REFRESH_PROFILES_MUTATION,
37
- variables={"id": node_id},
38
- branch_name=branch_name,
39
- )
39
+ await client.execute_graphql(query=REFRESH_PROFILES_MUTATION, variables={"id": node_id}, branch_name=branch_name)
40
40
  log.info(f"Profiles refreshed for {node_id}")
41
41
 
42
42
 
43
- @flow(
44
- name="objects-profiles-refresh-multiple",
45
- flow_run_name="Refresh profiles for multiple objects",
46
- )
47
- async def objects_profiles_refresh_multiple(
43
+ @flow(name="objects-profiles-refresh-multiple", flow_run_name="Refresh profiles for multiple objects")
44
+ async def objects_profiles_refresh_multiple(branch_name: str, node_ids: list[str]) -> None:
45
+ log = get_run_logger()
46
+
47
+ await add_tags(branches=[branch_name])
48
+
49
+ for node_id in node_ids:
50
+ log.info(f"Requesting profile refresh for {node_id}")
51
+ await get_workflow().submit_workflow(
52
+ workflow=PROFILE_REFRESH, parameters={"branch_name": branch_name, "node_id": node_id}
53
+ )
54
+
55
+
56
+ @flow(name="profile-refresh-setup", flow_run_name="Setup profile refresh triggers")
57
+ async def profile_refresh_setup(
58
+ context: InfrahubContext, # noqa: ARG001
59
+ branch_name: str | None = None,
60
+ event_name: str | None = None, # noqa: ARG001
61
+ ) -> None:
62
+ """Setup Prefect automations for profile refresh triggers.
63
+
64
+ This flow is triggered by schema changes and sets up automations that will
65
+ listen for profile updates. When a profile's attributes or relationships
66
+ change, the corresponding automation will trigger profile refresh for all
67
+ related nodes.
68
+ """
69
+ database = await get_database()
70
+ async with database.start_session() as db:
71
+ log = get_run_logger()
72
+
73
+ if branch_name:
74
+ await add_tags(branches=[branch_name])
75
+ component = await get_component()
76
+ await wait_for_schema_to_converge(branch_name=branch_name, component=component, db=db, log=log)
77
+
78
+ report: TriggerSetupReport = await setup_triggers_specific(
79
+ gatherer=gather_trigger_profile_refresh, trigger_type=TriggerType.PROFILE
80
+ ) # type: ignore[misc]
81
+
82
+ log.info(f"{report.in_use_count} Profile refresh automation configuration completed")
83
+
84
+
85
+ @flow(name="profile-refresh-process", flow_run_name="Process profile refresh for {profile_kind}")
86
+ async def profile_refresh_process(
48
87
  branch_name: str,
49
- node_ids: list[str],
88
+ profile_kind: str,
89
+ profile_id: str,
90
+ context: InfrahubContext, # noqa: ARG001
50
91
  ) -> None:
92
+ """Process profile refresh when a profile's attributes or relationships change.
93
+
94
+ This flow fetches all nodes related to the profile via the `related_nodes`
95
+ relationship and submits profile refresh workflows for each of them.
96
+ """
51
97
  log = get_run_logger()
98
+ client = get_client()
52
99
 
53
100
  await add_tags(branches=[branch_name])
54
101
 
55
- for node_id in node_ids:
102
+ profile = await client.get(kind=profile_kind, id=profile_id, branch=branch_name, include=["related_nodes"])
103
+ related_nodes: RelationshipManager = profile.related_nodes # type: ignore
104
+
105
+ if not related_nodes.peer_ids:
106
+ log.info(f"No related nodes found for profile {profile_id}")
107
+ return
108
+
109
+ log.info(f"Found {len(related_nodes.peer_ids)} related nodes for profile {profile_id}")
110
+
111
+ for node_id in related_nodes.peer_ids:
56
112
  log.info(f"Requesting profile refresh for {node_id}")
57
113
  await get_workflow().submit_workflow(
58
- workflow=PROFILE_REFRESH,
59
- parameters={
60
- "branch_name": branch_name,
61
- "node_id": node_id,
62
- },
114
+ workflow=PROFILE_REFRESH, parameters={"branch_name": branch_name, "node_id": node_id}
63
115
  )
@@ -0,0 +1,22 @@
1
+ from infrahub.events.branch_action import BranchDeletedEvent
2
+ from infrahub.events.schema_action import SchemaUpdatedEvent
3
+ from infrahub.trigger.models import BuiltinTriggerDefinition, EventTrigger, ExecuteWorkflow
4
+ from infrahub.workflows.catalogue import PROFILE_REFRESH_SETUP
5
+
6
+ TRIGGER_PROFILE_REFRESH_SETUP = BuiltinTriggerDefinition(
7
+ name="profile-refresh-setup-all",
8
+ trigger=EventTrigger(events={SchemaUpdatedEvent.event_name, BranchDeletedEvent.event_name}),
9
+ actions=[
10
+ ExecuteWorkflow(
11
+ workflow=PROFILE_REFRESH_SETUP,
12
+ parameters={
13
+ "branch_name": "{{ event.resource['infrahub.branch.name'] }}",
14
+ "event_name": "{{ event.event }}",
15
+ "context": {
16
+ "__prefect_kind": "json",
17
+ "value": {"__prefect_kind": "jinja", "template": "{{ event.payload['context'] | tojson }}"},
18
+ },
19
+ },
20
+ ),
21
+ ],
22
+ )
@@ -622,6 +622,7 @@ async def validate_artifacts_generation(model: RequestArtifactDefinitionCheck, c
622
622
 
623
623
  log = get_run_logger()
624
624
  client = get_client()
625
+ client.request_context = context.to_request_context()
625
626
 
626
627
  artifact_definition = await client.get(
627
628
  kind=CoreArtifactDefinition,
@@ -781,6 +782,7 @@ async def run_generator_as_check(model: RunGeneratorAsCheckModel, context: Infra
781
782
  await add_tags(branches=[model.branch_name], nodes=[model.proposed_change], db_change=True)
782
783
 
783
784
  client = get_client()
785
+ client.request_context = context.to_request_context()
784
786
  log = get_run_logger()
785
787
 
786
788
  repository = await get_initialized_repo(
@@ -859,7 +861,7 @@ async def run_generator_as_check(model: RunGeneratorAsCheckModel, context: Infra
859
861
  if check:
860
862
  check.created_at.value = Timestamp().to_string()
861
863
  check.conclusion.value = conclusion.value
862
- await check.save()
864
+ await check.save(request_context=context.to_request_context())
863
865
  else:
864
866
  check = await client.create(
865
867
  kind=InfrahubKind.GENERATORCHECK,
@@ -925,6 +927,7 @@ async def request_generator_definition_check(model: RequestGeneratorDefinitionCh
925
927
 
926
928
  log = get_run_logger()
927
929
  client = get_client()
930
+ client.request_context = context.to_request_context()
928
931
 
929
932
  proposed_change = await client.get(kind=InfrahubKind.PROPOSEDCHANGE, id=model.proposed_change)
930
933
 
@@ -13,6 +13,7 @@ from infrahub.workers.dependencies import get_client
13
13
  async def define_artifact(model: CheckArtifactCreate | RequestArtifactGenerate) -> tuple[InfrahubNode, bool]:
14
14
  """Return an artifact together with a flag to indicate if the artifact is created now or already existed."""
15
15
  client = get_client()
16
+ client.request_context = model.context.to_request_context()
16
17
  created = False
17
18
  if model.artifact_id:
18
19
  artifact = await client.get(kind=InfrahubKind.ARTIFACT, id=model.artifact_id, branch=model.branch_name)
@@ -33,7 +33,7 @@ async def transform_python(message: TransformPythonData) -> Any:
33
33
  location=message.transform_location,
34
34
  data=message.data,
35
35
  convert_query_response=message.convert_query_response,
36
- ) # type: ignore[misc]
36
+ ) # type: ignore[call-overload]
37
37
 
38
38
  return transformed_data
39
39
 
@@ -54,6 +54,6 @@ async def transform_render_jinja2_template(message: TransformJinjaTemplateData)
54
54
 
55
55
  rendered_template = await repo.render_jinja2_template.with_options(timeout_seconds=message.timeout)(
56
56
  commit=message.commit, location=message.template_location, data={"data": message.data}
57
- ) # type: ignore[misc]
57
+ ) # type: ignore[call-overload]
58
58
 
59
59
  return rendered_template
@@ -6,6 +6,7 @@ from infrahub.computed_attribute.triggers import (
6
6
  )
7
7
  from infrahub.display_labels.triggers import TRIGGER_DISPLAY_LABELS_ALL_SCHEMA
8
8
  from infrahub.hfid.triggers import TRIGGER_HFID_ALL_SCHEMA
9
+ from infrahub.profiles.triggers import TRIGGER_PROFILE_REFRESH_SETUP
9
10
  from infrahub.schema.triggers import TRIGGER_SCHEMA_UPDATED
10
11
  from infrahub.trigger.models import TriggerDefinition
11
12
  from infrahub.webhook.triggers import TRIGGER_WEBHOOK_DELETE, TRIGGER_WEBHOOK_SETUP_UPDATE
@@ -17,6 +18,7 @@ builtin_triggers: list[TriggerDefinition] = [
17
18
  TRIGGER_COMPUTED_ATTRIBUTE_PYTHON_SETUP_COMMIT,
18
19
  TRIGGER_DISPLAY_LABELS_ALL_SCHEMA,
19
20
  TRIGGER_HFID_ALL_SCHEMA,
21
+ TRIGGER_PROFILE_REFRESH_SETUP,
20
22
  TRIGGER_SCHEMA_UPDATED,
21
23
  TRIGGER_WEBHOOK_DELETE,
22
24
  TRIGGER_WEBHOOK_SETUP_UPDATE,