infrahub-server 1.4.10__py3-none-any.whl → 1.5.0b1__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 (178) hide show
  1. infrahub/actions/tasks.py +208 -16
  2. infrahub/api/artifact.py +3 -0
  3. infrahub/api/diff/diff.py +1 -1
  4. infrahub/api/query.py +2 -0
  5. infrahub/api/schema.py +3 -0
  6. infrahub/auth.py +5 -5
  7. infrahub/cli/db.py +26 -2
  8. infrahub/cli/db_commands/clean_duplicate_schema_fields.py +212 -0
  9. infrahub/config.py +7 -2
  10. infrahub/core/attribute.py +25 -22
  11. infrahub/core/branch/models.py +2 -2
  12. infrahub/core/branch/needs_rebase_status.py +11 -0
  13. infrahub/core/branch/tasks.py +4 -3
  14. infrahub/core/changelog/models.py +4 -12
  15. infrahub/core/constants/__init__.py +1 -0
  16. infrahub/core/constants/infrahubkind.py +1 -0
  17. infrahub/core/convert_object_type/object_conversion.py +201 -0
  18. infrahub/core/convert_object_type/repository_conversion.py +89 -0
  19. infrahub/core/convert_object_type/schema_mapping.py +27 -3
  20. infrahub/core/diff/model/path.py +4 -0
  21. infrahub/core/diff/payload_builder.py +1 -1
  22. infrahub/core/diff/query/artifact.py +1 -1
  23. infrahub/core/graph/__init__.py +1 -1
  24. infrahub/core/initialization.py +2 -2
  25. infrahub/core/ipam/utilization.py +1 -1
  26. infrahub/core/manager.py +9 -84
  27. infrahub/core/migrations/graph/__init__.py +6 -0
  28. infrahub/core/migrations/graph/m040_profile_attrs_in_db.py +166 -0
  29. infrahub/core/migrations/graph/m041_create_hfid_display_label_in_db.py +97 -0
  30. infrahub/core/migrations/graph/m042_backfill_hfid_display_label_in_db.py +86 -0
  31. infrahub/core/migrations/schema/node_attribute_add.py +5 -2
  32. infrahub/core/migrations/shared.py +5 -6
  33. infrahub/core/node/__init__.py +165 -42
  34. infrahub/core/node/constraints/attribute_uniqueness.py +3 -1
  35. infrahub/core/node/create.py +67 -35
  36. infrahub/core/node/lock_utils.py +98 -0
  37. infrahub/core/node/node_property_attribute.py +230 -0
  38. infrahub/core/node/standard.py +1 -1
  39. infrahub/core/property.py +11 -0
  40. infrahub/core/protocols.py +8 -1
  41. infrahub/core/query/attribute.py +27 -15
  42. infrahub/core/query/node.py +61 -185
  43. infrahub/core/query/relationship.py +43 -26
  44. infrahub/core/query/subquery.py +0 -8
  45. infrahub/core/registry.py +2 -2
  46. infrahub/core/relationship/constraints/count.py +1 -1
  47. infrahub/core/relationship/model.py +60 -20
  48. infrahub/core/schema/attribute_schema.py +0 -2
  49. infrahub/core/schema/basenode_schema.py +42 -2
  50. infrahub/core/schema/definitions/core/__init__.py +2 -0
  51. infrahub/core/schema/definitions/core/generator.py +2 -0
  52. infrahub/core/schema/definitions/core/group.py +16 -2
  53. infrahub/core/schema/definitions/core/repository.py +7 -0
  54. infrahub/core/schema/definitions/internal.py +14 -1
  55. infrahub/core/schema/generated/base_node_schema.py +6 -1
  56. infrahub/core/schema/node_schema.py +5 -2
  57. infrahub/core/schema/relationship_schema.py +0 -1
  58. infrahub/core/schema/schema_branch.py +137 -2
  59. infrahub/core/schema/schema_branch_display.py +123 -0
  60. infrahub/core/schema/schema_branch_hfid.py +114 -0
  61. infrahub/core/validators/aggregated_checker.py +1 -1
  62. infrahub/core/validators/determiner.py +12 -1
  63. infrahub/core/validators/relationship/peer.py +1 -1
  64. infrahub/core/validators/tasks.py +1 -1
  65. infrahub/display_labels/__init__.py +0 -0
  66. infrahub/display_labels/gather.py +48 -0
  67. infrahub/display_labels/models.py +240 -0
  68. infrahub/display_labels/tasks.py +186 -0
  69. infrahub/display_labels/triggers.py +22 -0
  70. infrahub/events/group_action.py +1 -1
  71. infrahub/events/node_action.py +1 -1
  72. infrahub/generators/constants.py +7 -0
  73. infrahub/generators/models.py +38 -12
  74. infrahub/generators/tasks.py +34 -16
  75. infrahub/git/base.py +38 -1
  76. infrahub/git/integrator.py +22 -14
  77. infrahub/graphql/analyzer.py +1 -1
  78. infrahub/graphql/api/dependencies.py +2 -4
  79. infrahub/graphql/api/endpoints.py +2 -2
  80. infrahub/graphql/app.py +2 -4
  81. infrahub/graphql/initialization.py +2 -3
  82. infrahub/graphql/manager.py +212 -137
  83. infrahub/graphql/middleware.py +12 -0
  84. infrahub/graphql/mutations/branch.py +11 -0
  85. infrahub/graphql/mutations/computed_attribute.py +110 -3
  86. infrahub/graphql/mutations/convert_object_type.py +34 -13
  87. infrahub/graphql/mutations/display_label.py +111 -0
  88. infrahub/graphql/mutations/generator.py +25 -7
  89. infrahub/graphql/mutations/hfid.py +118 -0
  90. infrahub/graphql/mutations/ipam.py +21 -8
  91. infrahub/graphql/mutations/main.py +37 -153
  92. infrahub/graphql/mutations/profile.py +195 -0
  93. infrahub/graphql/mutations/proposed_change.py +2 -1
  94. infrahub/graphql/mutations/relationship.py +2 -2
  95. infrahub/graphql/mutations/repository.py +22 -83
  96. infrahub/graphql/mutations/resource_manager.py +2 -2
  97. infrahub/graphql/mutations/schema.py +5 -5
  98. infrahub/graphql/mutations/webhook.py +1 -1
  99. infrahub/graphql/queries/resource_manager.py +1 -1
  100. infrahub/graphql/registry.py +173 -0
  101. infrahub/graphql/resolvers/resolver.py +2 -0
  102. infrahub/graphql/schema.py +8 -1
  103. infrahub/groups/tasks.py +1 -1
  104. infrahub/hfid/__init__.py +0 -0
  105. infrahub/hfid/gather.py +48 -0
  106. infrahub/hfid/models.py +240 -0
  107. infrahub/hfid/tasks.py +185 -0
  108. infrahub/hfid/triggers.py +22 -0
  109. infrahub/lock.py +67 -30
  110. infrahub/locks/__init__.py +0 -0
  111. infrahub/locks/tasks.py +37 -0
  112. infrahub/middleware.py +26 -1
  113. infrahub/patch/plan_writer.py +2 -2
  114. infrahub/profiles/__init__.py +0 -0
  115. infrahub/profiles/node_applier.py +101 -0
  116. infrahub/profiles/queries/__init__.py +0 -0
  117. infrahub/profiles/queries/get_profile_data.py +99 -0
  118. infrahub/profiles/tasks.py +63 -0
  119. infrahub/proposed_change/tasks.py +10 -1
  120. infrahub/repositories/__init__.py +0 -0
  121. infrahub/repositories/create_repository.py +113 -0
  122. infrahub/server.py +16 -3
  123. infrahub/services/__init__.py +8 -5
  124. infrahub/tasks/registry.py +6 -4
  125. infrahub/trigger/catalogue.py +4 -0
  126. infrahub/trigger/models.py +2 -0
  127. infrahub/trigger/tasks.py +3 -0
  128. infrahub/webhook/models.py +1 -1
  129. infrahub/workflows/catalogue.py +110 -3
  130. infrahub/workflows/initialization.py +16 -0
  131. infrahub/workflows/models.py +17 -2
  132. infrahub_sdk/branch.py +5 -8
  133. infrahub_sdk/checks.py +1 -1
  134. infrahub_sdk/client.py +364 -84
  135. infrahub_sdk/convert_object_type.py +61 -0
  136. infrahub_sdk/ctl/check.py +2 -3
  137. infrahub_sdk/ctl/cli_commands.py +18 -12
  138. infrahub_sdk/ctl/config.py +8 -2
  139. infrahub_sdk/ctl/generator.py +6 -3
  140. infrahub_sdk/ctl/graphql.py +184 -0
  141. infrahub_sdk/ctl/repository.py +39 -1
  142. infrahub_sdk/ctl/schema.py +18 -3
  143. infrahub_sdk/ctl/utils.py +4 -0
  144. infrahub_sdk/ctl/validate.py +5 -3
  145. infrahub_sdk/diff.py +4 -5
  146. infrahub_sdk/exceptions.py +2 -0
  147. infrahub_sdk/generator.py +7 -1
  148. infrahub_sdk/graphql/__init__.py +12 -0
  149. infrahub_sdk/graphql/constants.py +1 -0
  150. infrahub_sdk/graphql/plugin.py +85 -0
  151. infrahub_sdk/graphql/query.py +77 -0
  152. infrahub_sdk/{graphql.py → graphql/renderers.py} +88 -75
  153. infrahub_sdk/graphql/utils.py +40 -0
  154. infrahub_sdk/node/attribute.py +2 -0
  155. infrahub_sdk/node/node.py +28 -20
  156. infrahub_sdk/playback.py +1 -2
  157. infrahub_sdk/protocols.py +54 -6
  158. infrahub_sdk/pytest_plugin/plugin.py +7 -4
  159. infrahub_sdk/pytest_plugin/utils.py +40 -0
  160. infrahub_sdk/repository.py +1 -2
  161. infrahub_sdk/schema/__init__.py +38 -0
  162. infrahub_sdk/schema/main.py +1 -0
  163. infrahub_sdk/schema/repository.py +8 -0
  164. infrahub_sdk/spec/object.py +120 -7
  165. infrahub_sdk/spec/range_expansion.py +118 -0
  166. infrahub_sdk/timestamp.py +18 -6
  167. infrahub_sdk/transforms.py +1 -1
  168. {infrahub_server-1.4.10.dist-info → infrahub_server-1.5.0b1.dist-info}/METADATA +9 -11
  169. {infrahub_server-1.4.10.dist-info → infrahub_server-1.5.0b1.dist-info}/RECORD +177 -134
  170. infrahub_testcontainers/container.py +1 -1
  171. infrahub_testcontainers/docker-compose-cluster.test.yml +1 -1
  172. infrahub_testcontainers/docker-compose.test.yml +1 -1
  173. infrahub_testcontainers/models.py +2 -2
  174. infrahub_testcontainers/performance_test.py +4 -4
  175. infrahub/core/convert_object_type/conversion.py +0 -134
  176. {infrahub_server-1.4.10.dist-info → infrahub_server-1.5.0b1.dist-info}/LICENSE.txt +0 -0
  177. {infrahub_server-1.4.10.dist-info → infrahub_server-1.5.0b1.dist-info}/WHEEL +0 -0
  178. {infrahub_server-1.4.10.dist-info → infrahub_server-1.5.0b1.dist-info}/entry_points.txt +0 -0
