infrahub-server 1.3.7__py3-none-any.whl → 1.4.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 (174) hide show
  1. infrahub/api/internal.py +5 -0
  2. infrahub/artifacts/tasks.py +17 -22
  3. infrahub/branch/merge_mutation_checker.py +38 -0
  4. infrahub/cli/__init__.py +2 -2
  5. infrahub/cli/context.py +7 -3
  6. infrahub/cli/db.py +5 -16
  7. infrahub/cli/upgrade.py +10 -29
  8. infrahub/computed_attribute/tasks.py +36 -46
  9. infrahub/config.py +57 -6
  10. infrahub/constants/environment.py +1 -0
  11. infrahub/core/attribute.py +15 -7
  12. infrahub/core/branch/tasks.py +43 -41
  13. infrahub/core/constants/__init__.py +21 -6
  14. infrahub/core/constants/infrahubkind.py +2 -0
  15. infrahub/core/diff/coordinator.py +3 -1
  16. infrahub/core/diff/model/path.py +0 -39
  17. infrahub/core/diff/repository/repository.py +0 -8
  18. infrahub/core/diff/tasks.py +11 -8
  19. infrahub/core/graph/__init__.py +1 -1
  20. infrahub/core/graph/index.py +1 -2
  21. infrahub/core/graph/schema.py +50 -29
  22. infrahub/core/initialization.py +81 -47
  23. infrahub/core/ipam/tasks.py +4 -3
  24. infrahub/core/merge.py +8 -10
  25. infrahub/core/migrations/__init__.py +2 -0
  26. infrahub/core/migrations/graph/__init__.py +4 -0
  27. infrahub/core/migrations/graph/m036_drop_attr_value_index.py +45 -0
  28. infrahub/core/migrations/graph/m037_index_attr_vals.py +577 -0
  29. infrahub/core/migrations/query/attribute_add.py +27 -2
  30. infrahub/core/migrations/schema/attribute_kind_update.py +156 -0
  31. infrahub/core/migrations/schema/tasks.py +6 -5
  32. infrahub/core/models.py +5 -1
  33. infrahub/core/node/proposed_change.py +43 -0
  34. infrahub/core/protocols.py +12 -0
  35. infrahub/core/query/attribute.py +32 -14
  36. infrahub/core/query/diff.py +11 -0
  37. infrahub/core/query/ipam.py +13 -7
  38. infrahub/core/query/node.py +51 -10
  39. infrahub/core/query/resource_manager.py +3 -3
  40. infrahub/core/schema/basenode_schema.py +8 -0
  41. infrahub/core/schema/definitions/core/__init__.py +10 -1
  42. infrahub/core/schema/definitions/core/ipam.py +28 -2
  43. infrahub/core/schema/definitions/core/propose_change.py +15 -0
  44. infrahub/core/schema/definitions/core/webhook.py +3 -0
  45. infrahub/core/schema/definitions/internal.py +1 -1
  46. infrahub/core/schema/generated/attribute_schema.py +1 -1
  47. infrahub/core/schema/generic_schema.py +10 -0
  48. infrahub/core/schema/manager.py +10 -1
  49. infrahub/core/schema/node_schema.py +22 -22
  50. infrahub/core/schema/profile_schema.py +8 -0
  51. infrahub/core/schema/schema_branch.py +11 -7
  52. infrahub/core/schema/template_schema.py +8 -0
  53. infrahub/core/validators/attribute/kind.py +5 -1
  54. infrahub/core/validators/checks_runner.py +5 -5
  55. infrahub/core/validators/determiner.py +22 -2
  56. infrahub/core/validators/tasks.py +6 -7
  57. infrahub/core/validators/uniqueness/checker.py +4 -2
  58. infrahub/core/validators/uniqueness/model.py +1 -0
  59. infrahub/core/validators/uniqueness/query.py +57 -7
  60. infrahub/database/__init__.py +2 -1
  61. infrahub/events/__init__.py +20 -0
  62. infrahub/events/constants.py +7 -0
  63. infrahub/events/generator.py +29 -2
  64. infrahub/events/proposed_change_action.py +203 -0
  65. infrahub/generators/tasks.py +24 -20
  66. infrahub/git/base.py +4 -7
  67. infrahub/git/integrator.py +21 -12
  68. infrahub/git/repository.py +15 -30
  69. infrahub/git/tasks.py +121 -106
  70. infrahub/graphql/app.py +2 -1
  71. infrahub/graphql/field_extractor.py +69 -0
  72. infrahub/graphql/manager.py +15 -11
  73. infrahub/graphql/mutations/account.py +2 -2
  74. infrahub/graphql/mutations/action.py +8 -2
  75. infrahub/graphql/mutations/artifact_definition.py +4 -1
  76. infrahub/graphql/mutations/branch.py +10 -5
  77. infrahub/graphql/mutations/graphql_query.py +2 -1
  78. infrahub/graphql/mutations/main.py +14 -8
  79. infrahub/graphql/mutations/menu.py +2 -1
  80. infrahub/graphql/mutations/proposed_change.py +230 -8
  81. infrahub/graphql/mutations/relationship.py +5 -0
  82. infrahub/graphql/mutations/repository.py +2 -1
  83. infrahub/graphql/mutations/tasks.py +7 -9
  84. infrahub/graphql/mutations/webhook.py +4 -1
  85. infrahub/graphql/parser.py +15 -6
  86. infrahub/graphql/queries/__init__.py +10 -1
  87. infrahub/graphql/queries/account.py +3 -3
  88. infrahub/graphql/queries/branch.py +2 -2
  89. infrahub/graphql/queries/diff/tree.py +56 -5
  90. infrahub/graphql/queries/event.py +13 -3
  91. infrahub/graphql/queries/ipam.py +23 -1
  92. infrahub/graphql/queries/proposed_change.py +84 -0
  93. infrahub/graphql/queries/relationship.py +2 -2
  94. infrahub/graphql/queries/resource_manager.py +3 -3
  95. infrahub/graphql/queries/search.py +3 -2
  96. infrahub/graphql/queries/status.py +3 -2
  97. infrahub/graphql/queries/task.py +2 -2
  98. infrahub/graphql/resolvers/ipam.py +440 -0
  99. infrahub/graphql/resolvers/many_relationship.py +4 -3
  100. infrahub/graphql/resolvers/resolver.py +5 -5
  101. infrahub/graphql/resolvers/single_relationship.py +3 -2
  102. infrahub/graphql/schema.py +25 -5
  103. infrahub/graphql/types/__init__.py +2 -2
  104. infrahub/graphql/types/attribute.py +3 -3
  105. infrahub/graphql/types/event.py +68 -0
  106. infrahub/groups/tasks.py +6 -6
  107. infrahub/lock.py +3 -2
  108. infrahub/menu/generator.py +8 -0
  109. infrahub/message_bus/operations/__init__.py +9 -12
  110. infrahub/message_bus/operations/git/file.py +6 -5
  111. infrahub/message_bus/operations/git/repository.py +12 -20
  112. infrahub/message_bus/operations/refresh/registry.py +15 -9
  113. infrahub/message_bus/operations/send/echo.py +7 -4
  114. infrahub/message_bus/types.py +1 -0
  115. infrahub/permissions/__init__.py +2 -1
  116. infrahub/permissions/constants.py +13 -0
  117. infrahub/permissions/globals.py +31 -2
  118. infrahub/permissions/manager.py +8 -5
  119. infrahub/pools/prefix.py +7 -5
  120. infrahub/prefect_server/app.py +31 -0
  121. infrahub/prefect_server/bootstrap.py +18 -0
  122. infrahub/proposed_change/action_checker.py +206 -0
  123. infrahub/proposed_change/approval_revoker.py +40 -0
  124. infrahub/proposed_change/branch_diff.py +3 -1
  125. infrahub/proposed_change/checker.py +45 -0
  126. infrahub/proposed_change/constants.py +32 -2
  127. infrahub/proposed_change/tasks.py +182 -150
  128. infrahub/py.typed +0 -0
  129. infrahub/server.py +29 -17
  130. infrahub/services/__init__.py +13 -28
  131. infrahub/services/adapters/cache/__init__.py +4 -0
  132. infrahub/services/adapters/cache/nats.py +2 -0
  133. infrahub/services/adapters/cache/redis.py +3 -0
  134. infrahub/services/adapters/message_bus/__init__.py +0 -2
  135. infrahub/services/adapters/message_bus/local.py +1 -2
  136. infrahub/services/adapters/message_bus/nats.py +6 -8
  137. infrahub/services/adapters/message_bus/rabbitmq.py +7 -9
  138. infrahub/services/adapters/workflow/__init__.py +1 -0
  139. infrahub/services/adapters/workflow/local.py +1 -8
  140. infrahub/services/component.py +2 -1
  141. infrahub/task_manager/event.py +56 -0
  142. infrahub/task_manager/models.py +9 -0
  143. infrahub/tasks/artifact.py +6 -7
  144. infrahub/tasks/check.py +4 -7
  145. infrahub/telemetry/tasks.py +15 -18
  146. infrahub/transformations/tasks.py +10 -6
  147. infrahub/trigger/tasks.py +4 -3
  148. infrahub/types.py +4 -0
  149. infrahub/validators/events.py +7 -7
  150. infrahub/validators/tasks.py +6 -7
  151. infrahub/webhook/models.py +45 -45
  152. infrahub/webhook/tasks.py +25 -24
  153. infrahub/workers/dependencies.py +143 -0
  154. infrahub/workers/infrahub_async.py +19 -43
  155. infrahub/workflows/catalogue.py +16 -2
  156. infrahub/workflows/initialization.py +5 -4
  157. infrahub/workflows/models.py +2 -0
  158. infrahub_sdk/client.py +2 -2
  159. infrahub_sdk/ctl/repository.py +51 -0
  160. infrahub_sdk/ctl/schema.py +9 -9
  161. infrahub_sdk/node/node.py +2 -2
  162. infrahub_sdk/pytest_plugin/items/graphql_query.py +1 -1
  163. infrahub_sdk/schema/repository.py +1 -1
  164. infrahub_sdk/testing/docker.py +1 -1
  165. infrahub_sdk/utils.py +2 -2
  166. {infrahub_server-1.3.7.dist-info → infrahub_server-1.4.0.dist-info}/METADATA +7 -5
  167. {infrahub_server-1.3.7.dist-info → infrahub_server-1.4.0.dist-info}/RECORD +174 -158
  168. infrahub_testcontainers/container.py +17 -0
  169. infrahub_testcontainers/docker-compose-cluster.test.yml +56 -1
  170. infrahub_testcontainers/docker-compose.test.yml +56 -1
  171. infrahub_testcontainers/helpers.py +4 -1
  172. {infrahub_server-1.3.7.dist-info → infrahub_server-1.4.0.dist-info}/LICENSE.txt +0 -0
  173. {infrahub_server-1.3.7.dist-info → infrahub_server-1.4.0.dist-info}/WHEEL +0 -0
  174. {infrahub_server-1.3.7.dist-info → infrahub_server-1.4.0.dist-info}/entry_points.txt +0 -0
