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.
- infrahub/actions/tasks.py +208 -16
- infrahub/api/artifact.py +3 -0
- infrahub/api/diff/diff.py +1 -1
- infrahub/api/query.py +2 -0
- infrahub/api/schema.py +3 -0
- infrahub/auth.py +5 -5
- infrahub/cli/db.py +26 -2
- infrahub/cli/db_commands/clean_duplicate_schema_fields.py +212 -0
- infrahub/config.py +7 -2
- infrahub/core/attribute.py +25 -22
- infrahub/core/branch/models.py +2 -2
- infrahub/core/branch/needs_rebase_status.py +11 -0
- infrahub/core/branch/tasks.py +4 -3
- infrahub/core/changelog/models.py +4 -12
- infrahub/core/constants/__init__.py +1 -0
- infrahub/core/constants/infrahubkind.py +1 -0
- infrahub/core/convert_object_type/object_conversion.py +201 -0
- infrahub/core/convert_object_type/repository_conversion.py +89 -0
- infrahub/core/convert_object_type/schema_mapping.py +27 -3
- infrahub/core/diff/model/path.py +4 -0
- infrahub/core/diff/payload_builder.py +1 -1
- infrahub/core/diff/query/artifact.py +1 -1
- infrahub/core/graph/__init__.py +1 -1
- infrahub/core/initialization.py +2 -2
- infrahub/core/ipam/utilization.py +1 -1
- infrahub/core/manager.py +9 -84
- infrahub/core/migrations/graph/__init__.py +6 -0
- infrahub/core/migrations/graph/m040_profile_attrs_in_db.py +166 -0
- infrahub/core/migrations/graph/m041_create_hfid_display_label_in_db.py +97 -0
- infrahub/core/migrations/graph/m042_backfill_hfid_display_label_in_db.py +86 -0
- infrahub/core/migrations/schema/node_attribute_add.py +5 -2
- infrahub/core/migrations/shared.py +5 -6
- infrahub/core/node/__init__.py +165 -42
- infrahub/core/node/constraints/attribute_uniqueness.py +3 -1
- infrahub/core/node/create.py +67 -35
- infrahub/core/node/lock_utils.py +98 -0
- infrahub/core/node/node_property_attribute.py +230 -0
- infrahub/core/node/standard.py +1 -1
- infrahub/core/property.py +11 -0
- infrahub/core/protocols.py +8 -1
- infrahub/core/query/attribute.py +27 -15
- infrahub/core/query/node.py +61 -185
- infrahub/core/query/relationship.py +43 -26
- infrahub/core/query/subquery.py +0 -8
- infrahub/core/registry.py +2 -2
- infrahub/core/relationship/constraints/count.py +1 -1
- infrahub/core/relationship/model.py +60 -20
- infrahub/core/schema/attribute_schema.py +0 -2
- infrahub/core/schema/basenode_schema.py +42 -2
- infrahub/core/schema/definitions/core/__init__.py +2 -0
- infrahub/core/schema/definitions/core/generator.py +2 -0
- infrahub/core/schema/definitions/core/group.py +16 -2
- infrahub/core/schema/definitions/core/repository.py +7 -0
- infrahub/core/schema/definitions/internal.py +14 -1
- infrahub/core/schema/generated/base_node_schema.py +6 -1
- infrahub/core/schema/node_schema.py +5 -2
- infrahub/core/schema/relationship_schema.py +0 -1
- infrahub/core/schema/schema_branch.py +137 -2
- infrahub/core/schema/schema_branch_display.py +123 -0
- infrahub/core/schema/schema_branch_hfid.py +114 -0
- infrahub/core/validators/aggregated_checker.py +1 -1
- infrahub/core/validators/determiner.py +12 -1
- infrahub/core/validators/relationship/peer.py +1 -1
- infrahub/core/validators/tasks.py +1 -1
- infrahub/display_labels/__init__.py +0 -0
- infrahub/display_labels/gather.py +48 -0
- infrahub/display_labels/models.py +240 -0
- infrahub/display_labels/tasks.py +186 -0
- infrahub/display_labels/triggers.py +22 -0
- infrahub/events/group_action.py +1 -1
- infrahub/events/node_action.py +1 -1
- infrahub/generators/constants.py +7 -0
- infrahub/generators/models.py +38 -12
- infrahub/generators/tasks.py +34 -16
- infrahub/git/base.py +38 -1
- infrahub/git/integrator.py +22 -14
- infrahub/graphql/analyzer.py +1 -1
- infrahub/graphql/api/dependencies.py +2 -4
- infrahub/graphql/api/endpoints.py +2 -2
- infrahub/graphql/app.py +2 -4
- infrahub/graphql/initialization.py +2 -3
- infrahub/graphql/manager.py +212 -137
- infrahub/graphql/middleware.py +12 -0
- infrahub/graphql/mutations/branch.py +11 -0
- infrahub/graphql/mutations/computed_attribute.py +110 -3
- infrahub/graphql/mutations/convert_object_type.py +34 -13
- infrahub/graphql/mutations/display_label.py +111 -0
- infrahub/graphql/mutations/generator.py +25 -7
- infrahub/graphql/mutations/hfid.py +118 -0
- infrahub/graphql/mutations/ipam.py +21 -8
- infrahub/graphql/mutations/main.py +37 -153
- infrahub/graphql/mutations/profile.py +195 -0
- infrahub/graphql/mutations/proposed_change.py +2 -1
- infrahub/graphql/mutations/relationship.py +2 -2
- infrahub/graphql/mutations/repository.py +22 -83
- infrahub/graphql/mutations/resource_manager.py +2 -2
- infrahub/graphql/mutations/schema.py +5 -5
- infrahub/graphql/mutations/webhook.py +1 -1
- infrahub/graphql/queries/resource_manager.py +1 -1
- infrahub/graphql/registry.py +173 -0
- infrahub/graphql/resolvers/resolver.py +2 -0
- infrahub/graphql/schema.py +8 -1
- infrahub/groups/tasks.py +1 -1
- infrahub/hfid/__init__.py +0 -0
- infrahub/hfid/gather.py +48 -0
- infrahub/hfid/models.py +240 -0
- infrahub/hfid/tasks.py +185 -0
- infrahub/hfid/triggers.py +22 -0
- infrahub/lock.py +67 -30
- infrahub/locks/__init__.py +0 -0
- infrahub/locks/tasks.py +37 -0
- infrahub/middleware.py +26 -1
- infrahub/patch/plan_writer.py +2 -2
- infrahub/profiles/__init__.py +0 -0
- infrahub/profiles/node_applier.py +101 -0
- infrahub/profiles/queries/__init__.py +0 -0
- infrahub/profiles/queries/get_profile_data.py +99 -0
- infrahub/profiles/tasks.py +63 -0
- infrahub/proposed_change/tasks.py +10 -1
- infrahub/repositories/__init__.py +0 -0
- infrahub/repositories/create_repository.py +113 -0
- infrahub/server.py +16 -3
- infrahub/services/__init__.py +8 -5
- infrahub/tasks/registry.py +6 -4
- infrahub/trigger/catalogue.py +4 -0
- infrahub/trigger/models.py +2 -0
- infrahub/trigger/tasks.py +3 -0
- infrahub/webhook/models.py +1 -1
- infrahub/workflows/catalogue.py +110 -3
- infrahub/workflows/initialization.py +16 -0
- infrahub/workflows/models.py +17 -2
- infrahub_sdk/branch.py +5 -8
- infrahub_sdk/checks.py +1 -1
- infrahub_sdk/client.py +364 -84
- infrahub_sdk/convert_object_type.py +61 -0
- infrahub_sdk/ctl/check.py +2 -3
- infrahub_sdk/ctl/cli_commands.py +18 -12
- infrahub_sdk/ctl/config.py +8 -2
- infrahub_sdk/ctl/generator.py +6 -3
- infrahub_sdk/ctl/graphql.py +184 -0
- infrahub_sdk/ctl/repository.py +39 -1
- infrahub_sdk/ctl/schema.py +18 -3
- infrahub_sdk/ctl/utils.py +4 -0
- infrahub_sdk/ctl/validate.py +5 -3
- infrahub_sdk/diff.py +4 -5
- infrahub_sdk/exceptions.py +2 -0
- infrahub_sdk/generator.py +7 -1
- infrahub_sdk/graphql/__init__.py +12 -0
- infrahub_sdk/graphql/constants.py +1 -0
- infrahub_sdk/graphql/plugin.py +85 -0
- infrahub_sdk/graphql/query.py +77 -0
- infrahub_sdk/{graphql.py → graphql/renderers.py} +88 -75
- infrahub_sdk/graphql/utils.py +40 -0
- infrahub_sdk/node/attribute.py +2 -0
- infrahub_sdk/node/node.py +28 -20
- infrahub_sdk/playback.py +1 -2
- infrahub_sdk/protocols.py +54 -6
- infrahub_sdk/pytest_plugin/plugin.py +7 -4
- infrahub_sdk/pytest_plugin/utils.py +40 -0
- infrahub_sdk/repository.py +1 -2
- infrahub_sdk/schema/__init__.py +38 -0
- infrahub_sdk/schema/main.py +1 -0
- infrahub_sdk/schema/repository.py +8 -0
- infrahub_sdk/spec/object.py +120 -7
- infrahub_sdk/spec/range_expansion.py +118 -0
- infrahub_sdk/timestamp.py +18 -6
- infrahub_sdk/transforms.py +1 -1
- {infrahub_server-1.4.10.dist-info → infrahub_server-1.5.0b1.dist-info}/METADATA +9 -11
- {infrahub_server-1.4.10.dist-info → infrahub_server-1.5.0b1.dist-info}/RECORD +177 -134
- infrahub_testcontainers/container.py +1 -1
- infrahub_testcontainers/docker-compose-cluster.test.yml +1 -1
- infrahub_testcontainers/docker-compose.test.yml +1 -1
- infrahub_testcontainers/models.py +2 -2
- infrahub_testcontainers/performance_test.py +4 -4
- infrahub/core/convert_object_type/conversion.py +0 -134
- {infrahub_server-1.4.10.dist-info → infrahub_server-1.5.0b1.dist-info}/LICENSE.txt +0 -0
- {infrahub_server-1.4.10.dist-info → infrahub_server-1.5.0b1.dist-info}/WHEEL +0 -0
- {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__(
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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(
|
|
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":
|
|
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=
|
|
2135
|
+
identifier=PROFILE_NODE_RELATIONSHIP_IDENTIFIER,
|
|
2001
2136
|
peer=node.kind,
|
|
2002
2137
|
kind=RelationshipKind.PROFILE,
|
|
2003
2138
|
cardinality=RelationshipCardinality.MANY,
|