@@ -381,14 +381,33 @@ class Relationship(FlagPropertyMixin, NodePropertyMixin):
381
381
 
382
382
  node = await self.get_node(db=db)
383
383
 
384
+ flag_properties_to_update = {}
385
+ for prop_name in self._flag_properties:
386
+ if prop_name not in properties_to_update:
387
+ continue
388
+ value = getattr(self, prop_name)
389
+ if value is not None:
390
+ flag_properties_to_update[prop_name] = value
391
+
392
+ node_properties_to_update = {}
393
+ for prop_name in self._node_properties:
394
+ if prop_name not in properties_to_update:
395
+ continue
396
+ if value := getattr(self, f"{prop_name}_id"):
397
+ node_properties_to_update[prop_name] = value
398
+
399
+ if not flag_properties_to_update and not node_properties_to_update:
400
+ return
401
+
384
402
  query = await RelationshipUpdatePropertyQuery.init(
385
403
  db=db,
404
+ branch=branch,
386
405
  source=node,
387
406
  rel=self,
388
- properties_to_update=properties_to_update,
389
- data=data,
390
- branch=branch,
391
407
  at=update_at,
408
+ flag_properties_to_update=flag_properties_to_update,
409
+ node_properties_to_update=node_properties_to_update,
410
+ rel_node_id=data.rel_node_id,
392
411
  )