@@ -1,8 +1,11 @@
1
+ from __future__ import annotations
2
+
1
3
  from typing import TYPE_CHECKING, Any, Self
2
4
 
3
- from graphene import Boolean, Field, InputObjectType, Mutation, String
5
+ from graphene import Boolean, Enum, Field, InputObjectType, List, Mutation, String
4
6
  from graphql import GraphQLResolveInfo
5
7
 
8
+ from infrahub import lock
6
9
  from infrahub.core.account import GlobalPermission
7
10
  from infrahub.core.branch import Branch
8
11
  from infrahub.core.constants import (
@@ -12,26 +15,45 @@ from infrahub.core.constants import (
12
15
  PermissionDecision,
13
16
  )
14
17
  from infrahub.core.manager import NodeManager
15
- from infrahub.core.node import Node
18
+ from infrahub.core.protocols import CoreProposedChange
16
19
  from infrahub.core.schema import NodeSchema
17
20
  from infrahub.database import InfrahubDatabase, retry_db_transaction
21
+ from infrahub.events import (
22
+ EventMeta,
23
+ ProposedChangeApprovalRevokedEvent,
24
+ ProposedChangeApprovedEvent,
25
+ ProposedChangeRejectedEvent,
26
+ ProposedChangeRejectionRevokedEvent,
27
+ )
18
28
  from infrahub.exceptions import BranchNotFoundError, PermissionDeniedError, ValidationError
19
29
  from infrahub.graphql.mutations.main import InfrahubMutationMixin
20
30
  from infrahub.graphql.types.enums import CheckType as GraphQLCheckType
21
- from infrahub.proposed_change.constants import ProposedChangeState
31
+ from infrahub.graphql.types.task import TaskInfo
32
+ from infrahub.lock import InfrahubLock, build_object_lock_name
33
+ from infrahub.proposed_change.approval_revoker import do_revoke_approvals_on_updated_pcs
34
+ from infrahub.proposed_change.constants import ProposedChangeApprovalDecision, ProposedChangeState
35
+ from infrahub.proposed_change.models import RequestProposedChangePipeline
36
+ from infrahub.workers.dependencies import get_event_service
22
37
  from infrahub.workflows.catalogue import PROPOSED_CHANGE_MERGE, REQUEST_PROPOSED_CHANGE_PIPELINE
23
38
 
24
- from ...proposed_change.models import RequestProposedChangePipeline
25
- from ..types.task import TaskInfo
26
39
  from .main import InfrahubMutationOptions
27
40
 
28
41
  if TYPE_CHECKING:
42
+ from graphql import GraphQLResolveInfo
43
+
44
+ from infrahub.core.node import Node
45
+ from infrahub.events.models import InfrahubEvent
46
+
29
47
  from ..initialization import GraphqlContext
30
48
 
49
+ ProposedChangeApprovalDecisionInput = Enum.from_enum(ProposedChangeApprovalDecision)
50
+
31
51
 
32
52
  class InfrahubProposedChangeMutation(InfrahubMutationMixin, Mutation):
33
53
  @classmethod
34
- def __init_subclass_with_meta__(cls, schema: NodeSchema = None, _meta=None, **options) -> None:
54
+ def __init_subclass_with_meta__(
55
+ cls, schema: NodeSchema, _meta: InfrahubMutationOptions | None = None, **options: dict[str, Any]
56
+ ) -> None:
35
57
  # Make sure schema is a valid NodeSchema Node Class
36
58
  if not isinstance(schema, NodeSchema):
37
59
  raise ValueError(f"You need to pass a valid NodeSchema in '{cls.__name__}.Meta', received '{schema}'")
@@ -50,11 +72,19 @@ class InfrahubProposedChangeMutation(InfrahubMutationMixin, Mutation):
50
72
  data: InputObjectType,
51
73
  branch: Branch,
52
74
  database: InfrahubDatabase | None = None, # noqa: ARG003
75
+ override_data: dict[str, Any] | None = None,
53
76
  ) -> tuple[Node, Self]:
54
77
  graphql_context: GraphqlContext = info.context
55
78
 
79
+ override_data = {"created_by": {"id": graphql_context.active_account_session.account_id}}
80
+ state = data.get("state", {}).get("value")
81
+ if state and state != ProposedChangeState.OPEN.value:
82
+ raise ValidationError(input_value="A proposed change has to be in the open state during creation")
83
+
56
84
  async with graphql_context.db.start_transaction() as dbt:
57
- proposed_change, result = await super().mutate_create(info=info, data=data, branch=branch, database=dbt)
85
+ proposed_change, result = await super().mutate_create(
86
+ info=info, data=data, branch=branch, database=dbt, override_data=override_data
87
+ )
58
88
  destination_branch = proposed_change.destination_branch.value
59
89
  source_branch = await _get_source_branch(db=dbt, name=proposed_change.source_branch.value)
60
90
  if destination_branch == source_branch.name:
@@ -100,13 +130,16 @@ class InfrahubProposedChangeMutation(InfrahubMutationMixin, Mutation):
100
130
  include_source=True,
101
131
  )
102
132
  state = ProposedChangeState(obj.state.value.value)
103
- state.validate_editability()
133
+ state.validate_updatable()
104
134
 
105
135
  updated_state = None
106
136
  if state_update := data.get("state", {}).get("value"):
107
137
  updated_state = ProposedChangeState(state_update)
108
138
  state.validate_state_transition(updated_state)
109
139
 
140
+ # Check if the draft state will change (defaults to current draft state)
141
+ will_be_draft = data.get("is_draft", {}).get("value", obj.is_draft.value)
142
+
110
143
  # Check before starting a transaction, stopping in the middle of the transaction seems to break with memgraph
111
144
  if updated_state == ProposedChangeState.MERGED and graphql_context.account_session:
112
145
  try:
@@ -120,6 +153,8 @@ class InfrahubProposedChangeMutation(InfrahubMutationMixin, Mutation):
120
153
  raise ValidationError(str(exc)) from exc
121
154
 
122
155
  if updated_state == ProposedChangeState.MERGED:
156
+ if will_be_draft:
157
+ raise ValidationError("A draft proposed change is not allowed to be merged")
123
158
  data["state"]["value"] = ProposedChangeState.MERGING.value
124
159
 
125
160
  proposed_change, result = await super().mutate_update(
@@ -192,6 +227,165 @@ class ProposedChangeRequestRunCheck(Mutation):
192
227
  return {"ok": True}
193
228
 
194
229
 
230
+ class ProposedChangeReviewInput(InputObjectType):
231
+ id = String(required=True, description="The ID of the proposed change to review.")
232
+ decision = ProposedChangeApprovalDecisionInput(
233
+ required=True, description="The decision for the proposed change review."
234
+ )
235
+
236
+
237
+ class ProposedChangeReview(Mutation):
238
+ class Arguments:
239
+ data = ProposedChangeReviewInput(required=True)
240
+
241
+ ok = Boolean()
242
+
243
+ @classmethod
244
+ async def mutate(
245
+ cls,
246
+ root: dict, # noqa: ARG003
247
+ info: GraphQLResolveInfo,
248
+ data: ProposedChangeReviewInput,
249
+ ) -> dict[str, bool]:
250
+ """
251
+ This mutation is used to approve or reject a proposed change.
252
+ It can also be used to undo an approval or rejection.
253
+ """
254
+
255
+ graphql_context: GraphqlContext = info.context
256
+ graphql_context.active_permissions.raise_for_permission(
257
+ permission=GlobalPermission(
258
+ action=GlobalPermissions.REVIEW_PROPOSED_CHANGE.value, decision=PermissionDecision.ALLOW_ALL.value
259
+ )
260
+ )
261
+ pc_id = str(data.id)
262
+ lock_name = build_object_lock_name(pc_id)
263
+ async with InfrahubLock(name=lock_name, connection=lock.registry.connection):
264
+ proposed_change = await NodeManager.get_one_by_id_or_default_filter(
265
+ id=pc_id, kind=CoreProposedChange, db=graphql_context.db, prefetch_relationships=True
266
+ )
267
+ state = ProposedChangeState(proposed_change.state.value.value)
268
+ state.validate_reviewable()
269
+
270
+ created_by = await proposed_change.created_by.get_peer(db=graphql_context.db)
271
+ if created_by and created_by.id == graphql_context.active_account_session.account_id:
272
+ raise ValidationError(input_value="You cannot review your own proposed changes")
273
+
274
+ current_user = await NodeManager.get_one_by_id_or_default_filter(
275
+ id=graphql_context.active_account_session.account_id,
276
+ kind=InfrahubKind.GENERICACCOUNT,
277
+ db=graphql_context.db,
278
+ )
279
+
280
+ async with graphql_context.db.start_session() as db:
281
+ event = await cls._handle_decision(
282
+ db=db,
283
+ decision=data.decision,
284
+ proposed_change=proposed_change,
285
+ current_user=current_user,
286
+ context=graphql_context,
287
+ )
288
+ await proposed_change.save(db=db)
289
+
290
+ if event:
291
+ event_service = await get_event_service()
292
+ await event_service.send(event=event)
293
+
294
+ return {"ok": True}
295
+
296
+ @classmethod
297
+ async def _handle_decision(
298
+ cls,
299
+ db: InfrahubDatabase,
300
+ decision: ProposedChangeApprovalDecision,
301
+ proposed_change: CoreProposedChange,
302
+ current_user: Node,
303
+ context: GraphqlContext,
304
+ ) -> InfrahubEvent | None:
305
+ """Modify approved_by and rejected_by relationships of the prpoposed change based on the decision."""
306
+
307
+ approved_by = await proposed_change.approved_by.get_peers(db=db)
308
+ rejected_by = await proposed_change.rejected_by.get_peers(db=db)
309
+ approved_by_ids = [node.id for _, node in approved_by.items()]
310
+ rejected_by_ids = [node.id for _, node in rejected_by.items()]
311
+ event: InfrahubEvent | None = None
312
+ event_meta = EventMeta.from_context(context=context.get_context())
313
+
314
+ match decision:
315
+ case ProposedChangeApprovalDecision.APPROVE:
316
+ if current_user.id in approved_by_ids:
317
+ raise ValidationError(input_value="You have already approved this proposed change")
318
+ await proposed_change.approved_by.add(db=db, data=current_user)
319
+ if current_user.id in rejected_by_ids:
320
+ await proposed_change.rejected_by.remove_locally(db=db, peer_id=current_user.id)
321
+
322
+ event = ProposedChangeApprovedEvent(
323
+ proposed_change_id=proposed_change.id,
324
+ proposed_change_name=proposed_change.name.value,
325
+ proposed_change_state=proposed_change.state.value,
326
+ reviewer_account_id=current_user.id,
327
+ reviewer_account_name=current_user.name.value,
328
+ reviewer_decision=decision.value,
329
+ meta=event_meta,
330
+ )
331
+
332
+ case ProposedChangeApprovalDecision.CANCEL_APPROVE:
333
+ if current_user.id not in approved_by_ids:
334
+ raise ValidationError(
335
+ input_value="You did not approve this proposed change yet, it can't be un-approved"
336
+ )
337
+ await proposed_change.approved_by.remove_locally(db=db, peer_id=current_user.id)
338
+
339
+ event = ProposedChangeApprovalRevokedEvent(
340
+ proposed_change_id=proposed_change.id,
341
+ proposed_change_name=proposed_change.name.value,
342
+ proposed_change_state=proposed_change.state.value,
343
+ reviewer_account_id=current_user.id,
344
+ reviewer_account_name=current_user.name.value,
345
+ reviewer_former_decision=ProposedChangeApprovalDecision.APPROVE.value,
346
+ meta=event_meta,
347
+ )
348
+
349
+ case ProposedChangeApprovalDecision.REJECT:
350
+ if current_user.id in rejected_by_ids:
351
+ raise ValidationError(input_value="You have already rejected this proposed change")
352
+ await proposed_change.rejected_by.add(db=db, data=current_user)
353
+ if current_user.id in approved_by_ids:
354
+ await proposed_change.approved_by.remove_locally(db=db, peer_id=current_user.id)
355
+
356
+ event = ProposedChangeRejectedEvent(
357
+ proposed_change_id=proposed_change.id,
358
+ proposed_change_name=proposed_change.name.value,
359
+ proposed_change_state=proposed_change.state.value,
360
+ reviewer_account_id=current_user.id,
361
+ reviewer_account_name=current_user.name.value,
362
+ reviewer_decision=decision.value,
363
+ meta=event_meta,
364
+ )
365
+
366
+ case ProposedChangeApprovalDecision.CANCEL_REJECT:
367
+ if current_user.id not in rejected_by_ids:
368
+ raise ValidationError(
369
+ input_value="You did not reject this proposed change yet, it can't be un-rejected"
370
+ )
371
+ await proposed_change.rejected_by.remove_locally(db=db, peer_id=current_user.id)
372
+
373
+ event = ProposedChangeRejectionRevokedEvent(
374
+ proposed_change_id=proposed_change.id,
375
+ proposed_change_name=proposed_change.name.value,
376
+ proposed_change_state=proposed_change.state.value,
377
+ reviewer_account_id=current_user.id,
378
+ reviewer_account_name=current_user.name.value,
379
+ reviewer_former_decision=ProposedChangeApprovalDecision.REJECT.value,
380
+ meta=event_meta,
381
+ )
382
+
383
+ case _:
384
+ raise ValidationError(input_value=f"Invalid decision {decision}")
385
+
386
+ return event
387
+
388
+
195
389
  class ProposedChangeMergeInput(InputObjectType):
196
390
  id = String(required=True)
197
391
 
@@ -250,6 +444,34 @@ class ProposedChangeMerge(Mutation):
250
444
  return cls(ok=True, task=task)
251
445
 
252
446
 
447
+ class ProposedChangeCheckForApprovalRevokeInput(InputObjectType):
448
+ ids = Field(List(of_type=String, required=True), required=False)
449
+
450
+
451
+ class ProposedChangeCheckForApprovalRevoke(Mutation):
452
+ class Arguments:
453
+ data = ProposedChangeCheckForApprovalRevokeInput(required=True)
454
+
455
+ ok = Boolean()
456
+
457
+ @classmethod
458
+ async def mutate(
459
+ cls,
460
+ root: dict, # noqa: ARG003
461
+ info: GraphQLResolveInfo,
462
+ data: dict[str, Any],
463
+ ) -> dict[str, bool]:
464
+ db = info.context.db
465
+ ids: list[str] | None
466
+ try:
467
+ ids = data["ids"]
468
+ except KeyError:
469
+ ids = None
470
+
471
+ await do_revoke_approvals_on_updated_pcs(db=db, proposed_changes_ids=ids)
472
+ return cls(ok=True)
473
+
474
+
253
475
  async def _get_source_branch(db: InfrahubDatabase, name: str) -> Branch:
254
476
  try:
255
477
  return await Branch.get_by_name(name=name, db=db)
@@ -332,6 +332,11 @@ async def _validate_node(info: GraphQLResolveInfo, data: RelationshipNodesInput)
332
332
  if rel_schema.cardinality != RelationshipCardinality.MANY:
333
333
  raise ValidationError({"name": f"'{relationship_name}' must be a relationship of cardinality Many"})
334
334
 
335
+ if rel_schema.read_only:
336
+ # These mutations should never be allowed to update read-only relationships, as those typically
337
+ # have custom code tied to them such as the approved_by relationship of a CoreProposedChange.
338
+ raise ValidationError({source.get_kind(): f"'{relationship_name}' is a read-only relationship"})
339
+
335
340
  return source
336
341
 
337
342
 
@@ -64,12 +64,13 @@ class InfrahubRepositoryMutation(InfrahubMutationMixin, Mutation):
64
64
  data: InputObjectType,
65
65
  branch: Branch,
66
66
  database: InfrahubDatabase | None = None, # noqa: ARG003
67
+ override_data: dict[str, Any] | None = None,
67
68
  ) -> tuple[Node, Self]:
68
69
  graphql_context: GraphqlContext = info.context
69
70
 
70
71
  cleanup_payload(data)
71
72
  # Create the object in the database
72
- obj, result = await super().mutate_create(info, data, branch)
73
+ obj, result = await super().mutate_create(info, data, branch, override_data=override_data)
73
74
  obj = cast(CoreGenericRepository, obj)
74
75
 
75
76
  # First check the connectivity to the remote repository
@@ -15,16 +15,17 @@ from infrahub.core.validators.models.validate_migration import SchemaValidateMig
15
15
  from infrahub.core.validators.tasks import schema_validate_migrations
16
16
  from infrahub.dependencies.registry import get_component_registry
17
17
  from infrahub.exceptions import ValidationError
18
- from infrahub.services import InfrahubServices # noqa: TC001 needed for prefect flow
18
+ from infrahub.workers.dependencies import get_database, get_workflow
19
19
  from infrahub.workflows.catalogue import BRANCH_MERGE
20
20
  from infrahub.workflows.utils import add_tags
21
21
 
22
22
 
23
23
  @flow(name="merge-branch-mutation", flow_run_name="Merge branch graphQL mutation")
24
- async def merge_branch_mutation(branch: str, context: InfrahubContext, service: InfrahubServices) -> None:
24
+ async def merge_branch_mutation(branch: str, context: InfrahubContext) -> None:
25
25
  await add_tags(branches=[branch])