393
412
  await query.execute(db=db)
394
413
 
@@ -440,7 +459,7 @@ class Relationship(FlagPropertyMixin, NodePropertyMixin):
440
459
  self.set_peer(value=peer)
441
460
 
442
461
  if not self.peer_id and self.peer_hfid:
443
- peer_schema = db.schema.get(name=self.schema.peer, branch=self.branch)
462
+ peer_schema = db.schema.get(name=self.schema.peer, branch=self.branch, duplicate=False)
444
463
  kind = (
445
464
  self.data["kind"]
446
465
  if isinstance(self.data, dict) and "kind" in self.data and peer_schema.is_generic_schema
@@ -570,7 +589,9 @@ class RelationshipValidatorList:
570
589
  ValidationError: If the number of relationships is not within the min and max count.
571
590
  """
572
591
 
573
- def __init__(self, *relationships: Relationship, name: str, min_count: int = 0, max_count: int = 0) -> None:
592
+ def __init__(
593
+ self, *relationships: Relationship, name: str, min_count: int | None = 0, max_count: int | None = 0
594
+ ) -> None:
574
595
  """Initialize list for Relationship but with validation against min/max count.
575
596
 
576
597
  Args:
@@ -580,8 +601,14 @@ class RelationshipValidatorList:
580
601
  Raises:
581
602
  ValidationError: The number of relationships is not within the min and max count.
582
603
  """
583
- if max_count < min_count:
604
+ if max_count is not None and min_count is not None and max_count < min_count:
584
605
  raise ValidationError({"msg": "max_count must be greater than min_count"})
606
+
607
+ if max_count is None:
608
+ max_count = 0
609
+ if min_count is None:
610
+ min_count = 0
611
+
585
612
  self.min_count: int = min_count
586
613
  self.max_count: int = max_count
587
614
  self.name = name
@@ -726,15 +753,22 @@ class RelationshipManager:
726
753
  # TODO Ideally this information should come from the Schema
727
754
  self.rel_class = Relationship
728
755
 
729
- self._relationships: RelationshipValidatorList = RelationshipValidatorList(
730
- name=self.schema.name,
731
- min_count=0 if self.schema.optional else self.schema.min_count,
732
- max_count=self.schema.max_count,
733
- )
756
+ self._relationships: RelationshipValidatorList = self._get_init_relationships()
734
757
  self._relationship_id_details: RelationshipUpdateDetails | None = None
735
758
  self.has_fetched_relationships: bool = False
736
759
  self.lock = asyncio.Lock()
737
760
 
761
+ def _get_init_relationships(self) -> RelationshipValidatorList:
762
+ min_count = self.schema.min_count
763
+ max_count: int | None = self.schema.max_count if self.schema.max_count > 0 else None
764
+ if self.schema.optional:
765
+ min_count = 0
766
+ return RelationshipValidatorList(
767
+ name=self.schema.name,
768
+ min_count=min_count,
769
+ max_count=max_count,
770
+ )
771
+
738
772
  @classmethod
739
773
  async def init(
740
774
  cls,
@@ -909,6 +943,19 @@ class RelationshipManager:
909
943
  return registry.get_global_branch()
910
944
  return self.branch
911
945
 
946
+ async def get_db_peers(
947
+ self, db: InfrahubDatabase, at: Timestamp | None = None, branch_agnostic: bool = False
948
+ ) -> list[RelationshipPeerData]:
949
+ query = await RelationshipGetPeerQuery.init(
950
+ db=db,
951
+ source=self.node,
952
+ at=at or self.at,
953
+ rel=self.rel_class(schema=self.schema, branch=self.branch, node=self.node),
954
+ branch_agnostic=branch_agnostic,
955
+ )
956
+ await query.execute(db=db)
957
+ return list(query.get_peers())
958
+
912
959
  async def fetch_relationship_ids(
913
960
  self,
914
961
  db: InfrahubDatabase,
@@ -926,16 +973,9 @@ class RelationshipManager:
926
973
 
927
974
  current_peer_ids = [rel.get_peer_id() for rel in self._relationships]
928
975
 
929
- query = await RelationshipGetPeerQuery.init(
930
- db=db,
931
- source=self.node,
932
- at=at or self.at,
933
- rel=self.rel_class(schema=self.schema, branch=self.branch, node=self.node),
934
- branch_agnostic=branch_agnostic,
935
- )
936
- await query.execute(db=db)
976
+ peers = await self.get_db_peers(db=db, at=at, branch_agnostic=branch_agnostic)
937
977
 
938
- peers_database: dict = {str(peer.peer_id): peer for peer in query.get_peers()}
978
+ peers_database: dict = {str(peer.peer_id): peer for peer in peers}
939
979
  peer_ids = list(peers_database.keys())
940
980
 
941
981
  # Calculate which peer should be added or removed
@@ -202,7 +202,6 @@ class AttributeSchema(GeneratedAttributeSchema):
202
202
  param_prefix: str | None = None,
203
203
  db: InfrahubDatabase | None = None,
204
204
  partial_match: bool = False,
205
- support_profiles: bool = False,
206
205
  ) -> tuple[list[QueryElement], dict[str, Any], list[str]]:
207
206
  if self.enum:
208
207
  filter_value = self.convert_enum_to_value(filter_value)
@@ -217,7 +216,6 @@ class AttributeSchema(GeneratedAttributeSchema):
217
216
  param_prefix=param_prefix,
218
217
  db=db,
219
218
  partial_match=partial_match,
220
- support_profiles=support_profiles,
221
219
  )
222
220
 
223
221
 
@@ -9,7 +9,7 @@ from enum import Enum
9
9
  from typing import TYPE_CHECKING, Any, Callable, Iterable, Literal, overload
10
10
 
11
11
  from infrahub_sdk.utils import compare_lists, intersection
12
- from pydantic import field_validator
12
+ from pydantic import ConfigDict, field_validator
13
13
 
14
14
  from infrahub.core.constants import HashableModelState, RelationshipCardinality, RelationshipKind
15
15
  from infrahub.core.models import HashableModel, HashableModelDiff
@@ -19,6 +19,7 @@ from .generated.base_node_schema import GeneratedBaseNodeSchema
19
19
  from .relationship_schema import RelationshipSchema
20
20
 
21
21
  if TYPE_CHECKING:
22
+ from pydantic.config import JsonDict
22
23
  from typing_extensions import Self
23
24
 
24
25
  from infrahub.core.schema import GenericSchema, NodeSchema
@@ -26,6 +27,7 @@ if TYPE_CHECKING:
26
27
 
27
28
 
28
29
  NODE_METADATA_ATTRIBUTES = ["_source", "_owner"]
30
+ NODE_PROPERTY_ATTRIBUTES = ["display_label", "human_friendly_id"]
29
31
  INHERITED = "INHERITED"
30
32
 
31
33
  OPTIONAL_TEXT_FIELDS = [
@@ -39,10 +41,43 @@ OPTIONAL_TEXT_FIELDS = [
39
41
  ]
40
42
 
41
43
 
44
+ def _json_schema_extra(schema: JsonDict) -> None:
45
+ """
46
+ Mutate the generated JSON Schema in place to:
47
+ - allow `null` for `display_labels`
48
+ - mark the non-null branch as deprecated
49
+ """
50
+ props = schema.get("properties")
51
+ if not isinstance(props, dict):
52
+ return
53
+ dl = props.get("display_labels")
54
+ if not isinstance(dl, dict):
55
+ return
56
+
57
+ if "anyOf" in dl:
58
+ dl["anyOf"] = [
59
+ {
60
+ "type": "array",
61
+ "items": {
62
+ "type": "string",
63
+ "deprecationMessage": "display_labels are deprecated use display_label instead",
64
+ },
65
+ },
66
+ {"type": "null"},
67
+ ]
68
+
69
+
42
70
  class BaseNodeSchema(GeneratedBaseNodeSchema):
43
71
  _exclude_from_hash: list[str] = ["attributes", "relationships"]
44
72
  _sort_by: list[str] = ["namespace", "name"]
45
73
 
74
+ model_config = ConfigDict(extra="forbid", json_schema_extra=_json_schema_extra)
75
+
76
+ @property
77
+ def is_schema_node(self) -> bool:
78
+ """Tell if this node represent a part of the schema. Not to confuse this with `is_node_schema`."""
79
+ return self.namespace == "Schema"
80
+
46
81
  @property
47
82
  def is_node_schema(self) -> bool:
48
83
  return False
@@ -240,6 +275,11 @@ class BaseNodeSchema(GeneratedBaseNodeSchema):
240
275
  return None
241
276
 
242
277
  def get_attribute(self, name: str) -> AttributeSchema:
278
+ if name == "human_friendly_id":
279
+ return AttributeSchema(name="human_friendly_id", kind="List", optional=True, branch=self.branch)
280
+ if name == "display_label":
281
+ return AttributeSchema(name="display_label", kind="Text", optional=True, branch=self.branch)
282
+
243
283
  for item in self.attributes:
244
284
  if item.name == name:
245
285
  return item
@@ -329,7 +369,7 @@ class BaseNodeSchema(GeneratedBaseNodeSchema):
329
369
 
330
370
  @property
331
371
  def valid_input_names(self) -> list[str]:
332
- return self.attribute_names + self.relationship_names + NODE_METADATA_ATTRIBUTES
372
+ return self.attribute_names + self.relationship_names + NODE_METADATA_ATTRIBUTES + NODE_PROPERTY_ATTRIBUTES
333
373
 
334
374
  @property
335
375
  def valid_local_names(self) -> list[str]:
@@ -29,6 +29,7 @@ from .core import core_node, core_task_target
29
29
  from .generator import core_generator_definition, core_generator_instance
30
30
  from .graphql_query import core_graphql_query
31
31
  from .group import (
32
+ core_generator_aware_group,
32
33
  core_generator_group,
33
34
  core_graphql_query_group,
34
35
  core_group,
@@ -128,6 +129,7 @@ core_models_mixed: dict[str, list] = {
128
129
  core_group_action,
129
130
  core_standard_group,
130
131
  core_generator_group,
132
+ core_generator_aware_group,
131
133
  core_graphql_query_group,
132
134
  core_repository_group,
133
135
  builtin_tag,
@@ -33,6 +33,8 @@ core_generator_definition = NodeSchema(
33
33
  Attr(name="file_path", kind="Text"),
34
34
  Attr(name="class_name", kind="Text"),
35
35
  Attr(name="convert_query_response", kind="Boolean", optional=True, default_value=False),
36
+ Attr(name="execute_in_proposed_change", kind="Boolean", optional=True, default_value=True),
37
+ Attr(name="execute_after_merge", kind="Boolean", optional=True, default_value=True),
36
38
  ],
37
39
  relationships=[
38
40
  Rel(
@@ -70,10 +70,10 @@ core_standard_group = NodeSchema(
70
70
  core_generator_group = NodeSchema(
71
71
  name="GeneratorGroup",
72
72
  namespace="Core",
73
- description="Group of nodes that are created by a generator.",
73
+ description="Group of nodes that are created by a generator. (local)",
74
74
  include_in_menu=False,
75
75
  icon="mdi:state-machine",
76
- label="Generator Group",
76
+ label="Generator Group (local)",
77
77
  default_filter="name__value",
78
78
  order_by=["name__value"],
79
79
  display_labels=["name__value"],
@@ -82,6 +82,20 @@ core_generator_group = NodeSchema(
82
82
  generate_profile=False,
83
83
  )
84
84
 
85
+ core_generator_aware_group = NodeSchema(
86
+ name="GeneratorAwareGroup",
87
+ namespace="Core",
88
+ description="Group of nodes that are created by a generator. (Aware)",
89
+ include_in_menu=False,
90
+ icon="mdi:state-machine",
91
+ label="Generator Group (aware)",
92
+ default_filter="name__value",
93
+ order_by=["name__value"],
94
+ display_labels=["name__value"],
95
+ branch=BranchSupportType.AWARE,
96
+ inherit_from=[InfrahubKind.GENERICGROUP],
97
+ generate_profile=False,
98
+ )
85
99
 
86
100
  core_graphql_query_group = NodeSchema(
87
101
  name="GraphQLQueryGroup",
@@ -282,5 +282,12 @@ core_generic_repository = GenericSchema(
282
282
  cardinality=Cardinality.MANY,
283
283
  order_weight=12000,
284
284
  ),
285
+ Rel(
286
+ name="groups_objects",
287
+ peer=InfrahubKind.REPOSITORYGROUP,
288
+ optional=True,
289
+ cardinality=Cardinality.MANY,
290
+ order_weight=13000,
291
+ ),
285
292
  ],
286
293
  )
@@ -179,7 +179,9 @@ class SchemaNode(BaseModel):
179
179
  default_filter: str | None = None
180
180
  attributes: list[SchemaAttribute]
181
181
  relationships: list[SchemaRelationship]
182
+ display_label: str | None = None
182
183
  display_labels: list[str]
184
+ uniqueness_constraints: list[list[str]] | None = None
183
185
 
184
186
  def to_dict(self) -> dict[str, Any]:
185
187
  return {
@@ -194,7 +196,9 @@ class SchemaNode(BaseModel):
194
196
  if attribute.name not in ["id", "attributes", "relationships"]
195
197
  ],
196
198
  "relationships": [relationship.to_dict() for relationship in self.relationships],
199
+ "display_label": self.display_label,
197
200
  "display_labels": self.display_labels,
201
+ "uniqueness_constraints": self.uniqueness_constraints,
198
202
  }
199
203
 
200
204
  def without_duplicates(self, other: SchemaNode) -> SchemaNode:
@@ -292,11 +296,18 @@ base_node_schema = SchemaNode(
292
296
  optional=True,
293
297
  extra={"update": UpdateSupport.ALLOWED},
294
298
  ),
299
+ SchemaAttribute(
300
+ name="display_label",
301
+ kind="Text",
302
+ description="Attribute or Jinja2 template to use to generate the display label",
303
+ optional=True,
304
+ extra={"update": UpdateSupport.ALLOWED},
305
+ ),
295
306
  SchemaAttribute(
296
307
  name="display_labels",
297
308
  kind="List",
298
309
  internal_kind=str,
299
- description="List of attributes to use to generate the display label",
310
+ description="List of attributes to use to generate the display label (deprecated)",
300
311
  optional=True,
301
312
  extra={"update": UpdateSupport.ALLOWED},
302
313
  ),
@@ -465,6 +476,7 @@ attribute_schema = SchemaNode(
465
476
  include_in_menu=False,
466
477
  default_filter=None,
467
478
  display_labels=["name__value"],
479
+ uniqueness_constraints=[["name__value", "node"]],
468
480
  attributes=[
469
481
  SchemaAttribute(
470
482
  name="id",
@@ -669,6 +681,7 @@ relationship_schema = SchemaNode(
669
681
  include_in_menu=False,
670
682
  default_filter=None,
671
683
  display_labels=["name__value"],
684
+ uniqueness_constraints=[["name__value", "node"]],
672
685
  attributes=[
673
686
  SchemaAttribute(
674
687
  name="id",
@@ -58,9 +58,14 @@ class GeneratedBaseNodeSchema(HashableModel):
58
58
  description="Human friendly and unique identifier for the object.",
59
59
  json_schema_extra={"update": "allowed"},
60
60
  )
61
+ display_label: str | None = Field(
62
+ default=None,
63
+ description="Attribute or Jinja2 template to use to generate the display label",
64
+ json_schema_extra={"update": "allowed"},
65
+ )
61
66
  display_labels: list[str] | None = Field(
62
67
  default=None,
63
- description="List of attributes to use to generate the display label",
68
+ description="List of attributes to use to generate the display label (deprecated)",
64
69
  json_schema_extra={"update": "allowed"},
65
70
  )
66
71
  include_in_menu: bool | None = Field(
@@ -90,6 +90,7 @@ class NodeSchema(GeneratedNodeSchema):
90
90
 
91
91
  properties_to_inherit = [
92
92
  "human_friendly_id",
93
+ "display_label",
93
94
  "display_labels",
94
95
  "default_filter",
95
96
  "menu_placement",
@@ -129,10 +130,12 @@ class NodeSchema(GeneratedNodeSchema):
129
130
  item_idx = existing_inherited_relationships[relationship.name]
130
131
  self.relationships[item_idx].update_from_generic(other=new_relationship)
131
132
 
132
- def get_hierarchy_schema(self, db: InfrahubDatabase, branch: Branch | str | None = None) -> GenericSchema:
133
+ def get_hierarchy_schema(
134
+ self, db: InfrahubDatabase, branch: Branch | str | None = None, duplicate: bool = False
135
+ ) -> GenericSchema:
133
136
  if not self.hierarchy:
134
137
  raise ValueError("The node is not part of a hierarchy")
135
- schema = db.schema.get(name=self.hierarchy, branch=branch)
138
+ schema = db.schema.get(name=self.hierarchy, branch=branch, duplicate=duplicate)
136
139
  if not isinstance(schema, GenericSchema):
137
140
  raise TypeError
138
141
  return schema
@@ -91,7 +91,6 @@ class RelationshipSchema(GeneratedRelationshipSchema):
91
91
  include_match: bool = True,
92
92
  param_prefix: str | None = None,
93
93
  partial_match: bool = False,
94
- support_profiles: bool = False, # noqa: ARG002
95
94
  ) -> tuple[list[QueryElement], dict[str, Any], list[str]]:
96
95
  """Generate Query String Snippet to filter the right node."""
97
96
 
@@ -18,6 +18,7 @@ from infrahub.computed_attribute.constants import VALID_KINDS as VALID_COMPUTED_
18
18
  from infrahub.core.constants import (
19
19
  OBJECT_TEMPLATE_NAME_ATTR,
20
20
  OBJECT_TEMPLATE_RELATIONSHIP_NAME,
21
+ PROFILE_NODE_RELATIONSHIP_IDENTIFIER,
21
22
  RESERVED_ATTR_GEN_NAMES,
22
23
  RESERVED_ATTR_REL_NAMES,
23
24
  RESTRICTED_NAMESPACES,
@@ -65,6 +66,8 @@ from ... import config
65
66
  from ..constants.schema import PARENT_CHILD_IDENTIFIER
66
67
  from .constants import INTERNAL_SCHEMA_NODE_KINDS, SchemaNamespace
67
68
  from .schema_branch_computed import ComputedAttributes
69
+ from .schema_branch_display import DisplayLabels
70
+ from .schema_branch_hfid import HFIDs
68
71
 
69
72
  log = get_logger()
70
73
 
@@ -76,6 +79,8 @@ class SchemaBranch:
76
79
  name: str | None = None,
77
80
  data: dict[str, dict[str, str]] | None = None,
78
81
  computed_attributes: ComputedAttributes | None = None,
82
+ display_labels: DisplayLabels | None = None,
83
+ hfids: HFIDs | None = None,
79
84
  ):
80
85
  self._cache: dict[str, NodeSchema | GenericSchema] = cache
81
86
  self.name: str | None = name
@@ -84,6 +89,8 @@ class SchemaBranch:
84
89
  self.profiles: dict[str, str] = {}
85
90
  self.templates: dict[str, str] = {}
86
91
  self.computed_attributes = computed_attributes or ComputedAttributes()
92
+ self.display_labels = display_labels or DisplayLabels()
93
+ self.hfids = hfids or HFIDs()
87
94
 
88
95
  if data:
89
96
  self.nodes = data.get("nodes", {})
@@ -269,6 +276,8 @@ class SchemaBranch:
269
276
  data=copy.deepcopy(self.to_dict()),
270
277
  cache=self._cache,
271
278
  computed_attributes=self.computed_attributes.duplicate(),
279
+ display_labels=self.display_labels.duplicate(),
280
+ hfids=self.hfids.duplicate(),
272
281
  )
273
282
 
274
283
  def set(self, name: str, schema: MainSchemaTypes) -> str:
@@ -503,6 +512,9 @@ class SchemaBranch:
503
512
  self.process_post_validation()
504
513
 
505
514
  def process_pre_validation(self) -> None:
515
+ self.process_nodes_state()
516
+ self.process_attributes_state()
517
+ self.process_relationships_state()
506
518
  self.generate_identifiers()
507
519
  self.process_default_values()
508
520
  self.process_deprecations()
@@ -528,6 +540,7 @@ class SchemaBranch:
528
540
  self.validate_identifiers()
529
541
  self.sync_uniqueness_constraints_and_unique_attributes()
530
542
  self.validate_uniqueness_constraints()
543
+ self.validate_display_label()
531
544
  self.validate_display_labels()
532
545
  self.validate_order_by()
533
546
  self.validate_default_filters()
@@ -751,6 +764,35 @@ class SchemaBranch:
751
764
  element_name=element_name,
752
765
  )
753
766
 
767
+ def validate_display_label(self) -> None:
768
+ self.display_labels = DisplayLabels()
769
+ for name in self.all_names:
770
+ node_schema = self.get(name=name, duplicate=False)
771
+
772
+ if node_schema.display_label is None and node_schema.display_labels:
773
+ update_candidate = self.get(name=name, duplicate=True)
774
+ if len(node_schema.display_labels) == 1:
775
+ # If the previous display_labels consist of a single attribute convert
776
+ # it to an attribute based display label
777
+ converted_display_label = node_schema.display_labels[0]
778
+ if "__" not in converted_display_label:
779
+ # Previously we allowed defining a raw attribute name as a component of a
780
+ # display_label, if this is the case we need to append '__value'
781
+ converted_display_label = f"{converted_display_label}__value"
782
+ update_candidate.display_label = converted_display_label
783
+ else:
784
+ # If the previous display label consists of multiple attributes
785
+ # convert it to a Jinja2 based display label
786
+ update_candidate.display_label = " ".join(
787
+ [f"{{{{ {display_label} }}}}" for display_label in node_schema.display_labels]
788
+ )
789
+ self.set(name=name, schema=update_candidate)
790
+
791
+ if not node_schema.display_label:
792
+ continue
793
+
794
+ self._validate_display_label(node=node_schema)
795
+
754
796
  def validate_display_labels(self) -> None:
755
797
  for name in self.all_names:
756
798
  node_schema = self.get(name=name, duplicate=False)
@@ -848,6 +890,7 @@ class SchemaBranch:
848
890
  return False
849
891
 
850
892
  def validate_human_friendly_id(self) -> None:
893
+ self.hfids = HFIDs()
851
894
  for name in self.generic_names_without_templates + self.node_names:
852
895
  node_schema = self.get(name=name, duplicate=False)
853
896
 
@@ -859,7 +902,14 @@ class SchemaBranch:
859
902
  # Mapping relationship identifiers -> list of attributes paths
860
903
  rel_schemas_to_paths: dict[str, tuple[MainSchemaTypes, list[str]]] = {}
861
904
 
905
+ visited_paths: list[str] = []
862
906
  for hfid_path in node_schema.human_friendly_id:
907
+ if config.SETTINGS.main.schema_strict_mode and hfid_path in visited_paths:
908
+ raise ValidationError(
909
+ f"HFID of {node_schema.kind} cannot use the same path more than once: {hfid_path}"
910
+ )
911
+
912
+ visited_paths.append(hfid_path)
863
913
  schema_path = self.validate_schema_path(
864
914
  node_schema=node_schema,
865
915
  path=hfid_path,
@@ -874,6 +924,11 @@ class SchemaBranch:
874
924
  rel_schemas_to_paths[rel_identifier] = (schema_path.related_schema, [])
875
925
  rel_schemas_to_paths[rel_identifier][1].append(schema_path.attribute_path_as_str)
876
926
 
927
+ if node_schema.is_node_schema and node_schema.namespace not in ["Schema", "Internal"]:
928
+ self.hfids.register_hfid_schema_path(
929
+ kind=node_schema.kind, schema_path=schema_path, hfid=node_schema.human_friendly_id
930
+ )
931
+
877
932
  if config.SETTINGS.main.schema_strict_mode:
878
933
  # For every relationship referred within hfid, check whether the combination of attributes is unique is the peer schema node
879
934
  for related_schema, attrs_paths in rel_schemas_to_paths.values():
@@ -1136,6 +1191,50 @@ class SchemaBranch:
1136
1191
  node=node_schema, attribute=attribute, generic=generic_schema
1137
1192
  )
1138
1193
 
1194
+ def _validate_display_label(self, node: MainSchemaTypes) -> None:
1195
+ if not node.display_label:
1196
+ return
1197
+
1198
+ if not any(c in node.display_label for c in "{}"):
1199
+ schema_path = self.validate_schema_path(
1200
+ node_schema=node,
1201
+ path=node.display_label,
1202
+ allowed_path_types=SchemaElementPathType.ATTR_WITH_PROP,
1203
+ element_name="display_label - non Jinja2",
1204
+ )
1205
+ if schema_path.attribute_schema and node.is_node_schema and node.namespace not in ["Internal", "Schema"]:
1206
+ self.display_labels.register_attribute_based_display_label(
1207
+ kind=node.kind, attribute_name=schema_path.attribute_schema.name
1208
+ )
1209
+ return
1210
+
1211
+ jinja_template = Jinja2Template(template=node.display_label)
1212
+ try:
1213
+ variables = jinja_template.get_variables()
1214
+ jinja_template.validate(restricted=config.SETTINGS.security.restrict_untrusted_jinja2_filters)
1215
+ except (JinjaTemplateOperationViolationError, JinjaTemplateError) as exc:
1216
+ raise ValueError(
1217
+ f"{node.kind}: display_label is set to a jinja2 template, but has an invalid template: {exc.message}"
1218
+ ) from exc
1219
+
1220
+ allowed_path_types = (
1221
+ SchemaElementPathType.ATTR_WITH_PROP
1222
+ | SchemaElementPathType.REL_ONE_MANDATORY_ATTR_WITH_PROP
1223
+ | SchemaElementPathType.REL_ONE_ATTR_WITH_PROP
1224
+ )
1225
+ for variable in variables:
1226
+ schema_path = self.validate_schema_path(
1227
+ node_schema=node, path=variable, allowed_path_types=allowed_path_types, element_name="display_label"
1228
+ )
1229
+
1230
+ if schema_path.is_type_attribute and schema_path.active_attribute_schema.name == "display_label":
1231
+ raise ValueError(f"{node.kind}: display_label the '{variable}' variable is a reference to itself")
1232
+
1233
+ if node.is_node_schema and node.namespace not in ["Internal", "Schema"]:
1234
+ self.display_labels.register_template_schema_path(
1235
+ kind=node.kind, schema_path=schema_path, template=node.display_label
1236
+ )
1237
+
1139
1238
  def _validate_computed_attribute(self, node: NodeSchema, attribute: AttributeSchema) -> None:
1140
1239
  if not attribute.computed_attribute or attribute.computed_attribute.kind == ComputedAttributeKind.USER:
1141
1240
  return
@@ -1611,6 +1710,42 @@ class SchemaBranch:
1611
1710
 
1612
1711
  self.set(name=name, schema=node)
1613
1712
 
1713
+ def process_relationships_state(self) -> None:
1714
+ for name in self.node_names + self.generic_names_without_templates:
1715
+ node = self.get(name=name, duplicate=False)
1716
+ if node.id or (not node.id and not node.relationships):
1717
+ continue
1718
+
1719
+ filtered_relationships = [
1720
+ relationship for relationship in node.relationships if relationship.state != HashableModelState.ABSENT
1721
+ ]
1722
+ if len(filtered_relationships) == len(node.relationships):
1723
+ continue
1724
+ updated_node = node.duplicate()
1725
+ updated_node.relationships = filtered_relationships
1726
+ self.set(name=name, schema=updated_node)
1727
+
1728
+ def process_attributes_state(self) -> None:
1729
+ for name in self.node_names + self.generic_names_without_templates:
1730
+ node = self.get(name=name, duplicate=False)
1731
+ if not node.attributes:
1732
+ continue
1733
+
1734
+ filtered_attributes = [
1735
+ attribute for attribute in node.attributes if attribute.state != HashableModelState.ABSENT
1736
+ ]
1737
+ if len(filtered_attributes) == len(node.attributes):
1738
+ continue
1739
+ updated_node = node.duplicate()
1740
+ updated_node.attributes = filtered_attributes
1741
+ self.set(name=name, schema=updated_node)
1742
+
1743
+ def process_nodes_state(self) -> None:
1744
+ for name in self.node_names + self.generic_names_without_templates:
1745
+ node = self.get(name=name, duplicate=False)
1746
+ if not node.id and node.state == HashableModelState.ABSENT:
1747
+ self.delete(name=name)
1748
+
1614
1749
  def _generate_weight_generics(self) -> None:
1615
1750
  """Generate order_weight for all generic schemas."""
1616
1751
  for name in self.generic_names:
@@ -1934,7 +2069,7 @@ class SchemaBranch:
1934
2069
 
1935
2070
  profiles_rel_settings: dict[str, Any] = {
1936
2071
  "name": "profiles",
1937
- "identifier": "node__profile",
2072
+ "identifier": PROFILE_NODE_RELATIONSHIP_IDENTIFIER,
1938
2073
  "peer": InfrahubKind.PROFILE,
1939
2074
  "kind": RelationshipKind.PROFILE,
1940
2075
  "cardinality": RelationshipCardinality.MANY,
@@ -1997,7 +2132,7 @@ class SchemaBranch:
1997
2132
  relationships=[
1998
2133
  RelationshipSchema(
1999
2134
  name="related_nodes",
2000
- identifier="node__profile",
2135
+ identifier=PROFILE_NODE_RELATIONSHIP_IDENTIFIER,
2001
2136
  peer=node.kind,
2002
2137
  kind=RelationshipKind.PROFILE,
2003
2138
  cardinality=RelationshipCardinality.MANY,