26
26
 
27
- async with service.database.start_session() as db:
27
+ database = await get_database()
28
+ async with database.start_session() as db:
28
29
  obj = await Branch.get_by_name(db=db, name=branch)
29
30
  base_branch = await Branch.get_by_name(db=db, name=registry.default_branch)
30
31
 
@@ -52,7 +53,7 @@ async def merge_branch_mutation(branch: str, context: InfrahubContext, service:
52
53
  diff_repository=diff_repository,
53
54
  source_branch=obj,
54
55
  diff_locker=DiffLocker(),
55
- service=service,
56
+ workflow=get_workflow(),
56
57
  )
57
58
  candidate_schema = merger.get_candidate_schema()
58
59
  determiner = ConstraintValidatorDeterminer(schema_branch=candidate_schema)
@@ -62,13 +63,10 @@ async def merge_branch_mutation(branch: str, context: InfrahubContext, service:
62
63
 
63
64
  if constraints:
64
65
  responses = await schema_validate_migrations(
65
- message=SchemaValidateMigrationData(
66
- branch=obj, schema_branch=candidate_schema, constraints=constraints
67
- ),
68
- service=service,
66
+ message=SchemaValidateMigrationData(branch=obj, schema_branch=candidate_schema, constraints=constraints)
69
67
  )
70
68
  error_messages = [violation.message for response in responses for violation in response.violations]
71
69
  if error_messages:
72
70
  raise ValidationError(",\n".join(error_messages))
73
71
 
74
- await service.workflow.execute_workflow(workflow=BRANCH_MERGE, context=context, parameters={"branch": obj.name})
72
+ await get_workflow().execute_workflow(workflow=BRANCH_MERGE, context=context, parameters={"branch": obj.name})
@@ -67,6 +67,7 @@ class InfrahubWebhookMutation(InfrahubMutationMixin, Mutation):
67
67
  data: InputObjectType,
68
68
  branch: Branch,
69
69
  database: InfrahubDatabase | None = None,
70
+ override_data: dict[str, Any] | None = None,
70
71
  ) -> tuple[Node, Self]:
71
72
  graphql_context: GraphqlContext = info.context
72
73
 
@@ -74,7 +75,9 @@ class InfrahubWebhookMutation(InfrahubMutationMixin, Mutation):
74
75
 
75
76
  _validate_input(graphql_context=graphql_context, branch=branch, input_data=input_data)
76
77
 
77
- obj, result = await super().mutate_create(info=info, data=data, branch=branch, database=database)
78
+ obj, result = await super().mutate_create(
79
+ info=info, data=data, branch=branch, database=database, override_data=override_data
80
+ )
78
81
 
79
82
  return obj, result
80
83
 
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING
6
6
  from graphql.language import (
7
7
  DirectiveNode,
8
8
  FieldNode,
9
+ FragmentSpreadNode,
9
10
  InlineFragmentNode,
10
11
  ListValueNode,
11
12
  NameNode,
@@ -15,7 +16,9 @@ from graphql.language import (
15
16
  from infrahub_sdk.utils import deep_merge_dict
16
17
 
17
18
  if TYPE_CHECKING:
18
- from infrahub.core.schema import NodeSchema
19
+ from graphql import GraphQLResolveInfo
20
+
21
+ from infrahub.core.schema import GenericSchema, NodeSchema
19
22
 
20
23
 
21
24
  @dataclass
@@ -26,14 +29,14 @@ class FieldEnricher:
26
29
  fields: dict = field(default_factory=dict)
27
30
 
28
31
 
29
- async def extract_selection(field_node: FieldNode, schema: NodeSchema) -> dict:
30
- graphql_extractor = GraphQLExtractor(field_node=field_node, schema=schema)
32
+ async def extract_selection(info: GraphQLResolveInfo, schema: NodeSchema | GenericSchema) -> dict:
33
+ graphql_extractor = GraphQLExtractor(info=info, schema=schema)
31
34
  return await graphql_extractor.get_fields()
32
35
 
33
36
 
34
37
  class GraphQLExtractor:
35
- def __init__(self, field_node: FieldNode, schema: NodeSchema) -> None:
36
- self.field_node = field_node
38
+ def __init__(self, info: GraphQLResolveInfo, schema: NodeSchema | GenericSchema) -> None:
39
+ self.info = info
37
40
  self.schema = schema
38
41
  self.typename_paths: dict[str, list[FieldEnricher]] = {}
39
42
  self.node_path: dict[str, list[FieldEnricher]] = {}
@@ -43,7 +46,7 @@ class GraphQLExtractor:
43
46
  self.node_path[path] = []
44
47
 
45
48
  async def get_fields(self) -> dict:
46
- return await self.extract_fields(selection_set=self.field_node.selection_set) or {}
49
+ return await self.extract_fields(selection_set=self.info.field_nodes[0].selection_set) or {}
47
50
 
48
51
  def _process_expand_directive(self, path: str, directive: DirectiveNode) -> None:
49
52
  excluded_fields = []
@@ -204,6 +207,12 @@ class GraphQLExtractor:
204
207
  elif isinstance(fields[sub_node.name.value], dict) and isinstance(value, dict):
205
208
  fields[sub_node.name.value].update(value) # type: ignore[union-attr]
206
209
 
210
+ elif isinstance(node, FragmentSpreadNode):
211
+ if node.name.value in self.info.fragments:
212
+ fragment_fields = await self.extract_fields(self.info.fragments[node.name.value].selection_set)
213
+ if fragment_fields:
214
+ fields.update(fragment_fields)
215
+
207
216
  return self.apply_directives(selection_set=selection_set, fields=fields, path=path)
208
217
 
209
218
 
@@ -1,7 +1,13 @@
1
1
  from .account import AccountPermissions, AccountToken
2
2
  from .branch import BranchQueryList
3
3
  from .internal import InfrahubInfo
4
- from .ipam import InfrahubIPAddressGetNextAvailable, InfrahubIPPrefixGetNextAvailable
4
+ from .ipam import (
5
+ DeprecatedIPAddressGetNextAvailable,
6
+ DeprecatedIPPrefixGetNextAvailable,
7
+ InfrahubIPAddressGetNextAvailable,
8
+ InfrahubIPPrefixGetNextAvailable,
9
+ )
10
+ from .proposed_change import ProposedChangeAvailableActions
5
11
  from .relationship import Relationship
6
12
  from .resource_manager import InfrahubResourcePoolAllocated, InfrahubResourcePoolUtilization
7
13
  from .search import InfrahubSearchAnywhere
@@ -12,6 +18,8 @@ __all__ = [
12
18
  "AccountPermissions",
13
19
  "AccountToken",
14
20
  "BranchQueryList",
21
+ "DeprecatedIPAddressGetNextAvailable",
22
+ "DeprecatedIPPrefixGetNextAvailable",
15
23
  "InfrahubIPAddressGetNextAvailable",
16
24
  "InfrahubIPPrefixGetNextAvailable",
17
25
  "InfrahubInfo",
@@ -19,6 +27,7 @@ __all__ = [
19
27
  "InfrahubResourcePoolUtilization",
20
28
  "InfrahubSearchAnywhere",
21
29
  "InfrahubStatus",
30
+ "ProposedChangeAvailableActions",
22
31
  "Relationship",
23
32
  "Task",
24
33
  ]
@@ -3,11 +3,11 @@ from __future__ import annotations
3
3
  from typing import TYPE_CHECKING, Any
4
4
 
5
5
  from graphene import Field, Int, List, NonNull, ObjectType, String
6
- from infrahub_sdk.utils import extract_fields_first_node
7
6
 
8
7
  from infrahub.core.manager import NodeManager
9
8
  from infrahub.core.protocols import InternalAccountToken
10
9
  from infrahub.exceptions import PermissionDeniedError
10
+ from infrahub.graphql.field_extractor import extract_graphql_fields
11
11
 
12
12
  if TYPE_CHECKING:
13
13
  from graphql import GraphQLResolveInfo
@@ -44,7 +44,7 @@ async def resolve_account_tokens(
44
44
  if not graphql_context.account_session.authenticated_by_jwt:
45
45
  raise PermissionDeniedError("This operation requires authentication with a JWT token")
46
46
 
47
- fields = await extract_fields_first_node(info)
47
+ fields = extract_graphql_fields(info)
48
48
 
49
49
  filters = {"account__ids": [graphql_context.account_session.account_id]}
50
50
  response: dict[str, Any] = {}
@@ -121,7 +121,7 @@ async def resolve_account_permissions(
121
121
  if not graphql_context.account_session:
122
122
  raise ValueError("An account_session is mandatory to execute this query")
123
123
 
124
- fields = await extract_fields_first_node(info)
124
+ fields = extract_graphql_fields(info)
125
125
 
126
126
  response: dict[str, dict[str, Any]] = {}
127
127
  if "global_permissions" in fields:
@@ -3,8 +3,8 @@ from __future__ import annotations
3
3
  from typing import TYPE_CHECKING, Any
4
4
 
5
5
  from graphene import ID, Field, List, NonNull, String
6
- from infrahub_sdk.utils import extract_fields_first_node
7
6
 
7
+ from infrahub.graphql.field_extractor import extract_graphql_fields
8
8
  from infrahub.graphql.types import BranchType
9
9
 
10
10
  if TYPE_CHECKING:
@@ -16,7 +16,7 @@ async def branch_resolver(
16
16
  info: GraphQLResolveInfo,
17
17
  **kwargs: Any,
18
18
  ) -> list[dict[str, Any]]:
19
- fields = await extract_fields_first_node(info)
19
+ fields = extract_graphql_fields(info)
20
20
  return await BranchType.get_list(graphql_context=info.context, fields=fields, **kwargs)
21
21
 
22
22
 
@@ -1,13 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from dataclasses import dataclass
3
4
  from typing import TYPE_CHECKING, Any
4
5
 
5
6
  from graphene import Argument, Boolean, DateTime, Field, InputObjectType, Int, List, NonNull, ObjectType, String
6
7
  from graphene import Enum as GrapheneEnum
7
- from infrahub_sdk.utils import extract_fields
8
8
 
9
9
  from infrahub.core import registry
10
- from infrahub.core.constants import DiffAction, RelationshipCardinality
10
+ from infrahub.core.constants import DiffAction, RelationshipCardinality, RelationshipDirection
11
11
  from infrahub.core.constants.database import DatabaseEdgeType
12
12
  from infrahub.core.diff.model.path import NameTrackingId
13
13
  from infrahub.core.diff.query.filters import EnrichedDiffQueryFilters
@@ -16,6 +16,7 @@ from infrahub.core.query.diff import DiffCountChanges
16
16
  from infrahub.core.timestamp import Timestamp
17
17
  from infrahub.dependencies.registry import get_component_registry
18
18
  from infrahub.graphql.enums import ConflictSelection as GraphQLConflictSelection
19
+ from infrahub.graphql.field_extractor import extract_graphql_fields
19
20
 
20
21
  if TYPE_CHECKING:
21
22
  from datetime import datetime
@@ -38,6 +39,12 @@ GrapheneDiffActionEnum = GrapheneEnum.from_enum(DiffAction)
38
39
  GrapheneCardinalityEnum = GrapheneEnum.from_enum(RelationshipCardinality)
39
40
 
40
41
 
42
+ @dataclass
43
+ class ParentNodeInfo:
44
+ node: EnrichedDiffNode
45
+ relationship_name: str
46
+
47
+
41
48
  class ConflictDetails(ObjectType):
42
49
  uuid = String(required=True)
43
50
  base_branch_action = Field(GrapheneDiffActionEnum, required=True)
@@ -145,9 +152,16 @@ class DiffTreeSummary(DiffSummaryCounts):
145
152
 
146
153
 
147
154
  class DiffTreeResolver:
155
+ def __init__(self) -> None:
156
+ self.source_branch_name: str | None = None
157
+
158
+ def initialize(self, enriched_diff_root: EnrichedDiffRoot) -> None:
159
+ self.source_branch_name = enriched_diff_root.diff_branch_name
160
+
148
161
  async def to_diff_tree(
149
162
  self, enriched_diff_root: EnrichedDiffRoot, graphql_context: GraphqlContext | None = None
150
163
  ) -> DiffTree:
164
+ self.initialize(enriched_diff_root=enriched_diff_root)
151
165
  all_nodes = list(enriched_diff_root.nodes)
152
166
  tree_nodes = [self.to_diff_node(enriched_node=e_node, graphql_context=graphql_context) for e_node in all_nodes]
153
167
  name = None
@@ -166,6 +180,43 @@ class DiffTreeResolver:
166
180
  num_conflicts=enriched_diff_root.num_conflicts,
167
181
  )
168
182
 
183
+ def _get_parent_info(
184
+ self, diff_node: EnrichedDiffNode, graphql_context: GraphqlContext | None = None
185
+ ) -> ParentNodeInfo | None:
186
+ for r in diff_node.relationships:
187
+ for n in r.nodes:
188
+ relationship_name: str = "undefined"
189
+
190
+ if not graphql_context or not self.source_branch_name:
191
+ return ParentNodeInfo(node=n, relationship_name=relationship_name)
192
+
193
+ node_schema = graphql_context.db.schema.get(
194
+ name=diff_node.kind, branch=self.source_branch_name, duplicate=False
195
+ )
196
+ rel_schema = node_schema.get_relationship(name=r.name)
197
+
198
+ parent_schema = graphql_context.db.schema.get(
199
+ name=n.kind, branch=self.source_branch_name, duplicate=False
200
+ )
201
+ rels_parent = parent_schema.get_relationships_by_identifier(id=rel_schema.get_identifier())
202
+
203
+ if rels_parent and len(rels_parent) == 1:
204
+ relationship_name = rels_parent[0].name
205
+ elif rels_parent and len(rels_parent) > 1:
206
+ for rel_parent in rels_parent:
207
+ if (
208
+ rel_schema.direction == RelationshipDirection.INBOUND
209
+ and rel_parent.direction == RelationshipDirection.OUTBOUND
210
+ ) or (
211
+ rel_schema.direction == RelationshipDirection.OUTBOUND
212
+ and rel_parent.direction == RelationshipDirection.INBOUND
213
+ ):
214
+ relationship_name = rel_parent.name
215
+ break
216
+
217
+ return ParentNodeInfo(node=n, relationship_name=relationship_name)
218
+ return None
219
+
169
220
  def to_diff_node(self, enriched_node: EnrichedDiffNode, graphql_context: GraphqlContext | None = None) -> DiffNode:
170
221
  diff_attributes = [
171
222
  self.to_diff_attribute(enriched_attribute=e_attr, graphql_context=graphql_context)
@@ -181,7 +232,7 @@ class DiffTreeResolver:
181
232
  conflict = self.to_diff_conflict(enriched_conflict=enriched_node.conflict, graphql_context=graphql_context)
182
233
 
183
234
  parent = None
184
- if parent_info := enriched_node.get_parent_info(graphql_context=graphql_context):
235
+ if parent_info := self._get_parent_info(diff_node=enriched_node, graphql_context=graphql_context):
185
236
  parent = DiffNodeParent(
186
237
  uuid=parent_info.node.uuid,
187
238
  kind=parent_info.node.kind,
@@ -438,7 +489,7 @@ class DiffTreeResolver:
438
489
  else:
439
490
  enriched_diff = enriched_diffs[0]
440
491
 
441
- full_fields = await extract_fields(info.field_nodes[0].selection_set)
492
+ full_fields = extract_graphql_fields(info=info)
442
493
  diff_tree = await self.to_diff_tree(enriched_diff_root=enriched_diff, graphql_context=graphql_context)
443
494
  need_base_changes = "num_untracked_base_changes" in full_fields
444
495
  need_branch_changes = "num_untracked_diff_changes" in full_fields
@@ -495,7 +546,7 @@ class DiffTreeResolver:
495
546
  to_time=summary.to_time.to_datetime(),
496
547
  **summary.model_dump(exclude={"from_time", "to_time"}),
497
548
  )
498
- full_fields = await extract_fields(info.field_nodes[0].selection_set)
549
+ full_fields = extract_graphql_fields(info=info)
499
550
  need_base_changes = "num_untracked_base_changes" in full_fields
500
551
  need_branch_changes = "num_untracked_diff_changes" in full_fields
501
552
  if need_base_changes or need_branch_changes: