diff --git a/backend/infrahub/api/menu.py b/backend/infrahub/api/menu.py index c7573258b2..4f16a06424 100644 --- a/backend/infrahub/api/menu.py +++ b/backend/infrahub/api/menu.py @@ -133,11 +133,6 @@ async def get_menu(branch: Branch = Depends(get_branch_dep)) -> list[InterfaceMe unified_storage = InterfaceMenu( title="Unified Storage", children=[ - InterfaceMenu( - title="Credentials", - path=f"/objects/{InfrahubKind.CREDENTIAL}", - icon=_extract_node_icon(full_schema[InfrahubKind.CREDENTIAL]), - ), InterfaceMenu(title="Schema", path="/schema", icon="mdi:file-code"), InterfaceMenu( title="Repository", @@ -207,6 +202,11 @@ async def get_menu(branch: Branch = Depends(get_branch_dep)) -> list[InterfaceMe path=f"/objects/{InfrahubKind.GENERICACCOUNT}", icon=_extract_node_icon(full_schema[InfrahubKind.GENERICACCOUNT]), ), + InterfaceMenu( + title="Credentials", + path=f"/objects/{InfrahubKind.CREDENTIAL}", + icon=_extract_node_icon(full_schema[InfrahubKind.CREDENTIAL]), + ), InterfaceMenu( title="Webhooks", children=[ diff --git a/backend/infrahub/core/attribute.py b/backend/infrahub/core/attribute.py index 6e218c9ca6..a661c7d5b1 100644 --- a/backend/infrahub/core/attribute.py +++ b/backend/infrahub/core/attribute.py @@ -88,7 +88,7 @@ def __init__( # pylint: disable=too-many-branches self.at = at self.is_default = is_default self.is_from_profile = is_from_profile - self.from_pool: Optional[str] = None + self.from_pool: Optional[dict] = None self._init_node_property_mixin(kwargs) self._init_flag_property_mixin(kwargs) diff --git a/backend/infrahub/core/constants/__init__.py b/backend/infrahub/core/constants/__init__.py index 0e5ecb49aa..6bb1f4195c 100644 --- a/backend/infrahub/core/constants/__init__.py +++ b/backend/infrahub/core/constants/__init__.py @@ -107,7 +107,7 @@ class CheckType(InfrahubStringEnum): ALL = "all" -class RepositoryAdminStatus(InfrahubStringEnum): +class RepositoryInternalStatus(InfrahubStringEnum): ACTIVE = "active" INACTIVE = "inactive" STAGING = "staging" diff --git a/backend/infrahub/core/diff/combiner.py b/backend/infrahub/core/diff/combiner.py index 986af352b6..ed4c4eedd6 100644 --- a/backend/infrahub/core/diff/combiner.py +++ b/backend/infrahub/core/diff/combiner.py @@ -3,8 +3,8 @@ from typing import Iterable from uuid import uuid4 -from infrahub.core import registry from infrahub.core.constants import DiffAction, RelationshipCardinality +from infrahub.core.constants.database import DatabaseEdgeType from .model.path import ( EnrichedDiffAttribute, @@ -25,7 +25,6 @@ class NodePair: class DiffCombiner: def __init__(self) -> None: - self.schema_manager = registry.schema # {child_uuid: (parent_uuid, parent_rel_name)} self._child_parent_uuid_map: dict[str, tuple[str, str]] = {} self._parent_node_uuids: set[str] = set() @@ -89,20 +88,26 @@ def _should_include(self, earlier: DiffAction, later: DiffAction) -> bool: actions = {earlier, later} if actions == {DiffAction.UNCHANGED}: return False - if actions == {DiffAction.ADDED, DiffAction.REMOVED}: + if earlier is DiffAction.ADDED and later is DiffAction.REMOVED: return False return True def _combine_actions(self, earlier: DiffAction, later: DiffAction) -> DiffAction: actions = {earlier, later} - combined_action = DiffAction.UPDATED - if DiffAction.ADDED in actions: - combined_action = DiffAction.ADDED - elif DiffAction.REMOVED in actions: - combined_action = DiffAction.REMOVED - elif actions == {DiffAction.UNCHANGED}: - combined_action = DiffAction.UNCHANGED - return combined_action + if len(actions) == 1: + return actions.pop() + if DiffAction.UNCHANGED in actions: + actual_action = actions - {DiffAction.UNCHANGED} + return actual_action.pop() + actions_map = { + (DiffAction.ADDED, DiffAction.REMOVED): DiffAction.UPDATED, + (DiffAction.ADDED, DiffAction.UPDATED): DiffAction.ADDED, + (DiffAction.UPDATED, DiffAction.ADDED): DiffAction.UPDATED, + (DiffAction.UPDATED, DiffAction.REMOVED): DiffAction.REMOVED, + (DiffAction.REMOVED, DiffAction.ADDED): DiffAction.UPDATED, + (DiffAction.REMOVED, DiffAction.UPDATED): DiffAction.UPDATED, + } + return actions_map[(earlier, later)] def _combine_conflicts( self, earlier: EnrichedDiffConflict | None, later: EnrichedDiffConflict | None @@ -141,16 +146,15 @@ def _combine_properties( combined_conflict = self._combine_conflicts( earlier=earlier_property.conflict, later=later_property.conflict ) - combined_property = EnrichedDiffProperty( - property_type=later_property.property_type, - changed_at=later_property.changed_at, - previous_value=earlier_property.previous_value, - new_value=later_property.new_value, - path_identifier=later_property.path_identifier, - action=self._combine_actions(earlier=earlier_property.action, later=later_property.action), - conflict=combined_conflict, + combined_properties.add( + replace( + later_property, + previous_label=earlier_property.previous_label, + previous_value=earlier_property.previous_value, + action=self._combine_actions(earlier=earlier_property.action, later=later_property.action), + conflict=combined_conflict, + ) ) - combined_properties.add(combined_property) combined_properties |= { deepcopy(prop) for prop in later_properties if prop.property_type not in common_property_types } @@ -200,11 +204,25 @@ def _combine_cardinality_one_relationship_elements( earlier_properties=combined_properties, later_properties=element.properties ) final_element = ordered_elements[-1] + peer_id = final_element.peer_id + peer_label = final_element.peer_label + # if this relationship is removed and was updated earlier, use the previous peer ID from the update + if combined_action is DiffAction.REMOVED: + for element in ordered_elements: + for prop in element.properties: + if ( + prop.property_type is DatabaseEdgeType.IS_RELATED + and prop.action is DiffAction.UPDATED + and prop.previous_value + ): + peer_id = prop.previous_value + peer_label = prop.previous_label + break return EnrichedDiffSingleRelationship( changed_at=final_element.changed_at, action=combined_action, - peer_id=final_element.peer_id, - peer_label=final_element.peer_label, + peer_id=peer_id, + peer_label=peer_label, path_identifier=final_element.path_identifier, properties=combined_properties, conflict=self._combine_conflicts(earlier=ordered_elements[0].conflict, later=final_element.conflict), @@ -244,9 +262,7 @@ def _combine_relationships( self, earlier_relationships: set[EnrichedDiffRelationship], later_relationships: set[EnrichedDiffRelationship], - node_kind: str, ) -> set[EnrichedDiffRelationship]: - node_schema = self.schema_manager.get_node_schema(name=node_kind, branch=self.diff_branch_name, duplicate=False) earlier_rels_by_name = {rel.name: rel for rel in earlier_relationships} later_rels_by_name = {rel.name: rel for rel in later_relationships} common_rel_names = set(earlier_rels_by_name.keys()) & set(later_rels_by_name.keys()) @@ -257,12 +273,10 @@ def _combine_relationships( copied.nodes = set() combined_relationships.add(copied) continue - relationship_schema = node_schema.get_relationship(name=earlier_relationship.name) - is_cardinality_one = relationship_schema.cardinality is RelationshipCardinality.ONE later_relationship = later_rels_by_name[earlier_relationship.name] if len(earlier_relationship.relationships) == 0 and len(later_relationship.relationships) == 0: combined_relationship_elements = set() - elif is_cardinality_one: + elif earlier_relationship.cardinality is RelationshipCardinality.ONE: combined_relationship_elements = { self._combine_cardinality_one_relationship_elements( elements=(earlier_relationship.relationships | later_relationship.relationships) @@ -275,6 +289,7 @@ def _combine_relationships( combined_relationship = EnrichedDiffRelationship( name=later_relationship.name, label=later_relationship.label, + cardinality=later_relationship.cardinality, changed_at=later_relationship.changed_at or earlier_relationship.changed_at, action=self._combine_actions(earlier=earlier_relationship.action, later=later_relationship.action), path_identifier=later_relationship.path_identifier, @@ -315,7 +330,6 @@ def _combine_nodes(self, node_pairs: list[NodePair]) -> set[EnrichedDiffNode]: combined_relationships = self._combine_relationships( earlier_relationships=node_pair.earlier.relationships, later_relationships=node_pair.later.relationships, - node_kind=node_pair.later.kind, ) combined_action = self._combine_actions(earlier=node_pair.earlier.action, later=node_pair.later.action) combined_nodes.add( diff --git a/backend/infrahub/core/diff/conflicts_enricher.py b/backend/infrahub/core/diff/conflicts_enricher.py index 28030a73f1..6a0147198e 100644 --- a/backend/infrahub/core/diff/conflicts_enricher.py +++ b/backend/infrahub/core/diff/conflicts_enricher.py @@ -50,6 +50,8 @@ async def add_conflicts_to_branch_diff( def _add_node_conflicts(self, base_node: EnrichedDiffNode, branch_node: EnrichedDiffNode) -> None: if base_node.action != branch_node.action: self._add_node_conflict(base_node=base_node, branch_node=branch_node) + elif branch_node.conflict: + branch_node.conflict = None base_attribute_map = {a.name: a for a in base_node.attributes} branch_attribute_map = {a.name: a for a in branch_node.attributes} common_attribute_names = set(base_attribute_map.keys()) & set(branch_attribute_map.keys()) @@ -103,6 +105,8 @@ def _add_attribute_conflicts( base_property=base_property, branch_property=branch_property, ) + elif branch_property.conflict: + branch_property.conflict = None def _add_relationship_conflicts( self, @@ -147,36 +151,49 @@ def _add_relationship_conflicts_for_one_peer( for property_type in common_property_types: base_property = base_properties_by_type[property_type] branch_property = branch_properties_by_type[property_type] - if base_property.new_value != branch_property.new_value: - if branch_property.conflict: - conflict_uuid = branch_property.conflict.uuid - selected_branch = branch_property.conflict.selected_branch + same_value = base_property.new_value == branch_property.new_value + # special handling for cardinality-one peer ID conflict + if branch_property.property_type is DatabaseEdgeType.IS_RELATED and is_cardinality_one: + if same_value: + branch_element.conflict = None + continue + if branch_element.conflict: + conflict_uuid = branch_element.conflict.uuid + selected_branch = branch_element.conflict.selected_branch else: conflict_uuid = str(uuid4()) selected_branch = None - # special handling for cardinality-one peer ID conflict - if branch_property.property_type is DatabaseEdgeType.IS_RELATED and is_cardinality_one: - branch_element.conflict = EnrichedDiffConflict( - uuid=conflict_uuid, - base_branch_action=base_element.action, - base_branch_value=base_property.new_value, - base_branch_changed_at=base_property.changed_at, - diff_branch_action=branch_element.action, - diff_branch_value=branch_property.new_value, - diff_branch_changed_at=branch_property.changed_at, - selected_branch=selected_branch, - ) - else: - branch_property.conflict = EnrichedDiffConflict( - uuid=conflict_uuid, - base_branch_action=base_property.action, - base_branch_value=base_property.new_value, - base_branch_changed_at=base_property.changed_at, - diff_branch_action=branch_property.action, - diff_branch_value=branch_property.new_value, - diff_branch_changed_at=branch_property.changed_at, - selected_branch=selected_branch, - ) + conflict = EnrichedDiffConflict( + uuid=conflict_uuid, + base_branch_action=base_element.action, + base_branch_value=base_property.new_value, + base_branch_changed_at=base_property.changed_at, + diff_branch_action=branch_element.action, + diff_branch_value=branch_property.new_value, + diff_branch_changed_at=branch_property.changed_at, + selected_branch=selected_branch, + ) + branch_element.conflict = conflict + continue + if same_value: + branch_property.conflict = None + continue + if branch_property.conflict: + conflict_uuid = branch_property.conflict.uuid + selected_branch = branch_property.conflict.selected_branch + else: + conflict_uuid = str(uuid4()) + selected_branch = None + branch_property.conflict = EnrichedDiffConflict( + uuid=conflict_uuid, + base_branch_action=base_property.action, + base_branch_value=base_property.new_value, + base_branch_changed_at=base_property.changed_at, + diff_branch_action=branch_property.action, + diff_branch_value=branch_property.new_value, + diff_branch_changed_at=branch_property.changed_at, + selected_branch=selected_branch, + ) def _add_property_conflict( self, diff --git a/backend/infrahub/core/diff/coordinator.py b/backend/infrahub/core/diff/coordinator.py index 49550dcc17..06289511f6 100644 --- a/backend/infrahub/core/diff/coordinator.py +++ b/backend/infrahub/core/diff/coordinator.py @@ -3,7 +3,9 @@ from dataclasses import dataclass, replace from typing import TYPE_CHECKING +from infrahub import lock from infrahub.core.timestamp import Timestamp +from infrahub.log import get_logger from .model.path import BranchTrackingId, EnrichedDiffRoot, NameTrackingId, TimeRange, TrackingId @@ -16,10 +18,14 @@ from .conflicts_enricher import ConflictsEnricher from .data_check_synchronizer import DiffDataCheckSynchronizer from .enricher.aggregated import AggregatedDiffEnricher + from .enricher.labels import DiffLabelsEnricher from .enricher.summary_counts import DiffSummaryCountsEnricher from .repository.repository import DiffRepository +log = get_logger() + + @dataclass class EnrichedDiffRequest: base_branch: Branch @@ -33,6 +39,8 @@ def __hash__(self) -> int: class DiffCoordinator: + lock_namespace = "diff-update" + def __init__( self, diff_repo: DiffRepository, @@ -40,6 +48,7 @@ def __init__( diff_enricher: AggregatedDiffEnricher, diff_combiner: DiffCombiner, conflicts_enricher: ConflictsEnricher, + labels_enricher: DiffLabelsEnricher, summary_counts_enricher: DiffSummaryCountsEnricher, data_check_synchronizer: DiffDataCheckSynchronizer, ) -> None: @@ -48,8 +57,10 @@ def __init__( self.diff_enricher = diff_enricher self.diff_combiner = diff_combiner self.conflicts_enricher = conflicts_enricher + self.labels_enricher = labels_enricher self.summary_counts_enricher = summary_counts_enricher self.data_check_synchronizer = data_check_synchronizer + self.lock_registry = lock.registry self._enriched_diff_cache: dict[EnrichedDiffRequest, EnrichedDiffRoot] = {} async def run_update( @@ -80,17 +91,45 @@ async def run_update( name=name, ) + def _get_lock_name(self, base_branch_name: str, diff_branch_name: str, is_incremental: bool) -> str: + lock_name = f"{base_branch_name}__{diff_branch_name}" + if is_incremental: + lock_name += "__incremental" + return lock_name + async def update_branch_diff(self, base_branch: Branch, diff_branch: Branch) -> EnrichedDiffRoot: + log.debug(f"Received request to update branch diff for {base_branch.name} - {diff_branch.name}") + incremental_lock_name = self._get_lock_name( + base_branch_name=base_branch.name, diff_branch_name=diff_branch.name, is_incremental=True + ) + existing_incremental_lock = self.lock_registry.get_existing( + name=incremental_lock_name, namespace=self.lock_namespace + ) + if existing_incremental_lock and await existing_incremental_lock.locked(): + log.debug(f"Branch diff update for {base_branch.name} - {diff_branch.name} already in progress") + async with self.lock_registry.get(name=incremental_lock_name, namespace=self.lock_namespace): + log.debug(f"Existing branch diff update for {base_branch.name} - {diff_branch.name} complete") + return await self.diff_repo.get_one( + tracking_id=BranchTrackingId(name=diff_branch.name), diff_branch_name=diff_branch.name + ) + general_lock_name = self._get_lock_name( + base_branch_name=base_branch.name, diff_branch_name=diff_branch.name, is_incremental=False + ) from_time = Timestamp(diff_branch.get_created_at()) to_time = Timestamp() tracking_id = BranchTrackingId(name=diff_branch.name) - return await self._update_diffs( - base_branch=base_branch, - diff_branch=diff_branch, - from_time=from_time, - to_time=to_time, - tracking_id=tracking_id, - ) + async with ( + self.lock_registry.get(name=general_lock_name, namespace=self.lock_namespace), + self.lock_registry.get(name=incremental_lock_name, namespace=self.lock_namespace), + ): + log.debug(f"Acquired lock to run branch diff update for {base_branch.name} - {diff_branch.name}") + return await self._update_diffs( + base_branch=base_branch, + diff_branch=diff_branch, + from_time=from_time, + to_time=to_time, + tracking_id=tracking_id, + ) async def create_or_update_arbitrary_timeframe_diff( self, @@ -103,13 +142,18 @@ async def create_or_update_arbitrary_timeframe_diff( tracking_id = None if name: tracking_id = NameTrackingId(name=name) - return await self._update_diffs( - base_branch=base_branch, - diff_branch=diff_branch, - from_time=from_time, - to_time=to_time, - tracking_id=tracking_id, + general_lock_name = self._get_lock_name( + base_branch_name=base_branch.name, diff_branch_name=diff_branch.name, is_incremental=False ) + async with self.lock_registry.get(name=general_lock_name, namespace=self.lock_namespace): + log.debug(f"Acquired lock to run arbitrary diff update for {base_branch.name} - {diff_branch.name}") + return await self._update_diffs( + base_branch=base_branch, + diff_branch=diff_branch, + from_time=from_time, + to_time=to_time, + tracking_id=tracking_id, + ) async def _update_diffs( self, @@ -128,6 +172,8 @@ async def _update_diffs( diff_branch_names=[branch.name], from_time=from_time, to_time=to_time, + tracking_id=tracking_id, + include_empty=True, ) if tracking_id: diff_uuids_to_delete += [ @@ -161,6 +207,9 @@ async def _update_diffs( base_diff_root=aggregated_diffs_by_branch_name[base_branch.name], branch_diff_root=aggregated_diffs_by_branch_name[diff_branch.name], ) + await self.labels_enricher.enrich( + enriched_diff_root=aggregated_diffs_by_branch_name[diff_branch.name], conflicts_only=True + ) if tracking_id: for enriched_diff in aggregated_diffs_by_branch_name.values(): @@ -199,16 +248,17 @@ def _get_missing_time_ranges( ) -> list[TimeRange]: if not time_ranges: return [TimeRange(from_time=from_time, to_time=to_time)] + sorted_time_ranges = sorted(time_ranges, key=lambda tr: tr.from_time) missing_time_ranges = [] - if time_ranges[0].from_time > from_time: - missing_time_ranges.append(TimeRange(from_time=from_time, to_time=time_ranges[0].from_time)) + if sorted_time_ranges[0].from_time > from_time: + missing_time_ranges.append(TimeRange(from_time=from_time, to_time=sorted_time_ranges[0].from_time)) index = 0 - while index < len(time_ranges) - 1: - this_diff = time_ranges[index] - next_diff = time_ranges[index + 1] - if this_diff.to_time != next_diff.from_time: + while index < len(sorted_time_ranges) - 1: + this_diff = sorted_time_ranges[index] + next_diff = sorted_time_ranges[index + 1] + if this_diff.to_time < next_diff.from_time: missing_time_ranges.append(TimeRange(from_time=this_diff.to_time, to_time=next_diff.from_time)) index += 1 - if time_ranges[-1].to_time < to_time: - missing_time_ranges.append(TimeRange(from_time=time_ranges[-1].to_time, to_time=to_time)) + if sorted_time_ranges[-1].to_time < to_time: + missing_time_ranges.append(TimeRange(from_time=sorted_time_ranges[-1].to_time, to_time=to_time)) return missing_time_ranges diff --git a/backend/infrahub/core/diff/data_check_synchronizer.py b/backend/infrahub/core/diff/data_check_synchronizer.py index 191836d669..492919db75 100644 --- a/backend/infrahub/core/diff/data_check_synchronizer.py +++ b/backend/infrahub/core/diff/data_check_synchronizer.py @@ -1,5 +1,4 @@ from enum import Enum -from typing import TYPE_CHECKING from infrahub.core.constants import BranchConflictKeep, InfrahubKind, ProposedChangeState from infrahub.core.integrity.object_conflict.conflict_recorder import ObjectConflictValidatorRecorder @@ -10,9 +9,6 @@ from .conflicts_extractor import DiffConflictsExtractor from .model.path import ConflictSelection, EnrichedDiffConflict, EnrichedDiffRoot -if TYPE_CHECKING: - from infrahub.core.protocols import CoreProposedChange - class DiffDataCheckSynchronizer: def __init__( @@ -33,26 +29,28 @@ async def synchronize(self, enriched_diff: EnrichedDiffRoot) -> list[Node]: ) if not proposed_changes: return [] - proposed_change: CoreProposedChange = proposed_changes[0] enriched_conflicts = enriched_diff.get_all_conflicts() data_conflicts = await self.conflicts_extractor.get_data_conflicts(enriched_diff_root=enriched_diff) - core_data_checks = await self.conflict_recorder.record_conflicts( - proposed_change_id=proposed_change.get_id(), conflicts=data_conflicts - ) - core_data_checks_by_id = {cdc.get_id(): cdc for cdc in core_data_checks} - enriched_conflicts_by_id = {ec.uuid: ec for ec in enriched_conflicts} - for conflict_id, core_data_check in core_data_checks_by_id.items(): - enriched_conflict = enriched_conflicts_by_id.get(conflict_id) - if not enriched_conflict: - continue - expected_keep_branch = self._get_keep_branch_for_enriched_conflict(enriched_conflict=enriched_conflict) - expected_keep_branch_value = ( - expected_keep_branch.value if isinstance(expected_keep_branch, Enum) else expected_keep_branch + all_data_checks = [] + for pc in proposed_changes: + core_data_checks = await self.conflict_recorder.record_conflicts( + proposed_change_id=pc.get_id(), conflicts=data_conflicts ) - if core_data_check.keep_branch.value != expected_keep_branch_value: # type: ignore[attr-defined] - core_data_check.keep_branch.value = expected_keep_branch_value # type: ignore[attr-defined] - await core_data_check.save(db=self.db) - return core_data_checks + all_data_checks.extend(core_data_checks) + core_data_checks_by_id = {cdc.enriched_conflict_id.value: cdc for cdc in core_data_checks} # type: ignore[attr-defined] + enriched_conflicts_by_id = {ec.uuid: ec for ec in enriched_conflicts} + for conflict_id, core_data_check in core_data_checks_by_id.items(): + enriched_conflict = enriched_conflicts_by_id.get(conflict_id) + if not enriched_conflict: + continue + expected_keep_branch = self._get_keep_branch_for_enriched_conflict(enriched_conflict=enriched_conflict) + expected_keep_branch_value = ( + expected_keep_branch.value if isinstance(expected_keep_branch, Enum) else expected_keep_branch + ) + if core_data_check.keep_branch.value != expected_keep_branch_value: # type: ignore[attr-defined] + core_data_check.keep_branch.value = expected_keep_branch_value # type: ignore[attr-defined] + await core_data_check.save(db=self.db) + return all_data_checks def _get_keep_branch_for_enriched_conflict( self, enriched_conflict: EnrichedDiffConflict diff --git a/backend/infrahub/core/diff/enricher/cardinality_one.py b/backend/infrahub/core/diff/enricher/cardinality_one.py index 9704cc9489..b3230ecfd3 100644 --- a/backend/infrahub/core/diff/enricher/cardinality_one.py +++ b/backend/infrahub/core/diff/enricher/cardinality_one.py @@ -33,34 +33,22 @@ def __init__(self, db: InfrahubDatabase): self._node_schema_map: dict[str, MainSchemaTypes] = {} async def enrich(self, enriched_diff_root: EnrichedDiffRoot, calculated_diffs: CalculatedDiffs) -> None: + self._node_schema_map = {} for diff_node in enriched_diff_root.nodes: for relationship_group in diff_node.relationships: if ( - self.is_cardinality_one( - node_kind=diff_node.kind, - relationship_name=relationship_group.name, - diff_branch_name=enriched_diff_root.diff_branch_name, - ) + relationship_group.cardinality is RelationshipCardinality.ONE and len(relationship_group.relationships) > 0 ): self.consolidate_cardinality_one_diff_elements(diff_relationship=relationship_group) - def is_cardinality_one(self, node_kind: str, relationship_name: str, diff_branch_name: str) -> bool: - if node_kind not in self._node_schema_map: - self._node_schema_map[node_kind] = self.db.schema.get( - name=node_kind, branch=diff_branch_name, duplicate=False - ) - node_schema = self._node_schema_map[node_kind] - relationship_schema = node_schema.get_relationship(name=relationship_name) - return relationship_schema.cardinality == RelationshipCardinality.ONE - def _determine_action(self, previous_value: Any, new_value: Any) -> DiffAction: if previous_value == new_value: return DiffAction.UNCHANGED if previous_value in (None, "NULL"): return DiffAction.ADDED if new_value in (None, "NULL"): - return DiffAction.ADDED + return DiffAction.REMOVED return DiffAction.UPDATED def _build_property_maps( diff --git a/backend/infrahub/core/diff/enricher/hierarchy.py b/backend/infrahub/core/diff/enricher/hierarchy.py index af3523638f..79c6ba44c0 100644 --- a/backend/infrahub/core/diff/enricher/hierarchy.py +++ b/backend/infrahub/core/diff/enricher/hierarchy.py @@ -92,6 +92,7 @@ async def _enrich_hierarchical_nodes( parent_kind=ancestor.kind, parent_label="", parent_rel_name=parent_rel.name, + parent_rel_cardinality=parent_rel.cardinality, parent_rel_label=parent_rel.label or "", ) @@ -149,6 +150,7 @@ async def _enrich_nodes_with_parent( parent_kind=peer_parent.peer_kind, parent_label="", parent_rel_name=parent_rel.name, + parent_rel_cardinality=parent_rel.cardinality, parent_rel_label=parent_rel.label or "", ) diff --git a/backend/infrahub/core/diff/enricher/labels.py b/backend/infrahub/core/diff/enricher/labels.py index 2763c81312..c228fb44e3 100644 --- a/backend/infrahub/core/diff/enricher/labels.py +++ b/backend/infrahub/core/diff/enricher/labels.py @@ -1,45 +1,206 @@ from collections import defaultdict +from dataclasses import dataclass +from typing import Generator +from infrahub.core.constants import DiffAction +from infrahub.core.constants.database import DatabaseEdgeType +from infrahub.core.query.node import NodeGetKindQuery from infrahub.database import InfrahubDatabase -from ..model.path import CalculatedDiffs, EnrichedDiffRoot +from ..model.path import ( + CalculatedDiffs, + EnrichedDiffConflict, + EnrichedDiffProperty, + EnrichedDiffRelationship, + EnrichedDiffRoot, +) from ..payload_builder import get_display_labels from .interface import DiffEnricherInterface +PROPERTY_TYPES_WITH_LABELS = {DatabaseEdgeType.IS_RELATED, DatabaseEdgeType.HAS_OWNER, DatabaseEdgeType.HAS_SOURCE} + + +@dataclass +class DisplayLabelRequest: + node_id: str + branch_name: str + + def __hash__(self) -> int: + return hash(f"{self.node_id}-{self.branch_name}") + class DiffLabelsEnricher(DiffEnricherInterface): """Add display labels for nodes and labels for relationships""" def __init__(self, db: InfrahubDatabase): self.db = db + self._base_branch_name: str | None = None + self._diff_branch_name: str | None = None + self._conflicts_only = False + + @property + def base_branch_name(self) -> str: + if not self._base_branch_name: + raise RuntimeError("could not identify base branch") + return self._base_branch_name - async def enrich(self, enriched_diff_root: EnrichedDiffRoot, calculated_diffs: CalculatedDiffs) -> None: - node_kind_map = defaultdict(list) - diff_branch_name = enriched_diff_root.diff_branch_name + @property + def diff_branch_name(self) -> str: + if not self._diff_branch_name: + raise RuntimeError("could not identify diff branch") + return self._diff_branch_name + + def _get_branch_for_action(self, action: DiffAction) -> str: + if action is DiffAction.REMOVED: + return self.base_branch_name + return self.diff_branch_name + + def _nodes_iterator(self, enriched_diff_root: EnrichedDiffRoot) -> Generator[DisplayLabelRequest, str | None, None]: for node in enriched_diff_root.nodes: - node_kind_map[node.kind].append(node.uuid) + if not self._conflicts_only: + branch_name = self._get_branch_for_action(action=node.action) + label = yield DisplayLabelRequest(node_id=node.uuid, branch_name=branch_name) + if label: + node.label = label + for attribute_diff in node.attributes: + for property_diff in attribute_diff.properties: + property_iterator = self._property_iterator(property_diff=property_diff) + try: + label = None + while True: + display_label_request = property_iterator.send(label) + label = yield display_label_request + except StopIteration: + ... + for relationship_diff in node.relationships: + relationship_iterator = self._relationship_iterator(relationship_diff=relationship_diff) + try: + label = None + while True: + display_label_request = relationship_iterator.send(label) + label = yield display_label_request + except StopIteration: + ... + + def _relationship_iterator( + self, relationship_diff: EnrichedDiffRelationship + ) -> Generator[DisplayLabelRequest, str | None, None]: + for element_diff in relationship_diff.relationships: + if not self._conflicts_only: + branch_name = self._get_branch_for_action(action=element_diff.action) + peer_label = yield DisplayLabelRequest(node_id=element_diff.peer_id, branch_name=branch_name) + if peer_label: + element_diff.peer_label = peer_label + if element_diff.conflict: + conflict_iterator = self._conflict_iterator(conflict_diff=element_diff.conflict) + label = None + try: + while True: + display_label_request = conflict_iterator.send(label) + label = yield display_label_request + except StopIteration: + ... + for property_diff in element_diff.properties: + property_iterator = self._property_iterator(property_diff=property_diff) + label = None + try: + while True: + display_label_request = property_iterator.send(label) + label = yield display_label_request + except StopIteration: + ... + + def _property_iterator( + self, property_diff: EnrichedDiffProperty + ) -> Generator[DisplayLabelRequest, str | None, None]: + if property_diff.property_type in PROPERTY_TYPES_WITH_LABELS: + if property_diff.previous_value and not self._conflicts_only: + label = yield DisplayLabelRequest( + node_id=property_diff.previous_value, branch_name=self.base_branch_name + ) + if label: + property_diff.previous_label = label + if property_diff.new_value and not self._conflicts_only: + label = yield DisplayLabelRequest(node_id=property_diff.new_value, branch_name=self.diff_branch_name) + if label: + property_diff.new_label = label + if property_diff.conflict: + conflict_iterator = self._conflict_iterator(conflict_diff=property_diff.conflict) + label = None + try: + while True: + display_label_request = conflict_iterator.send(label) + label = yield display_label_request + except StopIteration: + ... + + def _conflict_iterator( + self, conflict_diff: EnrichedDiffConflict + ) -> Generator[DisplayLabelRequest, str | None, None]: + if conflict_diff.base_branch_value: + label = yield DisplayLabelRequest( + node_id=conflict_diff.base_branch_value, branch_name=self.base_branch_name + ) + if label: + conflict_diff.base_branch_label = label + if conflict_diff.diff_branch_value: + label = yield DisplayLabelRequest( + node_id=conflict_diff.diff_branch_value, branch_name=self.diff_branch_name + ) + if label: + conflict_diff.diff_branch_label = label + + def _update_relationship_labels(self, enriched_diff: EnrichedDiffRoot) -> None: + for node in enriched_diff.nodes: if not node.relationships: continue - node_schema = self.db.schema.get(name=node.kind, branch=diff_branch_name, duplicate=False) + node_schema = self.db.schema.get(name=node.kind, branch=self.diff_branch_name, duplicate=False) for relationship_diff in node.relationships: relationship_schema = node_schema.get_relationship(name=relationship_diff.name) relationship_diff.label = relationship_schema.label or "" - peer_kind = relationship_schema.peer - for element in relationship_diff.relationships: - node_kind_map[peer_kind].append(element.peer_id) - display_label_map = await get_display_labels(db=self.db, nodes={diff_branch_name: node_kind_map}) - for node in enriched_diff_root.nodes: + async def _get_display_label_map( + self, display_label_requests: set[DisplayLabelRequest] + ) -> dict[str, dict[str, str]]: + node_ids = [dlr.node_id for dlr in display_label_requests] + query = await NodeGetKindQuery.init(db=self.db, ids=node_ids) + await query.execute(db=self.db) + node_kind_map = await query.get_node_kind_map() + display_label_request_map: dict[str, dict[str, list[str]]] = defaultdict(dict) + for dlr in display_label_requests: try: - display_label = display_label_map[diff_branch_name][node.uuid] + node_kind = node_kind_map[dlr.node_id] except KeyError: - display_label = None - if display_label: - node.label = display_label + continue + branch_map = display_label_request_map[dlr.branch_name] + if node_kind not in branch_map: + branch_map[node_kind] = [] + branch_map[node_kind].append(dlr.node_id) + return await get_display_labels(db=self.db, nodes=display_label_request_map) - for relationship_diff in node.relationships: - for element in relationship_diff.relationships: - try: - element.peer_label = display_label_map[diff_branch_name][element.peer_id] - except KeyError: - pass + async def enrich( + self, + enriched_diff_root: EnrichedDiffRoot, + calculated_diffs: CalculatedDiffs | None = None, + conflicts_only: bool = False, + ) -> None: + self._base_branch_name = enriched_diff_root.base_branch_name + self._diff_branch_name = enriched_diff_root.diff_branch_name + self._conflicts_only = conflicts_only + display_label_requests = set(self._nodes_iterator(enriched_diff_root=enriched_diff_root)) + display_label_map = await self._get_display_label_map(display_label_requests=display_label_requests) + + # iterate through all the labels in this diff again and set them, if possible + nodes_iterator = self._nodes_iterator(enriched_diff_root=enriched_diff_root) + try: + display_label_request = next(nodes_iterator) + while display_label_request: + try: + display_label = display_label_map[display_label_request.branch_name][display_label_request.node_id] + except KeyError: + display_label = None + display_label_request = nodes_iterator.send(display_label) + except StopIteration: + ... + + self._update_relationship_labels(enriched_diff=enriched_diff_root) diff --git a/backend/infrahub/core/diff/enricher/path_identifier.py b/backend/infrahub/core/diff/enricher/path_identifier.py index 3d24d7a4fe..dac3e07fe6 100644 --- a/backend/infrahub/core/diff/enricher/path_identifier.py +++ b/backend/infrahub/core/diff/enricher/path_identifier.py @@ -63,8 +63,8 @@ async def enrich(self, enriched_diff_root: EnrichedDiffRoot, calculated_diffs: C for relationship_element in relationship.relationships: relationship_element_path = relationship_path.model_copy() relationship_element_path.peer_id = relationship_element.peer_id - relationship_element.path_identifier = relationship_element_path.get_path() + relationship_element.path_identifier = relationship_element_path.get_path(with_peer=False) for relationship_property in relationship_element.properties: relationship_property_path = relationship_element_path.model_copy() relationship_property_path.property_name = relationship_property.property_type.value - relationship_property.path_identifier = relationship_property_path.get_path() + relationship_property.path_identifier = relationship_property_path.get_path(with_peer=False) diff --git a/backend/infrahub/core/diff/enricher/summary_counts.py b/backend/infrahub/core/diff/enricher/summary_counts.py index a692c02225..cf53359323 100644 --- a/backend/infrahub/core/diff/enricher/summary_counts.py +++ b/backend/infrahub/core/diff/enricher/summary_counts.py @@ -82,8 +82,12 @@ def _add_relationship_summaries(self, diff_relationship: EnrichedDiffRelationshi return contains_conflict def _add_element_summaries(self, diff_element: EnrichedDiffSingleRelationship) -> bool: - contains_conflict = False - num_conflicts = 0 + if diff_element.conflict is None: + contains_conflict = False + num_conflicts = 0 + else: + contains_conflict = True + num_conflicts = 1 for diff_prop in diff_element.properties: if diff_prop.conflict: num_conflicts += 1 diff --git a/backend/infrahub/core/diff/model/path.py b/backend/infrahub/core/diff/model/path.py index 12464eca75..9a15427e7d 100644 --- a/backend/infrahub/core/diff/model/path.py +++ b/backend/infrahub/core/diff/model/path.py @@ -2,10 +2,10 @@ from dataclasses import dataclass, field, replace from enum import Enum -from typing import TYPE_CHECKING, Any, Optional, Self +from typing import TYPE_CHECKING, Any, Optional from uuid import uuid4 -from infrahub.core.constants import DiffAction, RelationshipDirection, RelationshipStatus +from infrahub.core.constants import DiffAction, RelationshipCardinality, RelationshipDirection, RelationshipStatus from infrahub.core.constants.database import DatabaseEdgeType from infrahub.core.timestamp import Timestamp @@ -51,6 +51,12 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash(self.serialize()) + def __str__(self) -> str: + return self.serialize() + + def __repr__(self) -> str: + return f"{self.__class__.__name__} ({self.serialize()})" + class BranchTrackingId(TrackingId): prefix = "branch" @@ -85,9 +91,11 @@ class ConflictSelection(Enum): class EnrichedDiffConflict: uuid: str base_branch_action: DiffAction - base_branch_value: Any + base_branch_value: str | None diff_branch_action: DiffAction - diff_branch_value: Any + diff_branch_value: str | None + base_branch_label: str | None = field(default=None, kw_only=True) + diff_branch_label: str | None = field(default=None, kw_only=True) base_branch_changed_at: Timestamp | None = field(default=None, kw_only=True) diff_branch_changed_at: Timestamp | None = field(default=None, kw_only=True) selected_branch: ConflictSelection | None = field(default=None) @@ -97,9 +105,11 @@ class EnrichedDiffConflict: class EnrichedDiffProperty: property_type: DatabaseEdgeType changed_at: Timestamp - previous_value: Any - new_value: Any + previous_value: str | None + new_value: str | None action: DiffAction + previous_label: str | None = field(default=None, kw_only=True) + new_label: str | None = field(default=None, kw_only=True) path_identifier: str = field(default="", kw_only=True) conflict: EnrichedDiffConflict | None = field(default=None) @@ -111,8 +121,10 @@ def from_calculated_property(cls, calculated_property: DiffProperty) -> Enriched return EnrichedDiffProperty( property_type=calculated_property.property_type, changed_at=calculated_property.changed_at, - previous_value=calculated_property.previous_value, - new_value=calculated_property.new_value, + previous_value=str(calculated_property.previous_value) + if calculated_property.previous_value is not None + else None, + new_value=str(calculated_property.new_value) if calculated_property.new_value is not None else None, action=calculated_property.action, ) @@ -187,6 +199,7 @@ def from_calculated_element(cls, calculated_element: DiffSingleRelationship) -> class EnrichedDiffRelationship(BaseSummary): name: str label: str + cardinality: RelationshipCardinality path_identifier: str = field(default="", kw_only=True) changed_at: Timestamp | None = field(default=None, kw_only=True) action: DiffAction @@ -213,6 +226,7 @@ def from_calculated_relationship(cls, calculated_relationship: DiffRelationship) return EnrichedDiffRelationship( name=calculated_relationship.name, label="", + cardinality=calculated_relationship.cardinality, changed_at=calculated_relationship.changed_at, action=calculated_relationship.action, relationships={ @@ -222,22 +236,6 @@ def from_calculated_relationship(cls, calculated_relationship: DiffRelationship) nodes=set(), ) - @classmethod - def from_graph(cls, node: Neo4jNode) -> Self: - timestamp_str = node.get("changed_at") - return cls( - name=node.get("name"), - label=node.get("label"), - changed_at=Timestamp(timestamp_str) if timestamp_str else None, - action=DiffAction(str(node.get("action"))), - path_identifier=str(node.get("path_identifier")), - num_added=int(node.get("num_added")), - num_conflicts=int(node.get("num_conflicts")), - num_removed=int(node.get("num_removed")), - num_updated=int(node.get("num_updated")), - contains_conflict=str(node.get("contains_conflict")).lower() == "true", - ) - @dataclass class ParentNodeInfo: @@ -351,14 +349,6 @@ def from_calculated_node(cls, calculated_node: DiffNode) -> EnrichedDiffNode: }, ) - def add_relationship_from_DiffRelationship(self, diff_rel: Neo4jNode) -> bool: - if self.has_relationship(name=diff_rel.get("name")): - return False - - rel = EnrichedDiffRelationship.from_graph(node=diff_rel) - self.relationships.add(rel) - return True - @dataclass class EnrichedDiffRoot(BaseSummary): @@ -417,6 +407,7 @@ def add_parent( parent_kind: str, parent_label: str, parent_rel_name: str, + parent_rel_cardinality: RelationshipCardinality, parent_rel_label: str = "", ) -> EnrichedDiffNode: node = self.get_node(node_uuid=node_id) @@ -441,6 +432,7 @@ def add_parent( EnrichedDiffRelationship( name=parent_rel_name, label=parent_rel_label, + cardinality=parent_rel_cardinality, changed_at=None, action=DiffAction.UNCHANGED, nodes={parent}, @@ -510,6 +502,7 @@ class DiffSingleRelationship: @dataclass class DiffRelationship: name: str + cardinality: RelationshipCardinality changed_at: Timestamp action: DiffAction relationships: list[DiffSingleRelationship] = field(default_factory=list) diff --git a/backend/infrahub/core/diff/query/diff_summary.py b/backend/infrahub/core/diff/query/diff_summary.py index 0e0c69cb9e..91a91d81e4 100644 --- a/backend/infrahub/core/diff/query/diff_summary.py +++ b/backend/infrahub/core/diff/query/diff_summary.py @@ -1,6 +1,6 @@ from typing import Any -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from typing_extensions import Self from infrahub.core.query import Query, QueryResult, QueryType @@ -14,11 +14,14 @@ # type: ignore[call-overload] class DiffSummaryCounters(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) num_added: int = 0 num_updated: int = 0 num_unchanged: int = 0 num_removed: int = 0 num_conflicts: int = 0 + from_time: Timestamp + to_time: Timestamp @classmethod def from_graph(cls, result: QueryResult) -> Self: @@ -28,6 +31,8 @@ def from_graph(cls, result: QueryResult) -> Self: num_unchanged=int(result.get_as_str("num_unchanged") or 0), num_removed=int(result.get_as_str("num_removed") or 0), num_conflicts=int(result.get_as_str("num_conflicts") or 0), + from_time=Timestamp(result.get_as_str("from_time")), + to_time=Timestamp(result.get_as_str("to_time")), ) @@ -85,10 +90,15 @@ async def query_init(self, db: InfrahubDatabase, **kwargs: Any) -> None: "SUM(diff_node.num_unchanged) as num_unchanged", "SUM(diff_node.num_removed) as num_removed", "SUM(diff_node.num_conflicts) as num_conflicts", + "min(diff_root.from_time) as from_time", + "max(diff_root.to_time) as to_time", + "count(diff_root) as num_roots", ] - def get_summary(self) -> DiffSummaryCounters: + def get_summary(self) -> DiffSummaryCounters | None: result = self.get_result() if not result: - return DiffSummaryCounters() + return None + if result.get("num_roots") == 0: + return None return DiffSummaryCounters.from_graph(result) diff --git a/backend/infrahub/core/diff/query/save_query.py b/backend/infrahub/core/diff/query/save_query.py index ca2ea59ead..cd35611fed 100644 --- a/backend/infrahub/core/diff/query/save_query.py +++ b/backend/infrahub/core/diff/query/save_query.py @@ -119,11 +119,13 @@ def _build_conflict_params(self, enriched_conflict: EnrichedDiffConflict) -> dic "base_branch_changed_at": enriched_conflict.base_branch_changed_at.to_string() if enriched_conflict.base_branch_changed_at else None, + "base_branch_label": enriched_conflict.base_branch_label, "diff_branch_action": enriched_conflict.diff_branch_action.value, "diff_branch_value": enriched_conflict.diff_branch_value, "diff_branch_changed_at": enriched_conflict.diff_branch_changed_at.to_string() if enriched_conflict.diff_branch_changed_at else None, + "diff_branch_label": enriched_conflict.diff_branch_label, "selected_branch": enriched_conflict.selected_branch.value if enriched_conflict.selected_branch else None, } @@ -137,6 +139,8 @@ def _build_diff_property_params(self, enriched_property: EnrichedDiffProperty) - "changed_at": enriched_property.changed_at.to_string(), "previous_value": enriched_property.previous_value, "new_value": enriched_property.new_value, + "previous_label": enriched_property.previous_label, + "new_label": enriched_property.new_label, "action": enriched_property.action, "path_identifier": enriched_property.path_identifier, }, @@ -197,6 +201,7 @@ def _build_diff_relationship_params(self, enriched_relationship: EnrichedDiffRel "node_properties": { "name": enriched_relationship.name, "label": enriched_relationship.label, + "cardinality": enriched_relationship.cardinality.value, "changed_at": enriched_relationship.changed_at.to_string() if enriched_relationship.changed_at else None, diff --git a/backend/infrahub/core/diff/query_parser.py b/backend/infrahub/core/diff/query_parser.py index c8d2b66c7d..12a6c63a2a 100644 --- a/backend/infrahub/core/diff/query_parser.py +++ b/backend/infrahub/core/diff/query_parser.py @@ -224,7 +224,7 @@ def _get_single_relationship_final_property( changed_at = chronological_properties[-1].changed_at previous_value = None first_diff_prop = chronological_properties[0] - if first_diff_prop.changed_at < from_time and first_diff_prop.status is not RelationshipStatus.DELETED: + if first_diff_prop.status is RelationshipStatus.DELETED or first_diff_prop.changed_at < from_time: previous_value = first_diff_prop.value new_value = None last_diff_prop = chronological_properties[-1] @@ -268,6 +268,14 @@ def get_final_single_relationship(self, from_time: Timestamp) -> DiffSingleRelat final_properties = [peer_final_property] + other_final_properties last_changed_property = max(final_properties, key=lambda fp: fp.changed_at) last_changed_at = last_changed_property.changed_at + # handle case when peer has been deleted on another branch, but the properties of the relationship are updated + if ( + last_changed_property.property_type is not DatabaseEdgeType.IS_RELATED + and any(p.action is not DiffAction.REMOVED for p in other_final_properties) + and peer_final_property.action is DiffAction.REMOVED + ): + peer_final_property.action = DiffAction.UNCHANGED + peer_final_property.new_value = peer_id if last_changed_at < from_time: action = DiffAction.UNCHANGED elif peer_final_property.action in (DiffAction.ADDED, DiffAction.REMOVED): @@ -331,7 +339,11 @@ def to_diff_relationship(self, from_time: Timestamp) -> DiffRelationship: ): action = single_relationships[0].action return DiffRelationship( - name=self.name, changed_at=last_changed_at, action=action, relationships=single_relationships + name=self.name, + changed_at=last_changed_at, + action=action, + relationships=single_relationships, + cardinality=self.cardinality, ) @@ -539,20 +551,35 @@ def _apply_relationship_previous_values( DatabaseEdgeType(p.property_type): None for p in property_set } base_diff_property_by_type[DatabaseEdgeType.IS_RELATED] = None + latest_diff_property_times_by_type: dict[DatabaseEdgeType, Timestamp] = {} + for diff_property in property_set: + if ( + diff_property.property_type not in latest_diff_property_times_by_type + or diff_property.changed_at > latest_diff_property_times_by_type[diff_property.property_type] + ): + latest_diff_property_times_by_type[diff_property.property_type] = diff_property.changed_at + for base_diff_property in base_property_set: prop_type = DatabaseEdgeType(base_diff_property.property_type) - if prop_type not in base_diff_property_by_type: - continue - if base_diff_property.changed_at >= self.from_time: - continue if ( - base_diff_property_by_type[prop_type] is None - or base_diff_property.changed_at < base_diff_property_by_type[prop_type] + prop_type not in base_diff_property_by_type + or ( + base_diff_property.status is RelationshipStatus.ACTIVE + and base_diff_property.changed_at >= self.from_time + ) + or ( + prop_type in latest_diff_property_times_by_type + and base_diff_property.changed_at > latest_diff_property_times_by_type[prop_type] + ) ): + continue + if base_diff_property_by_type[prop_type] is None: + base_diff_property_by_type[prop_type] = base_diff_property + continue + current_property = base_diff_property_by_type.get(prop_type) + if current_property and base_diff_property.changed_at < current_property.changed_at: base_diff_property_by_type[prop_type] = base_diff_property - for diff_property in base_diff_property_by_type.values(): - if diff_property: - property_set.add(diff_property) + property_set.update({bdp for bdp in base_diff_property_by_type.values() if bdp}) def _remove_empty_base_diff_root(self) -> None: base_diff_root = self._diff_root_by_branch.get(self.base_branch_name) diff --git a/backend/infrahub/core/diff/repository/deserializer.py b/backend/infrahub/core/diff/repository/deserializer.py index ff75c8198e..ef00d047ca 100644 --- a/backend/infrahub/core/diff/repository/deserializer.py +++ b/backend/infrahub/core/diff/repository/deserializer.py @@ -3,7 +3,7 @@ from neo4j.graph import Node as Neo4jNode from neo4j.graph import Path as Neo4jPath -from infrahub.core.constants import DiffAction +from infrahub.core.constants import DiffAction, RelationshipCardinality from infrahub.core.constants.database import DatabaseEdgeType from infrahub.core.query import QueryResult from infrahub.core.timestamp import Timestamp @@ -89,15 +89,14 @@ def _deserialize_relationships( ) -> None: for relationship_result in result.get_nested_node_collection("diff_relationships"): group_node, element_node, element_conflict, property_node, property_conflict = relationship_result - if group_node is not None and element_node is None and property_node is None: - enriched_node.add_relationship_from_DiffRelationship(diff_rel=group_node) - continue - if group_node is None or element_node is None or property_node is None: + enriched_relationship_group = None + if group_node: + enriched_relationship_group = self._deserialize_diff_relationship_group( + relationship_group_node=group_node, enriched_root=enriched_root, enriched_node=enriched_node + ) + if element_node is None or property_node is None or enriched_relationship_group is None: continue - enriched_relationship_group = self._deserialize_diff_relationship_group( - relationship_group_node=group_node, enriched_root=enriched_root, enriched_node=enriched_node - ) enriched_relationship_element = self._deserialize_diff_relationship_element( relationship_element_node=element_node, enriched_relationship_group=enriched_relationship_group, @@ -137,6 +136,7 @@ def _deserialize_parents(self, result: QueryResult, enriched_root: EnrichedDiffR parent_kind=parent.get("kind"), parent_label=parent.get("label"), parent_rel_name=rel.get("name"), + parent_rel_cardinality=RelationshipCardinality(rel.get("cardinality")), parent_rel_label=rel.get("label"), ) current_node_uuid = parent.get("uuid") @@ -232,7 +232,21 @@ def _deserialize_diff_relationship_group( if rel_key in self._diff_node_rel_group_map: return self._diff_node_rel_group_map[rel_key] - enriched_relationship = EnrichedDiffRelationship.from_graph(node=relationship_group_node) + timestamp_str = relationship_group_node.get("changed_at") + enriched_relationship = EnrichedDiffRelationship( + name=relationship_group_node.get("name"), + label=relationship_group_node.get("label"), + cardinality=RelationshipCardinality(relationship_group_node.get("cardinality")), + changed_at=Timestamp(timestamp_str) if timestamp_str else None, + action=DiffAction(str(relationship_group_node.get("action"))), + path_identifier=str(relationship_group_node.get("path_identifier")), + num_added=int(relationship_group_node.get("num_added")), + num_conflicts=int(relationship_group_node.get("num_conflicts")), + num_removed=int(relationship_group_node.get("num_removed")), + num_updated=int(relationship_group_node.get("num_updated")), + contains_conflict=str(relationship_group_node.get("contains_conflict")).lower() == "true", + ) + self._diff_node_rel_group_map[rel_key] = enriched_relationship enriched_node.relationships.add(enriched_relationship) return enriched_relationship @@ -274,11 +288,15 @@ def _deserialize_diff_relationship_element( def _property_node_to_enriched_property(self, property_node: Neo4jNode) -> EnrichedDiffProperty: previous_value = self._get_str_or_none_property_value(node=property_node, property_name="previous_value") new_value = self._get_str_or_none_property_value(node=property_node, property_name="new_value") + previous_label = self._get_str_or_none_property_value(node=property_node, property_name="previous_label") + new_label = self._get_str_or_none_property_value(node=property_node, property_name="new_label") return EnrichedDiffProperty( property_type=DatabaseEdgeType(str(property_node.get("property_type"))), changed_at=Timestamp(str(property_node.get("changed_at"))), previous_value=previous_value, new_value=new_value, + previous_label=previous_label, + new_label=new_label, action=DiffAction(str(property_node.get("action"))), path_identifier=str(property_node.get("path_identifier")), ) @@ -331,6 +349,12 @@ def deserialize_conflict(self, diff_conflict_node: Neo4jNode) -> EnrichedDiffCon diff_branch_value = self._get_str_or_none_property_value( node=diff_conflict_node, property_name="diff_branch_value" ) + base_branch_label = self._get_str_or_none_property_value( + node=diff_conflict_node, property_name="base_branch_label" + ) + diff_branch_label = self._get_str_or_none_property_value( + node=diff_conflict_node, property_name="diff_branch_label" + ) base_timestamp_str = self._get_str_or_none_property_value( node=diff_conflict_node, property_name="base_branch_changed_at" ) @@ -343,8 +367,10 @@ def deserialize_conflict(self, diff_conflict_node: Neo4jNode) -> EnrichedDiffCon base_branch_action=DiffAction(str(diff_conflict_node.get("base_branch_action"))), base_branch_value=base_branch_value, base_branch_changed_at=Timestamp(base_timestamp_str) if base_timestamp_str else None, + base_branch_label=base_branch_label, diff_branch_action=DiffAction(str(diff_conflict_node.get("diff_branch_action"))), diff_branch_value=diff_branch_value, + diff_branch_label=diff_branch_label, diff_branch_changed_at=Timestamp(diff_timestamp_str) if diff_timestamp_str else None, selected_branch=ConflictSelection(selected_branch) if selected_branch else None, ) diff --git a/backend/infrahub/core/diff/repository/repository.py b/backend/infrahub/core/diff/repository/repository.py index 488636fbee..b0acd86d91 100644 --- a/backend/infrahub/core/diff/repository/repository.py +++ b/backend/infrahub/core/diff/repository/repository.py @@ -32,6 +32,7 @@ async def get( limit: int | None = None, offset: int | None = None, tracking_id: TrackingId | None = None, + include_empty: bool = False, ) -> list[EnrichedDiffRoot]: final_max_depth = config.SETTINGS.database.max_depth_search_hierarchy final_limit = limit or config.SETTINGS.database.query_size_limit @@ -51,7 +52,8 @@ async def get( diff_roots = await self.deserializer.deserialize( database_results=query.get_results(), include_parents=include_parents ) - diff_roots = [dr for dr in diff_roots if len(dr.nodes) > 0] + if not include_empty: + diff_roots = [dr for dr in diff_roots if len(dr.nodes) > 0] return diff_roots async def get_one( @@ -85,7 +87,7 @@ async def summary( from_time: Timestamp, to_time: Timestamp, filters: dict | None = None, - ) -> DiffSummaryCounters: + ) -> DiffSummaryCounters | None: query = await DiffSummaryQuery.init( db=self.db, base_branch_name=base_branch_name, diff --git a/backend/infrahub/core/initialization.py b/backend/infrahub/core/initialization.py index 328725954b..1c3d5b3b8a 100644 --- a/backend/infrahub/core/initialization.py +++ b/backend/infrahub/core/initialization.py @@ -10,6 +10,7 @@ from infrahub.core.node.ipam import BuiltinIPPrefix from infrahub.core.node.resource_manager.ip_address_pool import CoreIPAddressPool from infrahub.core.node.resource_manager.ip_prefix_pool import CoreIPPrefixPool +from infrahub.core.node.resource_manager.number_pool import CoreNumberPool from infrahub.core.root import Root from infrahub.core.schema import SchemaRoot, core_models, internal_schema from infrahub.core.schema_manager import SchemaManager @@ -79,6 +80,7 @@ async def initialize_registry(db: InfrahubDatabase, initialize: bool = False) -> registry.node[InfrahubKind.IPPREFIX] = BuiltinIPPrefix registry.node[InfrahubKind.IPADDRESSPOOL] = CoreIPAddressPool registry.node[InfrahubKind.IPPREFIXPOOL] = CoreIPPrefixPool + registry.node[InfrahubKind.NUMBERPOOL] = CoreNumberPool async def initialization(db: InfrahubDatabase) -> None: diff --git a/backend/infrahub/core/integrity/object_conflict/conflict_recorder.py b/backend/infrahub/core/integrity/object_conflict/conflict_recorder.py index 980b6fc3c3..39381b85f5 100644 --- a/backend/infrahub/core/integrity/object_conflict/conflict_recorder.py +++ b/backend/infrahub/core/integrity/object_conflict/conflict_recorder.py @@ -8,6 +8,7 @@ from infrahub.core.protocols import CoreProposedChange from infrahub.core.timestamp import Timestamp from infrahub.database import InfrahubDatabase +from infrahub.exceptions import NodeNotFoundError class ObjectConflictValidatorRecorder: @@ -17,15 +18,19 @@ def __init__(self, db: InfrahubDatabase, validator_kind: str, validator_label: s self.validator_label = validator_label self.check_schema_kind = check_schema_kind - async def record_conflicts(self, proposed_change_id: str, conflicts: Sequence[ObjectConflict]) -> list[Node]: - proposed_change = await NodeManager.get_one_by_id_or_default_filter( - id=proposed_change_id, kind=InfrahubKind.PROPOSEDCHANGE, db=self.db - ) + async def record_conflicts(self, proposed_change_id: str, conflicts: Sequence[ObjectConflict]) -> list[Node]: # pylint: disable=too-many-branches + try: + proposed_change = await NodeManager.get_one_by_id_or_default_filter( + id=proposed_change_id, kind=InfrahubKind.PROPOSEDCHANGE, db=self.db + ) + except NodeNotFoundError: + return [] proposed_change = cast(CoreProposedChange, proposed_change) validator = await self.get_or_create_validator(proposed_change) await self.initialize_validator(validator) previous_checks = await validator.checks.get_peers(db=self.db) # type: ignore[attr-defined] + previous_checks_by_conflict_id = {check.enriched_conflict_id.value: check for check in previous_checks.values()} is_success = False current_checks: list[Node] = [] @@ -56,9 +61,9 @@ async def record_conflicts(self, proposed_change_id: str, conflicts: Sequence[Ob for conflict in conflicts: conflicts_data = [conflict.to_conflict_dict()] conflict_obj = None - if conflict.conflict_id and conflict.conflict_id in previous_checks: - conflict_obj = previous_checks[conflict.conflict_id] - check_ids_to_keep.add(conflict.conflict_id) + if conflict.conflict_id and conflict.conflict_id in previous_checks_by_conflict_id: + conflict_obj = previous_checks_by_conflict_id[conflict.conflict_id] + check_ids_to_keep.add(conflict_obj.get_id()) if not conflict_obj: for previous_check in previous_checks.values(): if previous_check.conflicts.value == conflicts_data: # type: ignore[attr-defined] @@ -70,7 +75,7 @@ async def record_conflicts(self, proposed_change_id: str, conflicts: Sequence[Ob await conflict_obj.new( db=self.db, - id=conflict.conflict_id, + enriched_conflict_id=conflict.conflict_id, label=conflict.label, origin="internal", kind="DataIntegrity", diff --git a/backend/infrahub/core/manager.py b/backend/infrahub/core/manager.py index e53caf399b..a40d0cbc3e 100644 --- a/backend/infrahub/core/manager.py +++ b/backend/infrahub/core/manager.py @@ -198,6 +198,22 @@ async def query( node_schema = get_schema(db=db, branch=branch, node_schema=schema) + if filters and "hfid" in filters: + node = await cls.get_one_by_hfid( + db=db, + hfid=filters["hfid"], + kind=schema, + fields=fields, + at=at, + branch=branch, + include_source=include_source, + include_owner=include_owner, + prefetch_relationships=prefetch_relationships, + account=account, + branch_agnostic=branch_agnostic, + ) + return [node] if node else [] + # Query the list of nodes matching this Query query = await NodeGetListQuery.init( db=db, diff --git a/backend/infrahub/core/merge.py b/backend/infrahub/core/merge.py index 04d015f7b3..49ec1c5645 100644 --- a/backend/infrahub/core/merge.py +++ b/backend/infrahub/core/merge.py @@ -2,9 +2,10 @@ from typing import TYPE_CHECKING, Optional, Union -from infrahub.core.constants import DiffAction, InfrahubKind, RelationshipStatus, RepositoryAdminStatus +from infrahub.core.constants import DiffAction, RelationshipStatus, RepositoryInternalStatus from infrahub.core.manager import NodeManager from infrahub.core.models import SchemaBranchDiff +from infrahub.core.protocols import CoreRepository from infrahub.core.query.branch import ( AddNodeToBranch, ) @@ -22,7 +23,6 @@ if TYPE_CHECKING: from infrahub.core.branch import Branch from infrahub.core.models import SchemaUpdateConstraintInfo, SchemaUpdateMigrationInfo - from infrahub.core.protocols import CoreGenericRepository from infrahub.core.schema_manager import SchemaBranch, SchemaDiff from infrahub.database import InfrahubDatabase from infrahub.services import InfrahubServices @@ -434,31 +434,28 @@ async def merge_graph( # pylint: disable=too-many-branches,too-many-statements async def merge_repositories(self) -> None: # Collect all Repositories in Main because we'll need the commit in Main for each one. - repos_in_main_list: list[CoreGenericRepository] = await NodeManager.query( - schema=InfrahubKind.REPOSITORY, db=self.db - ) + repos_in_main_list = await NodeManager.query(schema=CoreRepository, db=self.db) repos_in_main = {repo.id: repo for repo in repos_in_main_list} - repos_in_branch_list: list[CoreGenericRepository] = await NodeManager.query( - schema=InfrahubKind.REPOSITORY, db=self.db, branch=self.source_branch - ) + repos_in_branch_list = await NodeManager.query(schema=CoreRepository, db=self.db, branch=self.source_branch) events = [] for repo in repos_in_branch_list: # Check if the repo, exist in main, if not ignore this repo if repo.id not in repos_in_main: continue - if repo.admin_status.value == RepositoryAdminStatus.INACTIVE.value: + if repo.internal_status.value == RepositoryInternalStatus.INACTIVE.value: continue - if self.source_branch.sync_with_git or repo.admin_status.value == RepositoryAdminStatus.STAGING.value: + if self.source_branch.sync_with_git or repo.internal_status.value == RepositoryInternalStatus.STAGING.value: events.append( messages.GitRepositoryMerge( repository_id=repo.id, - repository_name=repo.name.value, # type: ignore[attr-defined] - admin_status=repo.admin_status.value, + repository_name=repo.name.value, + internal_status=repo.internal_status.value, source_branch=self.source_branch.name, destination_branch=registry.default_branch, + default_branch=repo.default_branch.value, ) ) diff --git a/backend/infrahub/core/migrations/graph/m013_convert_git_password_credential.py b/backend/infrahub/core/migrations/graph/m013_convert_git_password_credential.py index 039a749604..67f2a32b32 100644 --- a/backend/infrahub/core/migrations/graph/m013_convert_git_password_credential.py +++ b/backend/infrahub/core/migrations/graph/m013_convert_git_password_credential.py @@ -8,7 +8,7 @@ BranchSupportType, InfrahubKind, RelationshipStatus, - RepositoryAdminStatus, + RepositoryInternalStatus, ) from infrahub.core.migrations.shared import MigrationResult from infrahub.core.query import Query, QueryType @@ -285,17 +285,17 @@ def __init__(self, **kwargs: Any): ) -class Migration013AddAdminStatusData(AttributeAddQuery): +class Migration013AddInternalStatusData(AttributeAddQuery): def __init__(self, **kwargs: Any): if "branch" in kwargs: del kwargs["branch"] super().__init__( node_kind="CoreGenericRepository", - attribute_name="admin_status", + attribute_name="internal_status", attribute_kind="Dropdown", branch_support=BranchSupportType.LOCAL.value, - default_value=RepositoryAdminStatus.ACTIVE.value, + default_value=RepositoryInternalStatus.ACTIVE.value, branch=default_branch, **kwargs, ) @@ -309,7 +309,7 @@ class Migration013(GraphMigration): Migration013DeleteUsernamePasswordGenericSchema, Migration013DeleteUsernamePasswordReadWriteSchema, Migration013DeleteUsernamePasswordReadOnlySchema, - Migration013AddAdminStatusData, + Migration013AddInternalStatusData, ] minimum_version: int = 12 diff --git a/backend/infrahub/core/node/__init__.py b/backend/infrahub/core/node/__init__.py index cfae109cb3..62cf2d64aa 100644 --- a/backend/infrahub/core/node/__init__.py +++ b/backend/infrahub/core/node/__init__.py @@ -7,12 +7,12 @@ from infrahub.core import registry from infrahub.core.constants import BranchSupportType, InfrahubKind, RelationshipCardinality +from infrahub.core.protocols import CoreNumberPool from infrahub.core.query.node import NodeCheckIDQuery, NodeCreateAllQuery, NodeDeleteQuery, NodeGetListQuery from infrahub.core.schema import AttributeSchema, NodeSchema, ProfileSchema, RelationshipSchema from infrahub.core.timestamp import Timestamp -from infrahub.exceptions import InitializationError, NodeNotFoundError, ValidationError +from infrahub.exceptions import InitializationError, NodeNotFoundError, PoolExhaustedError, ValidationError from infrahub.types import ATTRIBUTE_TYPES -from infrahub.utils import find_next_free from ..relationship import RelationshipManager from ..utils import update_relationships_to @@ -22,7 +22,6 @@ from typing_extensions import Self from infrahub.core.branch import Branch - from infrahub.core.protocols import CoreNumberPool from infrahub.database import InfrahubDatabase from ..attribute import BaseAttribute @@ -197,10 +196,9 @@ async def process_pool(self, db: InfrahubDatabase, attribute: BaseAttribute, err if not attribute.from_pool: return - number_pool: Optional[CoreNumberPool] = None try: number_pool = await registry.manager.get_one_by_id_or_default_filter( - db=db, id=attribute.from_pool, kind=InfrahubKind.NUMBERPOOL + db=db, id=attribute.from_pool["id"], kind=CoreNumberPool ) except NodeNotFoundError: errors.append( @@ -208,58 +206,27 @@ async def process_pool(self, db: InfrahubDatabase, attribute: BaseAttribute, err {f"{attribute.name}.from_pool": f"The pool requested {attribute.from_pool} was not found."} ) ) + return - if number_pool: - if number_pool.node.value == self._schema.kind and number_pool.node_attribute.value == attribute.name: - existing_nodes = await registry.manager.query(db=db, schema=self._schema, branch_agnostic=True) - - used_number_lookup_key: Optional[str] = None - used_numbers: dict[str, list[int]] = {} - - if not number_pool.unique_for.value: - # No uniqueness constraints so this should be a unique list - # None is used as key to make follow up code generic - used_numbers[None] = [ - getattr(existing_node, attribute.name).value for existing_node in existing_nodes - ] - else: - # Lookup existing values and sort them given the uniqueness field - for existing_node in existing_nodes: - # Get the relationship peer and use the peer ID as key - unique_for_value_peer = await getattr(existing_node, number_pool.unique_for.value).get_peer( - db=db - ) - used_number_set: list[int] = used_numbers.setdefault( - unique_for_value_peer.id if unique_for_value_peer else None, [] - ) - used_number_set.append(getattr(existing_node, attribute.name).value) - - unique_for_peer = await getattr(self, number_pool.unique_for.value).get_peer(db=db) - if unique_for_peer: - used_number_lookup_key = unique_for_peer.id - - next_free = find_next_free( - start=number_pool.start_range.value, - end=number_pool.end_range.value, - used_numbers=used_numbers.get(used_number_lookup_key, []), + if number_pool.node.value == self._schema.kind and number_pool.node_attribute.value == attribute.name: + try: + next_free = await number_pool.get_resource(db=db, branch=self._branch, node=self) + except PoolExhaustedError: + errors.append( + ValidationError({f"{attribute.name}.from_pool": f"The pool {number_pool.node.value} is exhausted."}) ) + return - if next_free: - attribute.value = next_free - else: - errors.append( - ValidationError( - {f"{attribute.name}.from_pool": f"The pool {number_pool.node.value} is exhausted."} - ) - ) - else: - errors.append( - ValidationError( - { - f"{attribute.name}.from_pool": f"The {number_pool.name.value} pool can't be used for '{attribute.name}'." - } - ) + attribute.value = next_free + attribute.source = number_pool.id + else: + errors.append( + ValidationError( + { + f"{attribute.name}.from_pool": f"The {number_pool.name.value} pool can't be used for '{attribute.name}'." + } ) + ) async def _process_fields(self, fields: dict, db: InfrahubDatabase) -> None: errors = [] @@ -335,7 +302,7 @@ async def _process_fields(self, fields: dict, db: InfrahubDatabase) -> None: db=db, name=attr_schema.name, schema=attr_schema, data=fields.get(attr_schema.name, None) ), ) - if not self.id: + if not self._existing: attribute: BaseAttribute = getattr(self, attr_schema.name) await self.process_pool(db=db, attribute=attribute, errors=errors) @@ -394,7 +361,7 @@ async def process_label(self, db: Optional[InfrahubDatabase] = None) -> None: # # If there label and name are both defined for this node # if label is not define, we'll automatically populate it with a human friendy vesion of name # pylint: disable=no-member - if not self.id and hasattr(self, "label") and hasattr(self, "name"): + if not self._existing and hasattr(self, "label") and hasattr(self, "name"): if self.label.value is None and self.name.value: self.label.value = " ".join([word.title() for word in self.name.value.split("_")]) self.label.is_default = False @@ -407,10 +374,10 @@ async def new(self, db: InfrahubDatabase, id: Optional[str] = None, **kwargs: An if await query.count(db=db): raise ValidationError({"id": f"{id} is already in use"}) - await self._process_fields(db=db, fields=kwargs) - self.id = id or str(UUIDT()) + await self._process_fields(db=db, fields=kwargs) + return self async def resolve_relationships(self, db: InfrahubDatabase) -> None: diff --git a/backend/infrahub/core/node/resource_manager/number_pool.py b/backend/infrahub/core/node/resource_manager/number_pool.py new file mode 100644 index 0000000000..7c63224442 --- /dev/null +++ b/backend/infrahub/core/node/resource_manager/number_pool.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from infrahub.core.query.resource_manager import NumberPoolGetReserved, NumberPoolGetUsed, NumberPoolSetReserved +from infrahub.exceptions import PoolExhaustedError + +from .. import Node + +if TYPE_CHECKING: + from infrahub.core.branch import Branch + from infrahub.database import InfrahubDatabase + + +class CoreNumberPool(Node): + async def get_resource( + self, + db: InfrahubDatabase, + branch: Branch, + node: Node, + identifier: Optional[str] = None, + ) -> int: + identifier = identifier or node.get_id() + # Check if there is already a resource allocated with this identifier + # if not, pull all existing prefixes and allocated the next available + # TODO add support for branch, if the node is reserved with this id in another branch we should return an error + query_get = await NumberPoolGetReserved.init(db=db, branch=branch, pool_id=self.id, identifier=identifier) + await query_get.execute(db=db) + reservation = query_get.get_reservation() + if reservation is not None: + return reservation + + # If we have not returned a value we need to find one if avaiable + number = await self.get_next(db=db, branch=branch) + + query_set = await NumberPoolSetReserved.init( + db=db, pool_id=self.get_id(), identifier=identifier, reserved=number + ) + await query_set.execute(db=db) + + return number + + async def get_next(self, db: InfrahubDatabase, branch: Branch) -> int: + query = await NumberPoolGetUsed.init(db=db, branch=branch, pool=self, branch_agnostic=True) + + await query.execute(db=db) + taken = [result.get_as_optional_type("av.value", return_type=int) for result in query.results] + next_number = find_next_free( + start=self.start_range.value, # type: ignore[attr-defined] + end=self.end_range.value, # type: ignore[attr-defined] + taken=taken, + ) + if next_number is None: + raise PoolExhaustedError("There are no more addresses available in this pool.") + + return next_number + + +def find_next_free(start: int, end: int, taken: list[int | None]) -> Optional[int]: + used_numbers = [number for number in taken if number is not None] + used_set = set(used_numbers) + + for num in range(start, end + 1): + if num not in used_set: + return num + + return None diff --git a/backend/infrahub/core/protocols.py b/backend/infrahub/core/protocols.py index 69f957f432..3138cc6dc8 100644 --- a/backend/infrahub/core/protocols.py +++ b/backend/infrahub/core/protocols.py @@ -105,7 +105,7 @@ class CoreGenericRepository(CoreNode): name: String description: StringOptional location: String - admin_status: Dropdown + internal_status: Dropdown operational_status: Dropdown sync_status: Dropdown credential: RelationshipManager @@ -261,6 +261,7 @@ class CoreCustomWebhook(CoreWebhook, CoreTaskTarget): class CoreDataCheck(CoreCheck): conflicts: JSONAttribute keep_branch: Enum + enriched_conflict_id: StringOptional class CoreDataValidator(CoreValidator): @@ -346,7 +347,6 @@ class CoreIPPrefixPool(CoreResourcePool, LineageSource): class CoreNumberPool(CoreResourcePool, LineageSource): node: String node_attribute: String - unique_for: StringOptional start_range: Integer end_range: Integer @@ -390,6 +390,7 @@ class CoreRepositoryValidator(CoreValidator): class CoreSchemaCheck(CoreCheck): conflicts: JSONAttribute + enriched_conflict_id: StringOptional class CoreSchemaValidator(CoreValidator): diff --git a/backend/infrahub/core/query/__init__.py b/backend/infrahub/core/query/__init__.py index 88bbafeac9..c2d4318398 100644 --- a/backend/infrahub/core/query/__init__.py +++ b/backend/infrahub/core/query/__init__.py @@ -4,7 +4,7 @@ from collections import defaultdict from dataclasses import dataclass, field from enum import Enum -from typing import TYPE_CHECKING, Any, Generator, Iterator, Optional, Union +from typing import TYPE_CHECKING, Any, Callable, Generator, Iterator, Optional, TypeVar, Union import ujson from neo4j.graph import Node as Neo4jNode @@ -24,6 +24,8 @@ from infrahub.core.branch import Branch from infrahub.database import InfrahubDatabase +RETURN_TYPE = TypeVar("RETURN_TYPE") + def sort_results_by_time(results: list[QueryResult], rel_label: str) -> list[QueryResult]: """Sort a list of QueryResult based on the to and from fields on given relationship. @@ -225,6 +227,27 @@ def get_as_str(self, label: str) -> Optional[str]: return str(item) return None + def get_as_optional_type(self, label: str, return_type: Callable[..., RETURN_TYPE]) -> Optional[RETURN_TYPE]: + """Return a label as a given type. + + For example if an integer is needed the caller would use: + .get_as_optional_type(label="name_of_label", return_type=int) + """ + item = self._get(label=label) + if item is not None: + return return_type(item) + return None + + def get_as_type(self, label: str, return_type: Callable[..., RETURN_TYPE]) -> RETURN_TYPE: + """Return a label as a given type. + + For example if an integer is needed the caller would use: + .get_as_type(label="name_of_label", return_type=int) + """ + item = self._get(label=label) + + return return_type(item) + def get_node_collection(self, label: str) -> list[Neo4jNode]: entry = self._get(label=label) if isinstance(entry, list): diff --git a/backend/infrahub/core/query/diff.py b/backend/infrahub/core/query/diff.py index 4a20672bf9..23ea3180b4 100644 --- a/backend/infrahub/core/query/diff.py +++ b/backend/infrahub/core/query/diff.py @@ -449,6 +449,57 @@ def get_results_by_id_and_prop_type(self, rel_id: str, prop_type: str) -> list[Q return sort_results_by_time(results, rel_label="r") +class DiffCountChanges(Query): + name: str = "diff_count_changes" + type: QueryType = QueryType.READ + + def __init__( + self, + branch_names: list[str], + diff_from: Timestamp, + diff_to: Timestamp, + **kwargs, + ): + self.branch_names = branch_names + self.diff_from = diff_from + self.diff_to = diff_to + super().__init__(**kwargs) + + async def query_init(self, db: InfrahubDatabase, **kwargs): + self.params = { + "from_time": self.diff_from.to_string(), + "to_time": self.diff_to.to_string(), + "branch_names": self.branch_names, + } + query = """ + MATCH (p)-[diff_rel]-(q) + WHERE any(l in labels(p) WHERE l in ["Node", "Attribute", "Relationship"]) + AND diff_rel.branch in $branch_names + AND ( + (diff_rel.from >= $from_time AND diff_rel.from <= $to_time) + OR (diff_rel.to >= $to_time AND diff_rel.to <= $to_time) + ) + AND (p.branch_support = "aware" OR q.branch_support = "aware") + WITH diff_rel.branch AS branch_name, count(*) AS num_changes + """ + self.add_to_query(query=query) + self.return_labels = ["branch_name", "num_changes"] + + def get_num_changes_by_branch(self) -> dict[str, int]: + branch_count_map = {} + for result in self.get_results(): + branch_name = str(result.get("branch_name")) + try: + count = int(result.get("num_changes")) + except (TypeError, ValueError): + count = 0 + branch_count_map[branch_name] = count + for branch_name in self.branch_names: + if branch_name not in branch_count_map: + branch_count_map[branch_name] = 0 + return branch_count_map + + class DiffAllPathsQuery(DiffQuery): """Gets the required Cypher paths for a diff""" @@ -514,6 +565,7 @@ async def query_init(self, db: InfrahubDatabase, **kwargs): self.params.update(br_params) self.params["branch_support"] = [item.value for item in self.branch_support] + # ruff: noqa: E501 query = """ // all updated edges for our branches and time frame MATCH (p)-[diff_rel]-(q) @@ -525,8 +577,8 @@ async def query_init(self, db: InfrahubDatabase, **kwargs): WITH p, diff_rel, q // -- DEEPEST EDGE SUBQUERY -- // get full path for every HAS_VALUE, HAS_SOURCE/OWNER, IS_VISIBLE/PROTECTED - // explicitly add in base branch path, if it exists to capture previous value - // explicitly add in far-side of any relationship to get peer_id for rel properties + // can be multiple paths in the case of HAS_SOURCE/OWNER, IS_VISIBLE/PROTECTED to include + // both peers in the relationship CALL { WITH p, q, diff_rel OPTIONAL MATCH path = ( @@ -538,19 +590,32 @@ async def query_init(self, db: InfrahubDatabase, **kwargs): AND any(l in labels(inner_q) WHERE l in ["Boolean", "Node", "AttributeValue"]) AND type(r_node) IN ["HAS_ATTRIBUTE", "IS_RELATED"] AND %(n_node_where)s - AND ID(n) <> ID(inner_q) + AND [ID(n), type(r_node)] <> [ID(inner_q), type(inner_diff_rel)] AND ALL( r in [r_root, r_node] - WHERE r.from <= $to_time AND (r.to IS NULL or r.to >= $to_time) - AND r.branch IN $branch_names + WHERE r.from <= $to_time AND r.branch IN $branch_names ) + // exclude paths where an active edge is below a deleted edge + AND (inner_diff_rel.status = "deleted" OR r_node.status = "active") + AND (r_node.status = "deleted" OR r_root.status = "active") WITH path AS diff_rel_path, diff_rel, r_root, n, r_node, p ORDER BY + ID(n) DESC, + ID(p) DESC, r_node.branch = diff_rel.branch DESC, r_root.branch = diff_rel.branch DESC, r_node.from DESC, r_root.from DESC - LIMIT 1 + WITH p, n, head(collect(diff_rel_path)) AS deepest_diff_path + RETURN deepest_diff_path + } + WITH p, diff_rel, q, deepest_diff_path + // explicitly add in base branch path, if it exists to capture previous value + // explicitly add in far-side of any relationship to get peer_id for rel properties + CALL { + WITH p, diff_rel, deepest_diff_path + WITH p, diff_rel, deepest_diff_path AS diff_rel_path, nodes(deepest_diff_path) AS drp_nodes, relationships(deepest_diff_path) AS drp_relationships + WITH p, diff_rel, diff_rel_path, drp_relationships[0] AS r_root, drp_nodes[1] AS n, drp_relationships[1] AS r_node // get base branch version of the diff path, if it exists WITH diff_rel_path, diff_rel, r_root, n, r_node, p OPTIONAL MATCH latest_base_path = (:Root)<-[r_root2]-(n2)-[r_node2]-(inner_p2)-[base_diff_rel]->(base_prop) @@ -560,9 +625,11 @@ async def query_init(self, db: InfrahubDatabase, **kwargs): AND type(base_diff_rel) = type(diff_rel) AND all( r in relationships(latest_base_path) - WHERE r.branch = $base_branch_name - AND r.from <= $from_time AND (r.to IS NULL or r.to <= $from_time) + WHERE r.branch = $base_branch_name AND r.from <= $from_time ) + // exclude paths where an active edge is below a deleted edge + AND (base_diff_rel.status = "deleted" OR r_node2.status = "active") + AND (r_node2.status = "deleted" OR r_root2.status = "active") WITH diff_rel_path, latest_base_path, diff_rel, r_root, n, r_node, p ORDER BY base_diff_rel.from DESC, r_node.from DESC, r_root.from DESC LIMIT 1 @@ -573,9 +640,12 @@ async def query_init(self, db: InfrahubDatabase, **kwargs): ) WHERE ID(r_root3) = ID(r_root) AND ID(n3) = ID(n) AND ID(r_node3) = ID(r_node) AND ID(inner_p3) = ID(p) AND type(diff_rel) <> "IS_RELATED" - AND ID(n3) <> ID(base_peer) - AND base_r_peer.from <= $from_time + AND [ID(n3), type(r_node3)] <> [ID(base_peer), type(base_r_peer)] + AND base_r_peer.from <= $to_time AND base_r_peer.branch IN $branch_names + // exclude paths where an active edge is below a deleted edge + AND (base_r_peer.status = "deleted" OR r_node3.status = "active") + AND (r_node3.status = "deleted" OR r_root3.status = "active") WITH diff_rel_path, latest_base_path, base_peer_path, base_r_peer, diff_rel ORDER BY base_r_peer.branch = diff_rel.branch DESC, base_r_peer.from DESC LIMIT 1 @@ -601,10 +671,12 @@ async def query_init(self, db: InfrahubDatabase, **kwargs): AND any(l in labels(prop) WHERE l in ["Boolean", "Node", "AttributeValue"]) AND ALL( r in [r_root, r_prop] - WHERE r.from <= $to_time AND (r.to IS NULL or r.to >= $to_time) - AND r.branch IN $branch_names + WHERE r.from <= $to_time AND r.branch IN $branch_names ) AND [ID(inner_p), type(inner_diff_rel)] <> [ID(prop), type(r_prop)] + // exclude paths where an active edge is below a deleted edge + AND (inner_diff_rel.status = "active" OR (r_prop.status = "deleted" AND inner_diff_rel.branch = r_prop.branch)) + AND (inner_diff_rel.status = "deleted" OR r_root.status = "active") WITH path, prop, r_prop, r_root ORDER BY ID(prop), @@ -632,10 +704,17 @@ async def query_init(self, db: InfrahubDatabase, **kwargs): AND any(l in labels(prop) WHERE l in ["Boolean", "Node", "AttributeValue"]) AND ALL( r in [r_node, r_prop] - WHERE r.from <= $to_time AND (r.to IS NULL or r.to >= $to_time) - AND r.branch IN $branch_names + WHERE r.from <= $to_time AND r.branch IN $branch_names ) AND [ID(inner_p), type(r_node)] <> [ID(prop), type(r_prop)] + // exclude paths where an active edge is below a deleted edge + AND (inner_diff_rel.status = "active" OR + ( + r_node.status = "deleted" AND inner_diff_rel.branch = r_node.branch + AND r_prop.status = "deleted" AND inner_diff_rel.branch = r_prop.branch + ) + ) + AND (r_prop.status = "deleted" OR r_node.status = "active") WITH path, node, prop, r_prop, r_node ORDER BY ID(node), diff --git a/backend/infrahub/core/query/node.py b/backend/infrahub/core/query/node.py index 5539f3ed05..1e6ae51565 100644 --- a/backend/infrahub/core/query/node.py +++ b/backend/infrahub/core/query/node.py @@ -595,6 +595,31 @@ def get_peers_group_by_node(self) -> dict[str, dict[str, list[str]]]: return peers_by_node +class NodeGetKindQuery(Query): + name: str = "node_get_kind_query" + type = QueryType.READ + + def __init__(self, ids: list[str], **kwargs: Any) -> None: + self.ids = ids + super().__init__(**kwargs) + + async def query_init(self, db: InfrahubDatabase, **kwargs: Any) -> None: + query = """ + MATCH p = (root:Root)<-[r_root:IS_PART_OF]-(n:Node) + WHERE n.uuid IN $ids + """ + self.add_to_query(query) + self.params["ids"] = self.ids + + self.return_labels = ["n.uuid AS node_id", "n.kind AS node_kind"] + + async def get_node_kind_map(self) -> dict[str, str]: + node_kind_map: dict[str, str] = {} + for result in self.get_results(): + node_kind_map[str(result.get("node_id"))] = str(result.get("node_kind")) + return node_kind_map + + class NodeListGetInfoQuery(Query): name: str = "node_list_get_info" @@ -1055,9 +1080,16 @@ def _add_final_filter(self, field_attribute_requirements: list[FieldAttributeReq var_name = f"final_attr_value{far.index}" self.params[var_name] = far.field_attr_comparison_value if self.partial_match: - where_parts.append( - f"toLower(toString({far.final_value_query_variable})) CONTAINS toLower(toString(${var_name}))" - ) + if isinstance(far.field_attr_comparison_value, list): + # If the any filter is an array/list + var_array = f"{var_name}_array" + where_parts.append( + f"any({var_array} IN ${var_name} WHERE toLower(toString({far.final_value_query_variable})) CONTAINS toLower({var_array}))" + ) + else: + where_parts.append( + f"toLower(toString({far.final_value_query_variable})) CONTAINS toLower(toString(${var_name}))" + ) continue where_parts.append(f"{far.final_value_query_variable} {far.comparison_operator} ${var_name}") diff --git a/backend/infrahub/core/query/resource_manager.py b/backend/infrahub/core/query/resource_manager.py index 8537274ca3..8488978e72 100644 --- a/backend/infrahub/core/query/resource_manager.py +++ b/backend/infrahub/core/query/resource_manager.py @@ -7,6 +7,7 @@ from infrahub.core.query import Query if TYPE_CHECKING: + from infrahub.core.protocols import CoreNumberPool from infrahub.database import InfrahubDatabase @@ -102,6 +103,164 @@ async def query_init(self, db: InfrahubDatabase, **kwargs: dict[str, Any]) -> No self.return_labels = ["pool", "rel", "address"] +class NumberPoolGetAllocated(Query): + name: str = "numberpool_get_allocated" + + def __init__( + self, + pool: CoreNumberPool, + **kwargs: dict[str, Any], + ) -> None: + self.pool = pool + + super().__init__(**kwargs) # type: ignore[arg-type] + + async def query_init(self, db: InfrahubDatabase, **kwargs: dict[str, Any]) -> None: + self.params["pool_id"] = self.pool.get_id() + self.params["node_attribute"] = self.pool.node_attribute.value + self.params["start_range"] = self.pool.start_range.value + self.params["end_range"] = self.pool.end_range.value + + self.params["time_at"] = self.at.to_string() + + def rel_filter(rel_name: str) -> str: + return f"{rel_name}.from <= $time_at AND ({rel_name}.to IS NULL OR {rel_name}.to >= $time_at)" + + query = f""" + MATCH (n:%(node)s)-[ha:HAS_ATTRIBUTE]-(a:Attribute {{name: $node_attribute}})-[hv:HAS_VALUE]-(av:AttributeValue) + MATCH (a)-[hs:HAS_SOURCE]-(pool:%(number_pool_kind)s) + WHERE + av.value >= $start_range and av.value <= $end_range + AND ({rel_filter("ha")}) + AND ({rel_filter("hv")}) + AND ({rel_filter("hs")}) + """ % { + "node": self.pool.node.value, + "number_pool_kind": InfrahubKind.NUMBERPOOL, + } + self.add_to_query(query) + + self.return_labels = ["n.uuid as id", "hv.branch as branch", "av.value as value"] + self.order_by = ["av.value"] + + +class NumberPoolGetReserved(Query): + name: str = "numberpool_get_reserved" + + def __init__( + self, + pool_id: str, + identifier: str, + **kwargs: dict[str, Any], + ) -> None: + self.pool_id = pool_id + self.identifier = identifier + + super().__init__(**kwargs) # type: ignore[arg-type] + + async def query_init(self, db: InfrahubDatabase, **kwargs: dict[str, Any]) -> None: + self.params["pool_id"] = self.pool_id + self.params["identifier"] = self.identifier + + branch_filter, branch_params = self.branch.get_query_filter_path( + at=self.at.to_string(), branch_agnostic=self.branch_agnostic + ) + + self.params.update(branch_params) + + query = """ + MATCH (pool:%(number_pool)s { uuid: $pool_id })-[r:IS_RESERVED]->(reservation:AttributeValue) + WHERE + r.identifier = $identifier + AND + %(branch_filter)s + """ % {"branch_filter": branch_filter, "number_pool": InfrahubKind.NUMBERPOOL} + self.add_to_query(query) + self.return_labels = ["reservation.value"] + + def get_reservation(self) -> int | None: + result = self.get_result() + if result: + return result.get_as_optional_type("reservation.value", return_type=int) + return None + + +class NumberPoolGetUsed(Query): + name: str = "number_pool_get_used" + + def __init__( + self, + pool: CoreNumberPool, + **kwargs: dict[str, Any], + ) -> None: + self.pool = pool + + super().__init__(**kwargs) # type: ignore[arg-type] + + async def query_init(self, db: InfrahubDatabase, **kwargs: dict[str, Any]) -> None: + self.params["pool_id"] = self.pool.get_id() + self.params["start_range"] = self.pool.start_range.value + self.params["end_range"] = self.pool.end_range.value + + branch_filter, branch_params = self.branch.get_query_filter_path( + at=self.at.to_string(), branch_agnostic=self.branch_agnostic + ) + + self.params.update(branch_params) + + query = """ + MATCH (pool:%(number_pool)s { uuid: $pool_id })-[r:IS_RESERVED]->(av:AttributeValue ) + WHERE + av.value >= $start_range and av.value <= $end_range + AND + %(branch_filter)s + """ % {"branch_filter": branch_filter, "number_pool": InfrahubKind.NUMBERPOOL} + + self.add_to_query(query) + self.return_labels = ["av.value"] + self.order_by = ["av.value"] + + +class NumberPoolSetReserved(Query): + name: str = "numberpool_set_reserved" + + def __init__( + self, + pool_id: str, + reserved: int, + identifier: str, + **kwargs: dict[str, Any], + ) -> None: + self.pool_id = pool_id + self.reserved = reserved + self.identifier = identifier + + super().__init__(**kwargs) # type: ignore[arg-type] + + async def query_init(self, db: InfrahubDatabase, **kwargs: dict[str, Any]) -> None: + self.params["pool_id"] = self.pool_id + self.params["reserved"] = self.reserved + self.params["identifier"] = self.identifier + + global_branch = registry.get_global_branch() + self.params["rel_prop"] = { + "branch": global_branch.name, + "branch_level": global_branch.hierarchy_level, + "status": RelationshipStatus.ACTIVE.value, + "from": self.at.to_string(), + "identifier": self.identifier, + } + + query = """ + MATCH (pool:%(number_pool)s { uuid: $pool_id }) + MERGE (value:AttributeValue { value: $reserved, is_default: false }) + CREATE (pool)-[rel:IS_RESERVED $rel_prop]->(value) + """ % {"number_pool": InfrahubKind.NUMBERPOOL} + + self.add_to_query(query) + self.return_labels = ["value"] + + class PrefixPoolGetIdentifiers(Query): name: str = "prefixpool_get_identifiers" diff --git a/backend/infrahub/core/schema/definitions/core.py b/backend/infrahub/core/schema/definitions/core.py index 432f8a233c..e980519164 100644 --- a/backend/infrahub/core/schema/definitions/core.py +++ b/backend/infrahub/core/schema/definitions/core.py @@ -15,7 +15,7 @@ InfrahubKind, ProposedChangeState, RelationshipDeleteBehavior, - RepositoryAdminStatus, + RepositoryInternalStatus, RepositoryOperationalStatus, RepositorySyncStatus, Severity, @@ -407,29 +407,32 @@ "allow_override": AllowOverrideType.NONE, }, { - "name": "admin_status", + "name": "internal_status", "kind": "Dropdown", "choices": [ { - "name": RepositoryAdminStatus.STAGING.value, + "name": RepositoryInternalStatus.STAGING.value, "label": "Staging", "description": "Repository was recently added to this branch.", + "color": "#fef08a", }, { - "name": RepositoryAdminStatus.ACTIVE.value, + "name": RepositoryInternalStatus.ACTIVE.value, "label": "Active", "description": "Repository is actively being synced for this branch", + "color": "#86efac", }, { - "name": RepositoryAdminStatus.INACTIVE.value, + "name": RepositoryInternalStatus.INACTIVE.value, "label": "Inactive", "description": "Repository is not active on this branch.", + "color": "#e5e7eb", }, ], "default_value": "inactive", "optional": False, "branch": BranchSupportType.LOCAL.value, - "order_weight": 6000, + "order_weight": 7000, "allow_override": AllowOverrideType.NONE, }, { @@ -440,32 +443,37 @@ "name": RepositoryOperationalStatus.UNKNOWN.value, "label": "Unknown", "description": "Status of the repository is unknown and mostlikely because it hasn't been synced yet", + "color": "#9ca3af", }, { "name": RepositoryOperationalStatus.ONLINE.value, "label": "Online", "description": "Repository connection is working", + "color": "#86efac", }, { "name": RepositoryOperationalStatus.ERROR_CRED.value, "label": "Credential Error", "description": "Repository can't be synced due to some credential error(s)", + "color": "#f87171", }, { "name": RepositoryOperationalStatus.ERROR_CONNECTION.value, "label": "Connectivity Error", "description": "Repository can't be synced due to some connectivity error(s)", + "color": "#f87171", }, { "name": RepositoryOperationalStatus.ERROR.value, "label": "Error", "description": "Repository can't be synced due to an unknown error", + "color": "#ef4444", }, ], "optional": False, "branch": BranchSupportType.AGNOSTIC.value, "default_value": RepositoryOperationalStatus.UNKNOWN.value, - "order_weight": 7000, + "order_weight": 5000, }, { "name": "sync_status", @@ -475,27 +483,31 @@ "name": RepositorySyncStatus.UNKNOWN.value, "label": "Unknown", "description": "Status of the repository is unknown and mostlikely because it hasn't been synced yet", + "color": "#9ca3af", }, { "name": RepositorySyncStatus.ERROR_IMPORT.value, "label": "Import Error", "description": "Repository import error observed", + "color": "#f87171", }, { "name": RepositorySyncStatus.IN_SYNC.value, "label": "In Sync", "description": "The repository is syncing correctly", + "color": "#60a5fa", }, { "name": RepositorySyncStatus.SYNCING.value, "label": "Syncing", "description": "A sync job is currently running against the repository.", + "color": "#a855f7", }, ], "optional": False, "branch": BranchSupportType.LOCAL.value, "default_value": RepositorySyncStatus.UNKNOWN.value, - "order_weight": 8000, + "order_weight": 6000, }, ], "relationships": [ @@ -506,6 +518,7 @@ "kind": "Attribute", "optional": True, "cardinality": "one", + "order_weight": 4000, }, { "name": "tags", @@ -513,6 +526,7 @@ "kind": "Attribute", "optional": True, "cardinality": "many", + "order_weight": 8000, }, { "name": "transformations", @@ -520,6 +534,7 @@ "identifier": "repository__transformation", "optional": True, "cardinality": "many", + "order_weight": 10000, }, { "name": "queries", @@ -527,6 +542,7 @@ "identifier": "graphql_query__repository", "optional": True, "cardinality": "many", + "order_weight": 9000, }, { "name": "checks", @@ -534,6 +550,7 @@ "identifier": "check_definition__repository", "optional": True, "cardinality": "many", + "order_weight": 11000, }, { "name": "generators", @@ -541,6 +558,7 @@ "identifier": "generator_definition__repository", "optional": True, "cardinality": "many", + "order_weight": 12000, }, ], }, @@ -1317,6 +1335,7 @@ "attributes": [ {"name": "conflicts", "kind": "JSON"}, {"name": "keep_branch", "enum": BranchConflictKeep.available_types(), "kind": "Text", "optional": True}, + {"name": "enriched_conflict_id", "kind": "Text", "optional": True}, ], }, { @@ -1342,6 +1361,7 @@ "branch": BranchSupportType.AGNOSTIC.value, "attributes": [ {"name": "conflicts", "kind": "JSON"}, + {"name": "enriched_conflict_id", "kind": "Text", "optional": True}, ], }, { @@ -2025,26 +2045,19 @@ "optional": False, "order_weight": 4000, }, - { - "name": "unique_for", - "kind": "Text", - "description": "Relationship to another model adding a uniqueness constraint the allocated integer", - "optional": True, - "order_weight": 5000, - }, { "name": "start_range", "kind": "Number", "optional": False, "description": "The start range for the pool", - "order_weight": 6000, + "order_weight": 5000, }, { "name": "end_range", "kind": "Number", "optional": False, "description": "The end range for the pool", - "order_weight": 7000, + "order_weight": 6000, }, ], }, diff --git a/backend/infrahub/core/schema_manager.py b/backend/infrahub/core/schema_manager.py index 6aa7bcc9e4..75f782bbf3 100644 --- a/backend/infrahub/core/schema_manager.py +++ b/backend/infrahub/core/schema_manager.py @@ -1586,6 +1586,8 @@ def generate_filters( filters = [] filters.append(FilterSchema(name="ids", kind=FilterSchemaKind.LIST)) + if schema.human_friendly_id: + filters.append(FilterSchema(name="hfid", kind=FilterSchemaKind.LIST)) for attr in schema.attributes: filter_kind = KIND_FILTER_MAP.get(attr.kind, None) diff --git a/backend/infrahub/dependencies/builder/diff/coordinator.py b/backend/infrahub/dependencies/builder/diff/coordinator.py index dfc2255563..76217d603d 100644 --- a/backend/infrahub/dependencies/builder/diff/coordinator.py +++ b/backend/infrahub/dependencies/builder/diff/coordinator.py @@ -6,6 +6,7 @@ from .conflicts_enricher import DiffConflictsEnricherDependency from .data_check_synchronizer import DiffDataCheckSynchronizerDependency from .enricher.aggregated import DiffAggregatedEnricherDependency +from .enricher.labels import DiffLabelsEnricherDependency from .enricher.summary_counts import DiffSummaryCountsEnricherDependency from .repository import DiffRepositoryDependency @@ -19,6 +20,7 @@ def build(cls, context: DependencyBuilderContext) -> DiffCoordinator: diff_combiner=DiffCombinerDependency.build(context=context), diff_enricher=DiffAggregatedEnricherDependency.build(context=context), conflicts_enricher=DiffConflictsEnricherDependency.build(context=context), + labels_enricher=DiffLabelsEnricherDependency.build(context=context), summary_counts_enricher=DiffSummaryCountsEnricherDependency.build(context=context), data_check_synchronizer=DiffDataCheckSynchronizerDependency.build(context=context), ) diff --git a/backend/infrahub/git/actions.py b/backend/infrahub/git/actions.py index ee1c916f7a..0989531ac8 100644 --- a/backend/infrahub/git/actions.py +++ b/backend/infrahub/git/actions.py @@ -1,5 +1,5 @@ from infrahub import lock -from infrahub.core.constants import InfrahubKind, RepositoryAdminStatus +from infrahub.core.constants import InfrahubKind, RepositoryInternalStatus from infrahub.core.registry import registry from infrahub.exceptions import RepositoryError from infrahub.services import InfrahubServices @@ -15,11 +15,11 @@ async def sync_remote_repositories(service: InfrahubServices) -> None: async with service.git_report( title="Syncing repository", related_node=repository_data.repository.id, create_with_context=False ) as git_report: - active_admin_status = RepositoryAdminStatus.ACTIVE.value - default_admin_status = repository_data.branch_info[registry.default_branch].admin_status + active_internal_status = RepositoryInternalStatus.ACTIVE.value + default_internal_status = repository_data.branch_info[registry.default_branch].internal_status staging_branch = None - if default_admin_status != RepositoryAdminStatus.ACTIVE.value: - active_admin_status = RepositoryAdminStatus.STAGING.value + if default_internal_status != RepositoryInternalStatus.ACTIVE.value: + active_internal_status = RepositoryInternalStatus.STAGING.value staging_branch = repository_data.get_staging_branch() infrahub_branch = staging_branch or registry.default_branch @@ -34,7 +34,7 @@ async def sync_remote_repositories(service: InfrahubServices) -> None: location=repository_data.repository.location.value, client=service.client, task_report=git_report, - admin_status=active_admin_status, + internal_status=active_internal_status, default_branch_name=repository_data.repository.default_branch.value, ) except RepositoryError as exc: @@ -50,7 +50,7 @@ async def sync_remote_repositories(service: InfrahubServices) -> None: location=repository_data.repository.location.value, client=service.client, task_report=git_report, - admin_status=active_admin_status, + internal_status=active_internal_status, default_branch_name=repository_data.repository.default_branch.value, ) await repo.import_objects_from_files( diff --git a/backend/infrahub/git/base.py b/backend/infrahub/git/base.py index 5272be5a4b..048e4cbd78 100644 --- a/backend/infrahub/git/base.py +++ b/backend/infrahub/git/base.py @@ -159,7 +159,7 @@ class InfrahubRepositoryBase(BaseModel, ABC): # pylint: disable=too-many-public is_read_only: bool = Field(False, description="If true, changes will not be synced to remote") task_report: Optional[InfrahubTaskReportLogger] = Field(default=None) - admin_status: str = Field("active", description="Administrative status: Active, Inactive, Staging") + internal_status: str = Field("active", description="Internal status: Active, Inactive, Staging") infrahub_branch_name: Optional[str] = Field( None, description="Infrahub branch on which to sync the remote repository" ) @@ -195,7 +195,7 @@ def directory_root(self) -> str: @property def directory_default(self) -> str: """Return the path to the directory of the main branch.""" - return os.path.join(self.directory_root, registry.default_branch) + return os.path.join(self.directory_root, "main") @property def directory_branches(self) -> str: @@ -352,7 +352,6 @@ def get_worktree(self, identifier: str) -> Worktree: """Access a specific worktree by its identifier.""" worktrees = self.get_worktrees() - for worktree in worktrees: if worktree.identifier == identifier: return worktree @@ -463,11 +462,12 @@ async def update_commit_value(self, branch_name: str, commit: str) -> bool: False if they already had the same value """ + infrahub_branch = self._get_mapped_target_branch(branch_name=branch_name) log.debug( - f"Updating commit value to {commit} for branch {branch_name}", repository=self.name, branch=branch_name + f"Updating commit value to {commit} for branch {branch_name}", repository=self.name, branch=infrahub_branch ) await self.sdk.repository_update_commit( - branch_name=branch_name, repository_id=self.id, commit=commit, is_read_only=self.is_read_only + branch_name=infrahub_branch, repository_id=self.id, commit=commit, is_read_only=self.is_read_only ) return True @@ -601,13 +601,19 @@ async def compare_local_remote(self) -> tuple[list[str], list[str]]: return sorted(list(new_branches)), sorted(updated_branches) - async def validate_remote_branch(self, branch_name: str) -> bool: # pylint: disable=unused-argument + async def validate_remote_branch(self, branch_name: str) -> bool: """Validate a branch present on the remote repository. To check a branch we need to first create a worktree in the temporary folder then apply some checks: - xxx At the end, we need to delete the worktree in the temporary folder. """ + if branch_name == registry.default_branch and branch_name != self.default_branch: + # If the default branch of Infrahub and the git repository differs we map the repository + # default branch to that of Infrahub. In that scenario we can't import a branch from the + # repository if it matches the default branch of Infrahub + log.warning("Ignoring import of mismatched default branch", branch=branch_name, repository=self.name) + return False try: # Check if the branch can be created in the database Branch(name=branch_name) @@ -628,8 +634,11 @@ async def pull(self, branch_name: str) -> Union[bool, str]: if not self.has_origin: return False + identifier = branch_name + if branch_name == self.default_branch and branch_name != registry.default_branch: + identifier = "main" - repo = self.get_git_repo_worktree(identifier=branch_name) + repo = self.get_git_repo_worktree(identifier=identifier) if not repo: raise ValueError(f"Unable to identify the worktree for the branch : {branch_name}") @@ -645,7 +654,8 @@ async def pull(self, branch_name: str) -> Union[bool, str]: return True self.create_commit_worktree(commit=commit_after) - await self.update_commit_value(branch_name=branch_name, commit=commit_after) + infrahub_branch = self._get_mapped_target_branch(branch_name=branch_name) + await self.update_commit_value(branch_name=infrahub_branch, commit=commit_after) return commit_after @@ -767,3 +777,15 @@ def _raise_enriched_error_static( ) from error raise RepositoryError(identifier=name, message=error.stderr) from error + + def _get_mapped_remote_branch(self, branch_name: str) -> str: + """Returns the remote branch for Git Repositories.""" + if branch_name != self.default_branch and branch_name == registry.default_branch: + return self.default_branch + return branch_name + + def _get_mapped_target_branch(self, branch_name: str) -> str: + """Returns the target branch within Infrahub.""" + if branch_name == self.default_branch and branch_name != registry.default_branch: + return registry.default_branch + return branch_name diff --git a/backend/infrahub/git/repository.py b/backend/infrahub/git/repository.py index f6d455c866..5566cedfcb 100644 --- a/backend/infrahub/git/repository.py +++ b/backend/infrahub/git/repository.py @@ -6,7 +6,7 @@ from infrahub_sdk import GraphQLError from pydantic import Field -from infrahub.core.constants import InfrahubKind, RepositoryAdminStatus +from infrahub.core.constants import InfrahubKind, RepositoryInternalStatus from infrahub.exceptions import RepositoryError from infrahub.git.integrator import InfrahubRepositoryIntegrator from infrahub.log import get_logger @@ -107,35 +107,38 @@ async def sync(self, staging_branch: str | None = None) -> None: log.debug(f"New Branches {new_branches}, Updated Branches {updated_branches}", repository=self.name) # TODO need to handle properly the situation when a branch is not valid. - if self.admin_status == RepositoryAdminStatus.ACTIVE.value: + if self.internal_status == RepositoryInternalStatus.ACTIVE.value: for branch_name in new_branches: is_valid = await self.validate_remote_branch(branch_name=branch_name) if not is_valid: continue + infrahub_branch = self._get_mapped_target_branch(branch_name=branch_name) try: - branch = await self.create_branch_in_graph(branch_name=branch_name) + branch = await self.create_branch_in_graph(branch_name=infrahub_branch) except GraphQLError as exc: if "already exist" not in exc.errors[0]["message"]: raise - branch = await self.sdk.branch.get(branch_name=branch_name) + branch = await self.sdk.branch.get(branch_name=infrahub_branch) await self.create_branch_in_git(branch_name=branch.name, branch_id=branch.id) commit = self.get_commit_value(branch_name=branch_name, remote=False) self.create_commit_worktree(commit=commit) - await self.update_commit_value(branch_name=branch_name, commit=commit) + await self.update_commit_value(branch_name=infrahub_branch, commit=commit) - await self.import_objects_from_files(infrahub_branch_name=branch_name, commit=commit) + await self.import_objects_from_files(infrahub_branch_name=infrahub_branch, commit=commit) for branch_name in updated_branches: is_valid = await self.validate_remote_branch(branch_name=branch_name) if not is_valid: continue + infrahub_branch = self._get_mapped_target_branch(branch_name=branch_name) + commit_after = await self.pull(branch_name=branch_name) if isinstance(commit_after, str): - await self.import_objects_from_files(infrahub_branch_name=branch_name, commit=commit_after) + await self.import_objects_from_files(infrahub_branch_name=infrahub_branch, commit=commit_after) elif commit_after is True: log.warning( @@ -148,7 +151,7 @@ async def sync(self, staging_branch: str | None = None) -> None: async def _sync_staging(self, staging_branch: str | None, updated_branches: list[str]) -> None: if ( - self.admin_status == RepositoryAdminStatus.STAGING.value + self.internal_status == RepositoryInternalStatus.STAGING.value and staging_branch and self.default_branch in updated_branches ): @@ -177,7 +180,8 @@ async def push(self, branch_name: str) -> bool: # TODO Catch potential exceptions coming from origin.push repo = self.get_git_repo_worktree(identifier=branch_name) - repo.remotes.origin.push(branch_name) + remote_branch = self._get_mapped_remote_branch(branch_name=branch_name) + repo.remotes.origin.push(remote_branch) return True @@ -206,7 +210,6 @@ async def merge(self, source_branch: str, dest_branch: str, push_remote: bool = self.create_commit_worktree(commit_after) await self.update_commit_value(branch_name=dest_branch, commit=commit_after) - if self.has_origin and push_remote: await self.push(branch_name=dest_branch) diff --git a/backend/infrahub/graphql/manager.py b/backend/infrahub/graphql/manager.py index 97ae09f7ce..246105db18 100644 --- a/backend/infrahub/graphql/manager.py +++ b/backend/infrahub/graphql/manager.py @@ -783,6 +783,10 @@ def generate_filters( if not top_level: filters["isnull"] = graphene.Boolean() + if schema.human_friendly_id and top_level: + # HFID filter limited to top level because we can't filter on HFID for relationships (yet) + filters["hfid"] = graphene.List(graphene.String) + for attr in schema.attributes: attr_kind = get_attr_kind(node_schema=schema, attr_schema=attr) filters.update( diff --git a/backend/infrahub/graphql/mutations/attribute.py b/backend/infrahub/graphql/mutations/attribute.py index 60948fca3f..085d91c8a9 100644 --- a/backend/infrahub/graphql/mutations/attribute.py +++ b/backend/infrahub/graphql/mutations/attribute.py @@ -1,7 +1,8 @@ -from graphene import Boolean, InputObjectType, Int, String +from graphene import Boolean, Field, InputObjectType, Int, String from graphene.types.generic import GenericScalar from infrahub.core import registry +from infrahub.graphql.types.attribute import GenericPoolInput class BaseAttributeCreate(InputObjectType): @@ -47,12 +48,12 @@ class StringAttributeUpdate(BaseAttributeUpdate): class NumberAttributeCreate(BaseAttributeCreate): value = Int(required=False) - from_pool = String(required=False) + from_pool = Field(GenericPoolInput, required=False) class NumberAttributeUpdate(BaseAttributeUpdate): value = Int(required=False) - from_pool = String(required=False) + from_pool = Field(GenericPoolInput, required=False) class IntAttributeCreate(BaseAttributeCreate): diff --git a/backend/infrahub/graphql/mutations/diff_conflict.py b/backend/infrahub/graphql/mutations/diff_conflict.py index 6fe1f91209..8877934f24 100644 --- a/backend/infrahub/graphql/mutations/diff_conflict.py +++ b/backend/infrahub/graphql/mutations/diff_conflict.py @@ -50,8 +50,10 @@ async def mutate( selection = ConflictSelection(data.selected_branch.value) if data.selected_branch else None await diff_repo.update_conflict_by_id(conflict_id=data.conflict_id, selection=selection) - core_data_check = await NodeManager.get_one(db=context.db, id=data.conflict_id, kind=InfrahubKind.DATACHECK) - if not core_data_check: + core_data_checks = await NodeManager.query( + db=context.db, schema=InfrahubKind.DATACHECK, filters={"enriched_conflict_id__value": data.conflict_id} + ) + if not core_data_checks: return cls(ok=True) if data.selected_branch is GraphQlConflictSelection.BASE_BRANCH: keep_branch = BranchConflictKeep.TARGET @@ -59,6 +61,7 @@ async def mutate( keep_branch = BranchConflictKeep.SOURCE else: keep_branch = None - core_data_check.keep_branch.value = keep_branch - await core_data_check.save(db=context.db) + for cdc in core_data_checks: + cdc.keep_branch.value = keep_branch + await cdc.save(db=context.db) return cls(ok=True) diff --git a/backend/infrahub/graphql/mutations/repository.py b/backend/infrahub/graphql/mutations/repository.py index 2a0316535b..57be6fae76 100644 --- a/backend/infrahub/graphql/mutations/repository.py +++ b/backend/infrahub/graphql/mutations/repository.py @@ -5,7 +5,7 @@ from graphene import Boolean, InputObjectType, Mutation, String -from infrahub.core.constants import InfrahubKind, RepositoryAdminStatus +from infrahub.core.constants import InfrahubKind, RepositoryInternalStatus from infrahub.core.manager import NodeManager from infrahub.core.protocols import CoreGenericRepository, CoreReadOnlyRepository, CoreRepository from infrahub.core.schema import NodeSchema @@ -77,9 +77,9 @@ async def mutate_create( # If we are in the default branch, we set the sync status to Active # If we are in another branch, we set the sync status to Staging if branch.is_default: - obj.admin_status.value = RepositoryAdminStatus.ACTIVE.value + obj.internal_status.value = RepositoryInternalStatus.ACTIVE.value else: - obj.admin_status.value = RepositoryAdminStatus.STAGING.value + obj.internal_status.value = RepositoryInternalStatus.STAGING.value await obj.save(db=context.db) # Create the new repository in the filesystem. @@ -95,7 +95,7 @@ async def mutate_create( location=obj.location.value, ref=obj.ref.value, infrahub_branch_name=branch.name, - admin_status=obj.admin_status.value, + internal_status=obj.internal_status.value, created_by=authenticated_user, ) else: @@ -106,7 +106,7 @@ async def mutate_create( location=obj.location.value, default_branch_name=obj.default_branch.value, infrahub_branch_name=branch.name, - admin_status=obj.admin_status.value, + internal_status=obj.internal_status.value, created_by=authenticated_user, ) diff --git a/backend/infrahub/graphql/mutations/resource_manager.py b/backend/infrahub/graphql/mutations/resource_manager.py index fba9769a90..2f116d612e 100644 --- a/backend/infrahub/graphql/mutations/resource_manager.py +++ b/backend/infrahub/graphql/mutations/resource_manager.py @@ -194,15 +194,6 @@ async def mutate_create( if attribute.kind != "Number": raise ValidationError(input_value="The selected attribute is not of the kind Number") - if "unique_for" in data: - relationships = [ - relationship - for relationship in pool_node.relationships - if relationship.name == data["unique_for"].value - ] - if not relationships: - raise ValidationError(input_value="The selected relationship doesn't exist in the selected model") - if data["start_range"].value > data["end_range"].value: raise ValidationError(input_value="start_range can't be larger than end_range") @@ -219,12 +210,10 @@ async def mutate_update( database: InfrahubDatabase | None = None, node: Node | None = None, ) -> tuple[Node, Self]: - if ( # pylint: disable=too-many-boolean-expressions - (data.get("node") and data.get("node").value) - or (data.get("node_attribute") and data.get("node_attribute").value) - or (data.get("unique_for") and data.get("unique_for").value) + if (data.get("node") and data.get("node").value) or ( + data.get("node_attribute") and data.get("node_attribute").value ): - raise ValidationError(input_value="The fields 'model', 'model_attribute' or 'unique_for' can't be changed.") + raise ValidationError(input_value="The fields 'node' or 'node_attribute' can't be changed.") context: GraphqlContext = info.context async with context.db.start_transaction() as dbt: diff --git a/backend/infrahub/graphql/queries/diff/tree.py b/backend/infrahub/graphql/queries/diff/tree.py index 4834634e2b..8e4512f29f 100644 --- a/backend/infrahub/graphql/queries/diff/tree.py +++ b/backend/infrahub/graphql/queries/diff/tree.py @@ -7,9 +7,11 @@ from infrahub_sdk.utils import extract_fields from infrahub.core import registry -from infrahub.core.constants import DiffAction +from infrahub.core.constants import DiffAction, RelationshipCardinality +from infrahub.core.constants.database import DatabaseEdgeType from infrahub.core.diff.model.path import NameTrackingId from infrahub.core.diff.repository.repository import DiffRepository +from infrahub.core.query.diff import DiffCountChanges from infrahub.core.timestamp import Timestamp from infrahub.dependencies.registry import get_component_registry from infrahub.graphql.enums import ConflictSelection as GraphQLConflictSelection @@ -28,9 +30,11 @@ EnrichedDiffRoot, EnrichedDiffSingleRelationship, ) + from infrahub.database import InfrahubDatabase from infrahub.graphql import GraphqlContext GrapheneDiffActionEnum = GrapheneEnum.from_enum(DiffAction) +GrapheneCardinalityEnum = GrapheneEnum.from_enum(RelationshipCardinality) class ConflictDetails(ObjectType): @@ -38,9 +42,11 @@ class ConflictDetails(ObjectType): base_branch_action = Field(GrapheneDiffActionEnum, required=True) base_branch_value = String() base_branch_changed_at = DateTime(required=True) + base_branch_label = String() diff_branch_action = Field(GrapheneDiffActionEnum, required=True) diff_branch_value = String() diff_branch_changed_at = DateTime(required=True) + diff_branch_label = String() selected_branch = Field(GraphQLConflictSelection) @@ -56,6 +62,8 @@ class DiffProperty(ObjectType): last_changed_at = DateTime(required=True) previous_value = String(required=False) new_value = String(required=False) + previous_label = String(required=False) + new_label = String(required=False) status = Field(GrapheneDiffActionEnum, required=True) path_identifier = String(required=True) conflict = Field(ConflictDetails, required=False) @@ -68,6 +76,7 @@ class DiffAttribute(DiffSummaryCounts): path_identifier = String(required=True) properties = List(DiffProperty) contains_conflict = Boolean(required=True) + conflict = Field(ConflictDetails, required=False) class DiffSingleRelationship(DiffSummaryCounts): @@ -85,6 +94,7 @@ class DiffRelationship(DiffSummaryCounts): name = String(required=True) label = String(required=False) last_changed_at = DateTime(required=False) + cardinality = Field(GrapheneCardinalityEnum, required=True) status = Field(GrapheneDiffActionEnum, required=True) path_identifier = String(required=True) elements = List(DiffSingleRelationship, required=True) @@ -116,6 +126,8 @@ class DiffTree(DiffSummaryCounts): diff_branch = String(required=True) from_time = DateTime(required=True) to_time = DateTime(required=True) + num_untracked_base_changes = Int(required=False) + num_untracked_diff_changes = Int(required=False) name = String(required=False) nodes = List(DiffNode) @@ -126,6 +138,8 @@ class DiffTreeSummary(DiffSummaryCounts): from_time = DateTime(required=True) to_time = DateTime(required=True) num_unchanged = Int(required=False) + num_untracked_base_changes = Int(required=False) + num_untracked_diff_changes = Int(required=False) class DiffTreeResolver: @@ -195,6 +209,11 @@ def to_diff_attribute( diff_properties = [ self.to_diff_property(enriched_property=e_prop, context=context) for e_prop in enriched_attribute.properties ] + conflict = None + for diff_prop in diff_properties: + if diff_prop.property_type == DatabaseEdgeType.HAS_VALUE.value and diff_prop.conflict: + conflict = diff_prop.conflict + diff_prop.conflict = None return DiffAttribute( name=enriched_attribute.name, last_changed_at=enriched_attribute.changed_at.obj, @@ -202,6 +221,7 @@ def to_diff_attribute( path_identifier=enriched_attribute.path_identifier, properties=diff_properties, contains_conflict=enriched_attribute.contains_conflict, + conflict=conflict, num_added=enriched_attribute.num_added, num_updated=enriched_attribute.num_updated, num_removed=enriched_attribute.num_removed, @@ -220,6 +240,7 @@ def to_diff_relationship( label=enriched_relationship.label, last_changed_at=enriched_relationship.changed_at.obj if enriched_relationship.changed_at else None, status=enriched_relationship.action, + cardinality=enriched_relationship.cardinality, path_identifier=enriched_relationship.path_identifier, elements=diff_elements, contains_conflict=enriched_relationship.contains_conflict, @@ -262,6 +283,8 @@ def to_diff_property( last_changed_at=enriched_property.changed_at.obj, previous_value=enriched_property.previous_value, new_value=enriched_property.new_value, + previous_label=enriched_property.previous_label, + new_label=enriched_property.new_label, status=enriched_property.action, path_identifier=enriched_property.path_identifier, conflict=conflict, @@ -279,11 +302,13 @@ def to_diff_conflict( base_branch_changed_at=enriched_conflict.base_branch_changed_at.obj if enriched_conflict.base_branch_changed_at else None, + base_branch_label=enriched_conflict.base_branch_label, diff_branch_action=enriched_conflict.diff_branch_action, diff_branch_value=enriched_conflict.diff_branch_value, diff_branch_changed_at=enriched_conflict.diff_branch_changed_at.obj if enriched_conflict.diff_branch_changed_at else None, + diff_branch_label=enriched_conflict.diff_branch_label, selected_branch=enriched_conflict.selected_branch.value if enriched_conflict.selected_branch else None, ) @@ -316,6 +341,29 @@ async def to_graphql( return response_list return response_list[0] + async def _add_untracked_fields( + self, + db: InfrahubDatabase, + diff_response: DiffTreeSummary | DiffTree, + from_time: Timestamp, + base_branch_name: str | None = None, + diff_branch_name: str | None = None, + ) -> None: + if not (base_branch_name or diff_branch_name): + return + branch_names = [] + if base_branch_name: + branch_names.append(base_branch_name) + if diff_branch_name: + branch_names.append(diff_branch_name) + query = await DiffCountChanges.init(db=db, branch_names=branch_names, diff_from=from_time, diff_to=Timestamp()) + await query.execute(db=db) + branch_change_map = query.get_num_changes_by_branch() + if base_branch_name: + diff_response.num_untracked_base_changes = branch_change_map.get(base_branch_name, 0) + if diff_branch_name: + diff_response.num_untracked_diff_changes = branch_change_map.get(diff_branch_name, 0) + # pylint: disable=unused-argument async def resolve( self, @@ -361,6 +409,7 @@ async def resolve( include_parents=include_parents, limit=limit, offset=offset, + include_empty=True, ) if not enriched_diffs: return None @@ -368,6 +417,16 @@ async def resolve( full_fields = await extract_fields(info.field_nodes[0].selection_set) diff_tree = await self.to_diff_tree(enriched_diff_root=enriched_diff, context=context) + need_base_changes = "num_untracked_base_changes" in full_fields + need_branch_changes = "num_untracked_diff_changes" in full_fields + if need_base_changes or need_branch_changes: + await self._add_untracked_fields( + db=context.db, + diff_response=diff_tree, + from_time=enriched_diff.to_time, + base_branch_name=base_branch.name if need_base_changes else None, + diff_branch_name=diff_branch.name if need_branch_changes else None, + ) return await self.to_graphql(fields=full_fields, diff_object=diff_tree) # pylint: disable=unused-argument @@ -404,14 +463,28 @@ async def summary( to_time=to_timestamp, filters=filters_dict, ) + if summary is None: + return None - return DiffTreeSummary( + diff_tree_summary = DiffTreeSummary( base_branch=base_branch.name, diff_branch=diff_branch.name, - from_time=from_timestamp.to_graphql(), - to_time=to_timestamp.to_graphql(), - **summary.model_dump(), + from_time=summary.from_time.obj, + to_time=summary.to_time.obj, + **summary.model_dump(exclude={"from_time", "to_time"}), ) + full_fields = await extract_fields(info.field_nodes[0].selection_set) + need_base_changes = "num_untracked_base_changes" in full_fields + need_branch_changes = "num_untracked_diff_changes" in full_fields + if need_base_changes or need_branch_changes: + await self._add_untracked_fields( + db=context.db, + diff_response=diff_tree_summary, + from_time=summary.to_time, + base_branch_name=base_branch.name if need_base_changes else None, + diff_branch_name=diff_branch.name if need_branch_changes else None, + ) + return diff_tree_summary class IncExclFilterOptions(InputObjectType): diff --git a/backend/infrahub/graphql/queries/resource_manager.py b/backend/infrahub/graphql/queries/resource_manager.py index 6fae86c213..420734a038 100644 --- a/backend/infrahub/graphql/queries/resource_manager.py +++ b/backend/infrahub/graphql/queries/resource_manager.py @@ -10,7 +10,11 @@ from infrahub.core.ipam.utilization import PrefixUtilizationGetter from infrahub.core.manager import NodeManager from infrahub.core.query.ipam import IPPrefixUtilization -from infrahub.core.query.resource_manager import IPAddressPoolGetIdentifiers, PrefixPoolGetIdentifiers +from infrahub.core.query.resource_manager import ( + IPAddressPoolGetIdentifiers, + NumberPoolGetAllocated, + PrefixPoolGetIdentifiers, +) from infrahub.exceptions import NodeNotFoundError, ValidationError from infrahub.pools.number import NumberUtilizationGetter @@ -244,30 +248,23 @@ async def resolve_number_pool_allocation( db: InfrahubDatabase, context: GraphqlContext, pool: CoreNode, fields: dict, offset: int, limit: int ) -> dict: response: dict[str, Any] = {} + query = await NumberPoolGetAllocated.init( + db=db, pool=pool, offset=offset, limit=limit, branch=context.branch, branch_agnostic=True + ) + if "count" in fields: - response["count"] = await registry.manager.count( - db=db, - schema=pool.node.value, # type: ignore[attr-defined] - at=context.at, - branch_agnostic=True, - ) + response["count"] = await query.count(db=db) + if "edges" in fields: - allocated_numbers = await registry.manager.query( - db=db, - schema=pool.node.value, # type: ignore[attr-defined] - at=context.at, - branch_agnostic=True, - limit=limit, - offset=offset, - ) + await query.execute(db=db) edges = [] - for entry in allocated_numbers: + for entry in query.results: node = { "node": { - "id": entry.get_id(), + "id": entry.get_as_optional_type("id", str), "kind": pool.node.value, # type: ignore[attr-defined] - "branch": entry._branch.name, - "display_label": getattr(entry, pool.node_attribute.value).value, # type: ignore[attr-defined] + "branch": entry.get_as_optional_type("branch", str), + "display_label": entry.get_as_optional_type("value", int), } } edges.append(node) @@ -277,7 +274,7 @@ async def resolve_number_pool_allocation( async def resolve_number_pool_utilization(db: InfrahubDatabase, context: GraphqlContext, pool: CoreNode) -> dict: - number_pool = NumberUtilizationGetter(db=db, pool=pool, at=context.at) + number_pool = NumberUtilizationGetter(db=db, pool=pool, at=context.at, branch=context.branch) await number_pool.load_data() return { diff --git a/backend/infrahub/graphql/resolver.py b/backend/infrahub/graphql/resolver.py index db8fe2538a..99229140bd 100644 --- a/backend/infrahub/graphql/resolver.py +++ b/backend/infrahub/graphql/resolver.py @@ -116,12 +116,14 @@ async def single_relationship_resolver(parent: dict, info: GraphQLResolveInfo, * # Extract the schema of the node on the other end of the relationship from the GQL Schema node_rel = node_schema.get_relationship(info.field_name) + # Extract only the filters from the kwargs and prepend the name of the field to the filters filters = { f"{info.field_name}__{key}": value for key, value in kwargs.items() if "__" in key and value or key in ["id", "ids"] } + response: dict[str, Any] = {"node": None, "properties": {}} async with context.db.start_session() as db: @@ -289,6 +291,7 @@ async def hierarchy_resolver( # Extract only the filters from the kwargs and prepend the name of the field to the filters offset = kwargs.pop("offset", None) limit = kwargs.pop("limit", None) + filters = { f"{info.field_name}__{key}": value for key, value in kwargs.items() diff --git a/backend/infrahub/graphql/types/mixin.py b/backend/infrahub/graphql/types/mixin.py index 1614f5de6a..f1cfa857b1 100644 --- a/backend/infrahub/graphql/types/mixin.py +++ b/backend/infrahub/graphql/types/mixin.py @@ -14,7 +14,7 @@ class GetListMixin: @classmethod async def get_list(cls, fields: dict, context: GraphqlContext, **kwargs): async with context.db.start_session() as db: - filters = {key: value for key, value in kwargs.items() if ("__" in key and value) or key == "ids"} + filters = {key: value for key, value in kwargs.items() if ("__" in key and value) or key in ("ids", "hfid")} objs = await NodeManager.query( db=db, @@ -43,17 +43,11 @@ async def get_paginated_list(cls, fields: dict, context: GraphqlContext, **kwarg offset = kwargs.pop("offset", None) limit = kwargs.pop("limit", None) filters = { - key: value for key, value in kwargs.items() if ("__" in key and value is not None) or key == "ids" + key: value + for key, value in kwargs.items() + if ("__" in key and value is not None) or key in ("ids", "hfid") } - if "count" in fields: - response["count"] = await NodeManager.count( - db=db, - schema=cls._meta.schema, - filters=filters, - at=context.at, - branch=context.branch, - partial_match=partial_match, - ) + edges = fields.get("edges", {}) node_fields = edges.get("node", {}) @@ -72,6 +66,19 @@ async def get_paginated_list(cls, fields: dict, context: GraphqlContext, **kwarg partial_match=partial_match, ) + if "count" in fields: + if filters.get("hfid"): + response["count"] = len(objs) + else: + response["count"] = await NodeManager.count( + db=db, + schema=cls._meta.schema, + filters=filters, + at=context.at, + branch=context.branch, + partial_match=partial_match, + ) + if objs: objects = [ {"node": await obj.to_graphql(db=db, fields=node_fields, related_node_ids=context.related_node_ids)} diff --git a/backend/infrahub/lock.py b/backend/infrahub/lock.py index 7b22202f5b..787a867920 100644 --- a/backend/infrahub/lock.py +++ b/backend/infrahub/lock.py @@ -102,6 +102,9 @@ async def do_acquire(self, token: str) -> bool: async def release(self) -> None: await self.service.cache.delete(key=self.name) + async def locked(self) -> bool: + return await self.service.cache.get(key=self.name) is not None + class InfrahubLock: """InfrahubLock object to provide a unified interface for both Local and Distributed locks. @@ -209,6 +212,17 @@ def _generate_name(cls, name: str, namespace: Optional[str] = None, local: Optio return new_name + def get_existing( + self, + name: str, + namespace: str | None, + local: Optional[bool] = None, + ) -> InfrahubLock | None: + lock_name = self._generate_name(name=name, namespace=namespace, local=local) + if lock_name not in self.locks: + return None + return self.locks[lock_name] + def get( self, name: str, namespace: Optional[str] = None, local: Optional[bool] = None, in_multi: bool = False ) -> InfrahubLock: diff --git a/backend/infrahub/message_bus/messages/git_repository_add.py b/backend/infrahub/message_bus/messages/git_repository_add.py index 835c073387..231f851753 100644 --- a/backend/infrahub/message_bus/messages/git_repository_add.py +++ b/backend/infrahub/message_bus/messages/git_repository_add.py @@ -14,4 +14,4 @@ class GitRepositoryAdd(InfrahubMessage): created_by: Optional[str] = Field(default=None, description="The user ID of the user that created the repository") default_branch_name: Optional[str] = Field(None, description="Default branch for this repository") infrahub_branch_name: str = Field(..., description="Infrahub branch on which to sync the remote repository") - admin_status: str = Field(..., description="Administrative status of the repository") + internal_status: str = Field(..., description="Administrative status of the repository") diff --git a/backend/infrahub/message_bus/messages/git_repository_merge.py b/backend/infrahub/message_bus/messages/git_repository_merge.py index ebe5dffb1c..56bcc25dfa 100644 --- a/backend/infrahub/message_bus/messages/git_repository_merge.py +++ b/backend/infrahub/message_bus/messages/git_repository_merge.py @@ -8,6 +8,7 @@ class GitRepositoryMerge(InfrahubMessage): repository_id: str = Field(..., description="The unique ID of the Repository") repository_name: str = Field(..., description="The name of the repository") - admin_status: str = Field(..., description="Administrative status of the repository") + internal_status: str = Field(..., description="Administrative status of the repository") source_branch: str = Field(..., description="The source branch") destination_branch: str = Field(..., description="The source branch") + default_branch: str = Field(..., description="The default branch in Git") diff --git a/backend/infrahub/message_bus/messages/git_repository_read_only_add.py b/backend/infrahub/message_bus/messages/git_repository_read_only_add.py index 1d3347ff4d..b16481ffa9 100644 --- a/backend/infrahub/message_bus/messages/git_repository_read_only_add.py +++ b/backend/infrahub/message_bus/messages/git_repository_read_only_add.py @@ -14,4 +14,4 @@ class GitRepositoryAddReadOnly(InfrahubMessage): ref: str = Field(..., description="Ref to track on the external repository") created_by: Optional[str] = Field(default=None, description="The user ID of the user that created the repository") infrahub_branch_name: str = Field(..., description="Infrahub branch on which to sync the remote repository") - admin_status: str = Field(..., description="Administrative status of the repository") + internal_status: str = Field(..., description="Internal status of the repository") diff --git a/backend/infrahub/message_bus/operations/git/repository.py b/backend/infrahub/message_bus/operations/git/repository.py index 1d951fa8b8..ed8b36c561 100644 --- a/backend/infrahub/message_bus/operations/git/repository.py +++ b/backend/infrahub/message_bus/operations/git/repository.py @@ -1,5 +1,5 @@ from infrahub import lock -from infrahub.core.constants import InfrahubKind, RepositoryAdminStatus +from infrahub.core.constants import InfrahubKind, RepositoryInternalStatus from infrahub.exceptions import RepositoryError from infrahub.git.repository import InfrahubReadOnlyRepository, InfrahubRepository, get_initialized_repo from infrahub.log import get_logger @@ -18,7 +18,7 @@ async def add(message: messages.GitRepositoryAdd, service: InfrahubServices) -> "Cloning and importing repository", repository=message.repository_name, location=message.location, - admin_status=message.admin_status, + internal_status=message.internal_status, ) async with service.git_report( related_node=message.repository_id, @@ -33,12 +33,13 @@ async def add(message: messages.GitRepositoryAdd, service: InfrahubServices) -> client=service.client, task_report=git_report, infrahub_branch_name=message.infrahub_branch_name, - admin_status=message.admin_status, + internal_status=message.internal_status, + default_branch_name=message.default_branch_name, ) await repo.import_objects_from_files( infrahub_branch_name=message.infrahub_branch_name, git_branch_name=message.default_branch_name ) - if message.admin_status == RepositoryAdminStatus.ACTIVE.value: + if message.internal_status == RepositoryInternalStatus.ACTIVE.value: await repo.sync() @@ -62,7 +63,7 @@ async def add_read_only(message: messages.GitRepositoryAddReadOnly, service: Inf task_report=git_report, ) await repo.import_objects_from_files(infrahub_branch_name=message.infrahub_branch_name) - if message.admin_status == RepositoryAdminStatus.ACTIVE.value: + if message.internal_status == RepositoryInternalStatus.ACTIVE.value: await repo.sync_from_remote() @@ -156,14 +157,19 @@ async def merge(message: messages.GitRepositoryMerge, service: InfrahubServices) destination_branch=message.destination_branch, ) - repo = await InfrahubRepository.init(id=message.repository_id, name=message.repository_name, client=service.client) + repo = await InfrahubRepository.init( + id=message.repository_id, + name=message.repository_name, + client=service.client, + default_branch_name=message.default_branch, + ) - if message.admin_status == RepositoryAdminStatus.STAGING.value: + if message.internal_status == RepositoryInternalStatus.STAGING.value: repo_source = await service.client.get( kind=InfrahubKind.GENERICREPOSITORY, id=message.repository_id, branch=message.source_branch ) repo_main = await service.client.get(kind=InfrahubKind.GENERICREPOSITORY, id=message.repository_id) - repo_main.admin_status.value = RepositoryAdminStatus.ACTIVE.value + repo_main.internal_status.value = RepositoryInternalStatus.ACTIVE.value repo_main.sync_status.value = repo_source.sync_status.value commit = repo.get_commit_value(branch_name=repo.default_branch, remote=False) diff --git a/backend/infrahub/message_bus/operations/requests/artifact_definition.py b/backend/infrahub/message_bus/operations/requests/artifact_definition.py index 4c19fa9ad5..2020d6f9d1 100644 --- a/backend/infrahub/message_bus/operations/requests/artifact_definition.py +++ b/backend/infrahub/message_bus/operations/requests/artifact_definition.py @@ -107,7 +107,7 @@ async def check(message: messages.RequestArtifactDefinitionCheck, service: Infra query=message.artifact_definition.query_name, variables=member.extract(params=artifact_definition.parameters.value), target_id=member.id, - target_name=member.name.value, + target_name=member.display_label, timeout=message.artifact_definition.timeout, validator_id=validator.id, meta=Meta(validator_execution_id=validator_execution_id, check_execution_id=check_execution_id), @@ -203,7 +203,7 @@ async def generate(message: messages.RequestArtifactDefinitionGenerate, service: query=query.name.value, variables=member.extract(params=artifact_definition.parameters.value), target_id=member.id, - target_name=member.name.value, + target_name=member.display_label, timeout=transform.timeout.value, ) ) diff --git a/backend/infrahub/message_bus/operations/requests/generator_definition.py b/backend/infrahub/message_bus/operations/requests/generator_definition.py index 6bc7a41868..92c03e839e 100644 --- a/backend/infrahub/message_bus/operations/requests/generator_definition.py +++ b/backend/infrahub/message_bus/operations/requests/generator_definition.py @@ -101,7 +101,7 @@ async def check(message: messages.RequestGeneratorDefinitionCheck, service: Infr query=message.generator_definition.query_name, variables=member.extract(params=message.generator_definition.parameters), target_id=member.id, - target_name=member.name.value, + target_name=member.display_label, validator_id=validator.id, meta=Meta(validator_execution_id=validator_execution_id, check_execution_id=check_execution_id), ) @@ -177,7 +177,7 @@ async def run(message: messages.RequestGeneratorDefinitionRun, service: Infrahub query=message.generator_definition.query_name, variables=member.extract(params=message.generator_definition.parameters), target_id=member.id, - target_name=member.name.value, + target_name=member.display_label, ) ) diff --git a/backend/infrahub/message_bus/operations/requests/proposed_change.py b/backend/infrahub/message_bus/operations/requests/proposed_change.py index 05f19df9a0..22158775a2 100644 --- a/backend/infrahub/message_bus/operations/requests/proposed_change.py +++ b/backend/infrahub/message_bus/operations/requests/proposed_change.py @@ -11,7 +11,7 @@ from pydantic import BaseModel from infrahub import config, lock -from infrahub.core.constants import CheckType, InfrahubKind, ProposedChangeState, RepositoryAdminStatus +from infrahub.core.constants import CheckType, InfrahubKind, ProposedChangeState, RepositoryInternalStatus from infrahub.core.diff.coordinator import DiffCoordinator from infrahub.core.diff.model.diff import SchemaConflict from infrahub.core.integrity.object_conflict.conflict_recorder import ObjectConflictValidatorRecorder @@ -98,17 +98,6 @@ async def data_integrity(message: messages.RequestProposedChangeDataIntegrity, s diff_coordinator = await component_registry.get_component(DiffCoordinator, db=dbt, branch=source_branch) await diff_coordinator.update_branch_diff(base_branch=destination_branch, diff_branch=source_branch) - # async with service.database.start_transaction() as db: - # object_conflict_validator_recorder = ObjectConflictValidatorRecorder( - # db=db, - # validator_kind=InfrahubKind.DATAVALIDATOR, - # validator_label="Data Integrity", - # check_schema_kind=InfrahubKind.DATACHECK, - # ) - # await object_conflict_validator_recorder.record_conflicts( - # proposed_change_id=message.proposed_change, conflicts=conflicts - # ) - async def pipeline(message: messages.RequestProposedChangePipeline, service: InfrahubServices) -> None: async with service.task_report( @@ -128,7 +117,7 @@ async def pipeline(message: messages.RequestProposedChangePipeline, service: Inf repositories=repositories ): for repo in repositories: - if not repo.read_only and repo.admin_status == RepositoryAdminStatus.ACTIVE.value: + if not repo.read_only and repo.internal_status == RepositoryInternalStatus.ACTIVE.value: events.append( messages.RequestRepositoryChecks( proposed_change=message.proposed_change, @@ -311,7 +300,7 @@ async def repository_checks(message: messages.RequestProposedChangeRepositoryChe if ( message.source_branch_sync_with_git and not repository.read_only - and repository.admin_status == RepositoryAdminStatus.ACTIVE.value + and repository.internal_status == RepositoryInternalStatus.ACTIVE.value ): events.append( messages.RequestRepositoryChecks( @@ -651,7 +640,7 @@ def _execute( name { value } - admin_status { + internal_status { value } ... on CoreRepository { @@ -680,7 +669,7 @@ def _execute( name { value } - admin_status { + internal_status { value } commit { @@ -701,7 +690,7 @@ def _execute( name { value } - admin_status { + internal_status { value } commit { @@ -719,7 +708,7 @@ class Repository(BaseModel): repository_name: str read_only: bool commit: str - admin_status: str + internal_status: str def _parse_proposed_change_repositories( @@ -741,7 +730,7 @@ def _parse_proposed_change_repositories( repository_id=repo.repository_id, repository_name=repo.repository_name, read_only=repo.read_only, - admin_status=repo.admin_status, + internal_status=repo.internal_status, destination_commit=repo.commit, source_branch=message.source_branch, destination_branch=message.destination_branch, @@ -755,14 +744,14 @@ def _parse_proposed_change_repositories( repository_id=repo.repository_id, repository_name=repo.repository_name, read_only=repo.read_only, - admin_status=repo.admin_status, + internal_status=repo.internal_status, source_commit=repo.commit, source_branch=message.source_branch, destination_branch=message.destination_branch, ) else: pc_repos[repo.repository_id].source_commit = repo.commit - pc_repos[repo.repository_id].admin_status = repo.admin_status + pc_repos[repo.repository_id].internal_status = repo.internal_status return list(pc_repos.values()) @@ -783,7 +772,7 @@ def _parse_repositories(repositories: list[dict]) -> list[Repository]: repository_name=repo["node"]["name"]["value"], read_only=repo["node"]["__typename"] == InfrahubKind.READONLYREPOSITORY, commit=repo["node"]["commit"]["value"] or "", - admin_status=repo["node"]["admin_status"]["value"], + internal_status=repo["node"]["internal_status"]["value"], ) ) return parsed diff --git a/backend/infrahub/message_bus/types.py b/backend/infrahub/message_bus/types.py index 714dd39aee..7f7c53e333 100644 --- a/backend/infrahub/message_bus/types.py +++ b/backend/infrahub/message_bus/types.py @@ -6,7 +6,7 @@ from infrahub_sdk.client import NodeDiff # noqa: TCH002 from pydantic import BaseModel, Field -from infrahub.core.constants import InfrahubKind, RepositoryAdminStatus +from infrahub.core.constants import InfrahubKind, RepositoryInternalStatus from infrahub.exceptions import NodeNotFoundError SCHEMA_CHANGE = re.compile("^Schema[A-Z]") @@ -45,7 +45,7 @@ class ProposedChangeRepository(BaseModel): read_only: bool source_branch: str destination_branch: str - admin_status: str + internal_status: str source_commit: str = Field(default="") destination_commit: str = Field(default="") conflicts: list[str] = Field(default_factory=list, description="List of files with merge conflicts") @@ -63,7 +63,7 @@ def has_diff(self) -> bool: @property def is_staging(self) -> bool: """Indicates if the repository is in staging mode.""" - if self.admin_status == RepositoryAdminStatus.STAGING.value: + if self.internal_status == RepositoryInternalStatus.STAGING.value: return True return False diff --git a/backend/infrahub/pools/number.py b/backend/infrahub/pools/number.py index 6a6a1d015b..754c574c79 100644 --- a/backend/infrahub/pools/number.py +++ b/backend/infrahub/pools/number.py @@ -1,10 +1,16 @@ +from __future__ import annotations + from dataclasses import dataclass -from typing import Optional, Union +from typing import TYPE_CHECKING, Optional, Union -from infrahub.core.protocols import CoreNode +from infrahub.core.query.resource_manager import NumberPoolGetAllocated from infrahub.core.registry import registry -from infrahub.core.timestamp import Timestamp -from infrahub.database import InfrahubDatabase + +if TYPE_CHECKING: + from infrahub.core.branch import Branch + from infrahub.core.protocols import CoreNode + from infrahub.core.timestamp import Timestamp + from infrahub.database import InfrahubDatabase @dataclass @@ -14,10 +20,13 @@ class UsedNumber: class NumberUtilizationGetter: - def __init__(self, db: InfrahubDatabase, pool: CoreNode, at: Optional[Union[Timestamp, str]] = None) -> None: + def __init__( + self, db: InfrahubDatabase, pool: CoreNode, branch: Branch, at: Optional[Union[Timestamp, str]] = None + ) -> None: self.db = db self.at = at self.pool = pool + self.branch = branch self.start_range = int(pool.start_range.value) # type: ignore[attr-defined] self.end_range = int(pool.end_range.value) # type: ignore[attr-defined] self.used: list[UsedNumber] = [] @@ -25,26 +34,20 @@ def __init__(self, db: InfrahubDatabase, pool: CoreNode, at: Optional[Union[Time self.used_branches: set[int] = set() async def load_data(self) -> None: - existing_nodes = await registry.manager.query( - db=self.db, - schema=self.pool.node.value, # type: ignore[attr-defined] - at=self.at, - branch_agnostic=True, - ) + query = await NumberPoolGetAllocated.init(db=self.db, pool=self.pool, branch=self.branch, branch_agnostic=True) + await query.execute(db=self.db) + self.used = [ - UsedNumber(number=getattr(node, self.pool.node_attribute.value).value, branch=node._branch.name) # type: ignore[attr-defined] - for node in existing_nodes + UsedNumber( + number=result.get_as_type(label="value", return_type=int), + branch=result.get_as_type(label="branch", return_type=str), + ) + for result in query.results + if result.get_as_optional_type(label="value", return_type=int) is not None ] - self.used_default_branch = { - entry.number - for entry in self.used - if self.start_range <= entry.number <= self.end_range and entry.branch == registry.default_branch - } - used_branches = { - entry.number - for entry in self.used - if self.start_range <= entry.number <= self.end_range and entry.branch != registry.default_branch - } + + self.used_default_branch = {entry.number for entry in self.used if entry.branch == registry.default_branch} + used_branches = {entry.number for entry in self.used if entry.branch != registry.default_branch} self.used_branches = used_branches - self.used_default_branch @property diff --git a/backend/infrahub/utils.py b/backend/infrahub/utils.py index 931dccb493..05b97dc44b 100644 --- a/backend/infrahub/utils.py +++ b/backend/infrahub/utils.py @@ -33,16 +33,6 @@ def format_label(slug: str) -> str: return " ".join([word.title() for word in slug.split("_")]) -def find_next_free(start: int, end: int, used_numbers: list[int]) -> Optional[int]: - used_set = set(used_numbers) - - for num in range(start, end + 1): - if num not in used_set: - return num - - return None - - class MetaEnum(EnumMeta): def __contains__(cls, item: Any) -> bool: try: diff --git a/backend/tests/fixtures/schemas/schema_01.json b/backend/tests/fixtures/schemas/schema_01.json index c8ed975a7b..d92529600c 100644 --- a/backend/tests/fixtures/schemas/schema_01.json +++ b/backend/tests/fixtures/schemas/schema_01.json @@ -190,7 +190,7 @@ "optional": false }, { - "name": "admin_status", + "name": "internal_status", "kind": "String", "label": null, "description": null, diff --git a/backend/tests/helpers/schema/car.py b/backend/tests/helpers/schema/car.py index ff87831729..5a2da040eb 100644 --- a/backend/tests/helpers/schema/car.py +++ b/backend/tests/helpers/schema/car.py @@ -8,6 +8,7 @@ include_in_menu=True, label="Car", default_filter="name__value", + display_labels=["name__value", "color__value"], attributes=[ AttributeSchema(name="name", kind="Text"), AttributeSchema(name="description", kind="Text", optional=True), @@ -21,6 +22,14 @@ peer=TestKind.PERSON, cardinality=RelationshipCardinality.ONE, ), + RelationshipSchema( + name="previous_owner", + kind=RelationshipKind.ATTRIBUTE, + optional=True, + peer=TestKind.PERSON, + cardinality=RelationshipCardinality.ONE, + identifier="testingcar__previousowners", + ), RelationshipSchema( name="manufacturer", kind=RelationshipKind.ATTRIBUTE, diff --git a/backend/tests/integration/diff/test_diff_incremental_addition.py b/backend/tests/integration/diff/test_diff_incremental_addition.py new file mode 100644 index 0000000000..b5acbc221a --- /dev/null +++ b/backend/tests/integration/diff/test_diff_incremental_addition.py @@ -0,0 +1,569 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import uuid4 + +import pytest + +from infrahub.core.constants import ( + DiffAction, + RelationshipCardinality, +) +from infrahub.core.constants.database import DatabaseEdgeType +from infrahub.core.diff.coordinator import DiffCoordinator +from infrahub.core.initialization import create_branch +from infrahub.core.manager import NodeManager +from infrahub.core.node import Node +from infrahub.core.timestamp import Timestamp +from infrahub.dependencies.registry import get_component_registry +from infrahub.services.adapters.cache.redis import RedisCache +from tests.constants import TestKind +from tests.helpers.schema import CAR_SCHEMA, load_schema +from tests.helpers.test_app import TestInfrahubApp + +if TYPE_CHECKING: + from infrahub_sdk import InfrahubClient + + from infrahub.core.branch import Branch + from infrahub.core.diff.model.path import EnrichedDiffRoot + from infrahub.database import InfrahubDatabase + from tests.adapters.message_bus import BusSimulator + +BRANCH_NAME = "branch1" +PROPOSED_CHANGE_NAME = "branch1-pc" + + +class TestDiffUpdateConflict(TestInfrahubApp): + @pytest.fixture(scope="class") + async def initial_dataset( + self, + db: InfrahubDatabase, + default_branch, + client: InfrahubClient, + bus_simulator: BusSimulator, + ) -> dict[str, Node]: + await load_schema(db, schema=CAR_SCHEMA) + doc_brown = await Node.init(schema=TestKind.PERSON, db=db) + await doc_brown.new(db=db, name="Doc Brown", height=175) + await doc_brown.save(db=db) + marty = await Node.init(schema=TestKind.PERSON, db=db) + await marty.new(db=db, name="Marty McFly", height=155) + await marty.save(db=db) + biff = await Node.init(schema=TestKind.PERSON, db=db) + await biff.new(db=db, name="Biff... something", height=177) + await biff.save(db=db) + dmc = await Node.init(schema=TestKind.MANUFACTURER, db=db) + await dmc.new(db=db, name="DMC") + await dmc.save(db=db) + delorean = await Node.init(schema=TestKind.CAR, db=db) + await delorean.new( + db=db, + name="Delorean", + color="Silver", + description="time-travelling coupe", + owner=doc_brown, + manufacturer=dmc, + ) + await delorean.save(db=db) + await delorean.previous_owner.update(db=db, data={"id": doc_brown.id, "_relation__is_protected": True}) # type: ignore[attr-defined] + await delorean.save(db=db) + + bus_simulator.service.cache = RedisCache() + + return { + "doc_brown": doc_brown, + "marty": marty, + "biff": biff, + "dmc": dmc, + "delorean": delorean, + } + + @pytest.fixture(scope="class") + async def diff_branch(self, db: InfrahubDatabase, initial_dataset) -> Branch: + return await create_branch(db=db, branch_name=BRANCH_NAME) + + @pytest.fixture(scope="class") + async def diff_coordinator(self, db: InfrahubDatabase, diff_branch: Branch) -> DiffCoordinator: + component_registry = get_component_registry() + return await component_registry.get_component(DiffCoordinator, db=db, branch=diff_branch) + + @pytest.fixture(scope="class") + async def data_01_remove_on_main( + self, + db: InfrahubDatabase, + initial_dataset, + default_branch: Branch, + diff_branch: Branch, + ) -> None: + delorean_id = initial_dataset["delorean"].get_id() + + delorean_main = await NodeManager.get_one(db=db, branch=default_branch, id=delorean_id) + await delorean_main.previous_owner.update(db=db, data=[None]) + await delorean_main.save(db=db) + + @pytest.fixture(scope="class") + async def data_02_previous_owner_on_branch( + self, + db: InfrahubDatabase, + initial_dataset, + default_branch: Branch, + diff_branch: Branch, + data_01_remove_on_main, + ) -> None: + delorean = initial_dataset["delorean"] + marty = initial_dataset["marty"] + + delorean_branch = await NodeManager.get_one(db=db, branch=diff_branch, id=delorean.get_id()) + await delorean_branch.previous_owner.update( + db=db, data=[{"id": marty.get_id(), "_relation__is_protected": False}] + ) + await delorean_branch.save(db=db) + + @pytest.fixture(scope="class") + async def data_03_new_peer_on_main( + self, + db: InfrahubDatabase, + initial_dataset, + default_branch: Branch, + diff_branch: Branch, + data_02_previous_owner_on_branch, + ) -> None: + delorean = initial_dataset["delorean"] + biff = initial_dataset["biff"] + + delorean_main = await NodeManager.get_one(db=db, branch=default_branch, id=delorean.get_id()) + await delorean_main.previous_owner.update(db=db, data=[{"id": biff.get_id(), "_relation__is_protected": True}]) + await delorean_main.save(db=db) + + @pytest.fixture(scope="class") + async def data_04_update_previous_owner_protected_on_branch( + self, + db: InfrahubDatabase, + initial_dataset, + default_branch: Branch, + diff_branch: Branch, + data_03_new_peer_on_main, + ) -> None: + delorean = initial_dataset["delorean"] + marty = initial_dataset["marty"] + + delorean_branch = await NodeManager.get_one(db=db, branch=diff_branch, id=delorean.get_id()) + await delorean_branch.previous_owner.update( + db=db, data=[{"id": marty.get_id(), "_relation__is_protected": True}] + ) + await delorean_branch.save(db=db) + + @pytest.fixture(scope="class") + async def data_05_remove_previous_owner_on_branch( + self, + db: InfrahubDatabase, + initial_dataset, + default_branch: Branch, + diff_branch: Branch, + data_04_update_previous_owner_protected_on_branch, + ) -> None: + delorean = initial_dataset["delorean"] + + delorean_branch = await NodeManager.get_one(db=db, branch=diff_branch, id=delorean.get_id()) + await delorean_branch.previous_owner.update(db=db, data=[None]) + await delorean_branch.save(db=db) + + @pytest.fixture(scope="class") + async def data_06_remove_previous_owner_on_main_again( + self, + db: InfrahubDatabase, + initial_dataset, + default_branch: Branch, + diff_branch: Branch, + data_05_remove_previous_owner_on_branch, + ) -> None: + delorean = initial_dataset["delorean"] + + delorean_main = await NodeManager.get_one(db=db, branch=default_branch, id=delorean.get_id()) + await delorean_main.previous_owner.update(db=db, data=[None]) + await delorean_main.save(db=db) + + async def test_remove_on_main( + self, + db: InfrahubDatabase, + default_branch: Branch, + diff_branch: Branch, + diff_coordinator: DiffCoordinator, + data_01_remove_on_main, + ) -> None: + enriched_diff = await diff_coordinator.update_branch_diff(base_branch=default_branch, diff_branch=diff_branch) + + assert len(enriched_diff.nodes) == 0 + + async def validate_diff_data_02( + self, + db: InfrahubDatabase, + enriched_diff: EnrichedDiffRoot, + initial_dataset: dict[str, Node], + ): + delorean = initial_dataset["delorean"] + marty = initial_dataset["marty"] + doc_brown = initial_dataset["doc_brown"] + marty_label = await marty.render_display_label(db=db) + delorean_label = await delorean.render_display_label(db=db) + + assert len(enriched_diff.nodes) == 1 + node = enriched_diff.nodes.pop() + assert node.uuid == delorean.get_id() + assert node.label == delorean_label + assert node.action is DiffAction.UPDATED + assert len(node.attributes) == 0 + assert len(node.relationships) == 1 + previous_owner_rel = node.relationships.pop() + assert previous_owner_rel.name == "previous_owner" + assert previous_owner_rel.action is DiffAction.UPDATED + assert previous_owner_rel.cardinality is RelationshipCardinality.ONE + assert len(previous_owner_rel.relationships) == 1 + rel_element = previous_owner_rel.relationships.pop() + assert rel_element.peer_id == marty.get_id() + assert rel_element.peer_label == marty_label + assert rel_element.action is DiffAction.UPDATED + assert rel_element.conflict + assert rel_element.conflict.base_branch_action is DiffAction.REMOVED + assert rel_element.conflict.base_branch_value is None + assert rel_element.conflict.diff_branch_action is DiffAction.UPDATED + assert rel_element.conflict.diff_branch_value == marty.get_id() + properties_by_type = {p.property_type: p for p in rel_element.properties} + # is_visible is still true, although on a different peeer + assert set(properties_by_type.keys()) == {DatabaseEdgeType.IS_RELATED, DatabaseEdgeType.IS_PROTECTED} + related_prop = properties_by_type[DatabaseEdgeType.IS_RELATED] + assert related_prop.previous_value == doc_brown.get_id() + assert related_prop.new_value == marty.get_id() + assert related_prop.action is DiffAction.UPDATED + assert related_prop.conflict is None + protected_prop = properties_by_type[DatabaseEdgeType.IS_PROTECTED] + assert protected_prop.previous_value == "True" + assert protected_prop.new_value == "False" + assert protected_prop.action is DiffAction.UPDATED + assert protected_prop.conflict + assert protected_prop.conflict.base_branch_action is DiffAction.REMOVED + assert protected_prop.conflict.base_branch_value is None + assert protected_prop.conflict.diff_branch_action is DiffAction.UPDATED + assert protected_prop.conflict.diff_branch_value == "False" + + async def test_update_previous_owner_on_branch( + self, + db: InfrahubDatabase, + default_branch: Branch, + diff_branch: Branch, + diff_coordinator: DiffCoordinator, + initial_dataset, + data_02_previous_owner_on_branch, + ) -> None: + incremental_diff = await diff_coordinator.update_branch_diff( + base_branch=default_branch, diff_branch=diff_branch + ) + await self.validate_diff_data_02(db=db, enriched_diff=incremental_diff, initial_dataset=initial_dataset) + full_diff = await diff_coordinator.create_or_update_arbitrary_timeframe_diff( + base_branch=default_branch, + diff_branch=diff_branch, + from_time=incremental_diff.from_time, + to_time=incremental_diff.to_time, + name=str(uuid4), + ) + await self.validate_diff_data_02(db=db, enriched_diff=full_diff, initial_dataset=initial_dataset) + + async def validate_diff_data_03( + self, + db: InfrahubDatabase, + default_branch: Branch, + enriched_diff: EnrichedDiffRoot, + initial_dataset: dict[str, Node], + ): + delorean = initial_dataset["delorean"] + doc_brown = initial_dataset["doc_brown"] + marty = initial_dataset["marty"] + biff = initial_dataset["biff"] + marty_label = await marty.render_display_label(db=db) + delorean_main = await NodeManager.get_one(db=db, branch=default_branch, id=delorean.get_id()) + delorean_label = await delorean_main.render_display_label(db=db) + + assert len(enriched_diff.nodes) == 1 + node = enriched_diff.nodes.pop() + assert node.uuid == delorean.get_id() + assert node.label == delorean_label + assert node.action is DiffAction.UPDATED + assert len(node.attributes) == 0 + assert len(node.relationships) == 1 + previous_owner_rel = node.relationships.pop() + assert previous_owner_rel.name == "previous_owner" + assert previous_owner_rel.action is DiffAction.UPDATED + assert previous_owner_rel.cardinality is RelationshipCardinality.ONE + assert len(previous_owner_rel.relationships) == 1 + rel_element = previous_owner_rel.relationships.pop() + assert rel_element.peer_id == marty.get_id() + assert rel_element.peer_label == marty_label + assert rel_element.action is DiffAction.UPDATED + assert rel_element.conflict + assert rel_element.conflict.base_branch_action is DiffAction.UPDATED + assert rel_element.conflict.base_branch_value == biff.get_id() + assert rel_element.conflict.diff_branch_action is DiffAction.UPDATED + assert rel_element.conflict.diff_branch_value == marty.get_id() + properties_by_type = {p.property_type: p for p in rel_element.properties} + # is_visible is still true, although on a different peeer + assert set(properties_by_type.keys()) == {DatabaseEdgeType.IS_RELATED, DatabaseEdgeType.IS_PROTECTED} + related_prop = properties_by_type[DatabaseEdgeType.IS_RELATED] + assert related_prop.previous_value == doc_brown.get_id() + assert related_prop.new_value == marty.get_id() + assert related_prop.action is DiffAction.UPDATED + assert related_prop.conflict is None + protected_prop = properties_by_type[DatabaseEdgeType.IS_PROTECTED] + assert protected_prop.previous_value == "True" + assert str(protected_prop.new_value) == "False" + assert protected_prop.action is DiffAction.UPDATED + assert protected_prop.conflict + assert protected_prop.conflict.base_branch_action is DiffAction.UPDATED + assert str(protected_prop.conflict.base_branch_value) == "True" + assert protected_prop.conflict.diff_branch_action is DiffAction.UPDATED + assert str(protected_prop.conflict.diff_branch_value) == "False" + + async def test_add_new_peer_on_main( + self, + db: InfrahubDatabase, + initial_dataset, + default_branch: Branch, + diff_branch: Branch, + diff_coordinator: DiffCoordinator, + data_03_new_peer_on_main, + ) -> None: + incremental_diff = await diff_coordinator.update_branch_diff( + base_branch=default_branch, diff_branch=diff_branch + ) + await self.validate_diff_data_03( + db=db, default_branch=default_branch, enriched_diff=incremental_diff, initial_dataset=initial_dataset + ) + full_diff = await diff_coordinator.create_or_update_arbitrary_timeframe_diff( + base_branch=default_branch, + diff_branch=diff_branch, + from_time=Timestamp(diff_branch.created_at), + to_time=Timestamp(), + name=str(uuid4), + ) + await self.validate_diff_data_03( + db=db, default_branch=default_branch, enriched_diff=full_diff, initial_dataset=initial_dataset + ) + + async def validate_diff_data_04( + self, + db: InfrahubDatabase, + enriched_diff: EnrichedDiffRoot, + initial_dataset: dict[str, Node], + ): + delorean = initial_dataset["delorean"] + marty = initial_dataset["marty"] + doc_brown = initial_dataset["doc_brown"] + biff = initial_dataset["biff"] + marty_label = await marty.render_display_label(db=db) + delorean_label = await delorean.render_display_label(db=db) + + assert len(enriched_diff.nodes) == 1 + node = enriched_diff.nodes.pop() + assert node.uuid == delorean.get_id() + assert node.label == delorean_label + assert node.action is DiffAction.UPDATED + assert len(node.attributes) == 0 + assert len(node.relationships) == 1 + previous_owner_rel = node.relationships.pop() + assert previous_owner_rel.name == "previous_owner" + assert previous_owner_rel.action is DiffAction.UPDATED + assert previous_owner_rel.cardinality is RelationshipCardinality.ONE + assert len(previous_owner_rel.relationships) == 1 + rel_element = previous_owner_rel.relationships.pop() + assert rel_element.peer_id == marty.get_id() + assert rel_element.peer_label == marty_label + assert rel_element.action is DiffAction.UPDATED + assert rel_element.conflict + assert rel_element.conflict.base_branch_action is DiffAction.UPDATED + assert rel_element.conflict.base_branch_value == biff.get_id() + assert rel_element.conflict.diff_branch_action is DiffAction.UPDATED + assert rel_element.conflict.diff_branch_value == marty.get_id() + properties_by_type = {p.property_type: p for p in rel_element.properties} + # is_visible is still true, although on a different peeer + assert set(properties_by_type.keys()) == {DatabaseEdgeType.IS_RELATED, DatabaseEdgeType.IS_PROTECTED} + related_prop = properties_by_type[DatabaseEdgeType.IS_RELATED] + assert related_prop.previous_value == doc_brown.get_id() + assert related_prop.new_value == marty.get_id() + assert related_prop.action is DiffAction.UPDATED + assert related_prop.conflict is None + protected_prop = properties_by_type[DatabaseEdgeType.IS_PROTECTED] + assert protected_prop.previous_value == "True" + assert protected_prop.new_value == "True" + assert protected_prop.action is DiffAction.UPDATED + # no conflict b/c both have been updated to the same value + assert protected_prop.conflict is None + + async def test_update_previous_owner_protected_on_branch( + self, + db: InfrahubDatabase, + initial_dataset, + default_branch: Branch, + diff_branch: Branch, + diff_coordinator: DiffCoordinator, + data_04_update_previous_owner_protected_on_branch, + ) -> None: + incremental_diff = await diff_coordinator.update_branch_diff( + base_branch=default_branch, diff_branch=diff_branch + ) + await self.validate_diff_data_04(db=db, enriched_diff=incremental_diff, initial_dataset=initial_dataset) + full_diff = await diff_coordinator.create_or_update_arbitrary_timeframe_diff( + base_branch=default_branch, + diff_branch=diff_branch, + from_time=Timestamp(diff_branch.created_at), + to_time=Timestamp(), + name=str(uuid4), + ) + await self.validate_diff_data_04(db=db, enriched_diff=full_diff, initial_dataset=initial_dataset) + + async def validate_diff_data_05( + self, + db: InfrahubDatabase, + enriched_diff: EnrichedDiffRoot, + initial_dataset: dict[str, Node], + ): + delorean = initial_dataset["delorean"] + doc_brown = initial_dataset["doc_brown"] + biff = initial_dataset["biff"] + doc_brown_label = await doc_brown.render_display_label(db=db) + delorean_label = await delorean.render_display_label(db=db) + + assert len(enriched_diff.nodes) == 1 + node = enriched_diff.nodes.pop() + assert node.uuid == delorean.get_id() + assert node.label == delorean_label + assert node.action is DiffAction.UPDATED + assert len(node.attributes) == 0 + assert len(node.relationships) == 1 + previous_owner_rel = node.relationships.pop() + assert previous_owner_rel.name == "previous_owner" + assert previous_owner_rel.action is DiffAction.REMOVED + assert previous_owner_rel.cardinality is RelationshipCardinality.ONE + assert len(previous_owner_rel.relationships) == 1 + rel_element = previous_owner_rel.relationships.pop() + # peer ID and label should point to latest pre-branch value on main b/c branch update has been removed + assert rel_element.peer_id == doc_brown.get_id() + assert rel_element.peer_label == doc_brown_label + assert rel_element.action is DiffAction.REMOVED + assert rel_element.conflict + assert rel_element.conflict.base_branch_action is DiffAction.UPDATED + assert rel_element.conflict.base_branch_value == biff.get_id() + assert rel_element.conflict.diff_branch_action is DiffAction.REMOVED + assert rel_element.conflict.diff_branch_value is None + properties_by_type = {p.property_type: p for p in rel_element.properties} + # is_visible is still true, although on a different peeer + assert set(properties_by_type.keys()) == { + DatabaseEdgeType.IS_RELATED, + DatabaseEdgeType.IS_PROTECTED, + DatabaseEdgeType.IS_VISIBLE, + } + related_prop = properties_by_type[DatabaseEdgeType.IS_RELATED] + assert related_prop.previous_value == doc_brown.get_id() + assert related_prop.new_value is None + assert related_prop.action is DiffAction.REMOVED + assert related_prop.conflict is None + for prop_type in (DatabaseEdgeType.IS_PROTECTED, DatabaseEdgeType.IS_VISIBLE): + protected_prop = properties_by_type[prop_type] + assert protected_prop.previous_value == "True" + assert protected_prop.new_value is None + assert protected_prop.action is DiffAction.REMOVED + assert protected_prop.conflict + assert protected_prop.conflict.base_branch_action is DiffAction.UPDATED + assert protected_prop.conflict.base_branch_value == "True" + assert protected_prop.conflict.diff_branch_action is DiffAction.REMOVED + assert protected_prop.conflict.diff_branch_value is None + + async def test_remove_previous_owner_on_branch( + self, + db: InfrahubDatabase, + initial_dataset, + default_branch: Branch, + diff_branch: Branch, + diff_coordinator: DiffCoordinator, + data_05_remove_previous_owner_on_branch, + ) -> None: + incremental_diff = await diff_coordinator.update_branch_diff( + base_branch=default_branch, diff_branch=diff_branch + ) + await self.validate_diff_data_05(db=db, enriched_diff=incremental_diff, initial_dataset=initial_dataset) + full_diff = await diff_coordinator.create_or_update_arbitrary_timeframe_diff( + base_branch=default_branch, + diff_branch=diff_branch, + from_time=Timestamp(diff_branch.created_at), + to_time=Timestamp(), + name=str(uuid4), + ) + await self.validate_diff_data_05(db=db, enriched_diff=full_diff, initial_dataset=initial_dataset) + + async def validate_diff_data_06( + self, + db: InfrahubDatabase, + enriched_diff: EnrichedDiffRoot, + initial_dataset: dict[str, Node], + ): + delorean = initial_dataset["delorean"] + doc_brown = initial_dataset["doc_brown"] + doc_brown_label = await doc_brown.render_display_label(db=db) + delorean_label = await delorean.render_display_label(db=db) + + assert len(enriched_diff.nodes) == 1 + node = enriched_diff.nodes.pop() + assert node.uuid == delorean.get_id() + assert node.label == delorean_label + assert node.action is DiffAction.UPDATED + assert len(node.attributes) == 0 + assert len(node.relationships) == 1 + previous_owner_rel = node.relationships.pop() + assert previous_owner_rel.name == "previous_owner" + assert previous_owner_rel.action is DiffAction.REMOVED + assert previous_owner_rel.cardinality is RelationshipCardinality.ONE + assert len(previous_owner_rel.relationships) == 1 + rel_element = previous_owner_rel.relationships.pop() + # peer ID and label should point to latest pre-branch value on main b/c branch update has been removed + assert rel_element.peer_id == doc_brown.get_id() + assert rel_element.peer_label == doc_brown_label + assert rel_element.action is DiffAction.REMOVED + assert rel_element.conflict is None + properties_by_type = {p.property_type: p for p in rel_element.properties} + # is_visible is still true, although on a different peeer + assert set(properties_by_type.keys()) == { + DatabaseEdgeType.IS_RELATED, + DatabaseEdgeType.IS_PROTECTED, + DatabaseEdgeType.IS_VISIBLE, + } + related_prop = properties_by_type[DatabaseEdgeType.IS_RELATED] + assert related_prop.previous_value == doc_brown.get_id() + assert related_prop.new_value is None + assert related_prop.action is DiffAction.REMOVED + assert related_prop.conflict is None + for prop_type in (DatabaseEdgeType.IS_PROTECTED, DatabaseEdgeType.IS_VISIBLE): + protected_prop = properties_by_type[prop_type] + assert protected_prop.previous_value == "True" + assert protected_prop.new_value is None + assert protected_prop.action is DiffAction.REMOVED + assert protected_prop.conflict is None + + async def test_remove_previous_owner_on_main_again( + self, + db: InfrahubDatabase, + initial_dataset, + default_branch: Branch, + diff_branch: Branch, + diff_coordinator: DiffCoordinator, + data_06_remove_previous_owner_on_main_again, + ) -> None: + incremental_diff = await diff_coordinator.update_branch_diff( + base_branch=default_branch, diff_branch=diff_branch + ) + await self.validate_diff_data_06(db=db, enriched_diff=incremental_diff, initial_dataset=initial_dataset) + full_diff = await diff_coordinator.create_or_update_arbitrary_timeframe_diff( + base_branch=default_branch, + diff_branch=diff_branch, + from_time=Timestamp(diff_branch.created_at), + to_time=Timestamp(), + name=str(uuid4), + ) + await self.validate_diff_data_06(db=db, enriched_diff=full_diff, initial_dataset=initial_dataset) diff --git a/backend/tests/integration/diff/test_diff_update.py b/backend/tests/integration/diff/test_diff_update.py index f03151b175..b59318f10e 100644 --- a/backend/tests/integration/diff/test_diff_update.py +++ b/backend/tests/integration/diff/test_diff_update.py @@ -1,12 +1,13 @@ from __future__ import annotations -import time -from typing import TYPE_CHECKING +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any import pytest +from infrahub_sdk.exceptions import GraphQLError from infrahub.core import registry -from infrahub.core.constants import DiffAction, InfrahubKind +from infrahub.core.constants import BranchConflictKeep, DiffAction, InfrahubKind, ProposedChangeState from infrahub.core.constants.database import DatabaseEdgeType from infrahub.core.diff.model.path import BranchTrackingId, ConflictSelection, EnrichedDiffRoot from infrahub.core.diff.repository.repository import DiffRepository @@ -15,7 +16,6 @@ from infrahub.core.node import Node from infrahub.core.timestamp import Timestamp from infrahub.dependencies.registry import get_component_registry -from infrahub.graphql.enums import ConflictSelection as GraphQlConflictSelection from infrahub.services.adapters.cache.redis import RedisCache from tests.constants import TestKind from tests.helpers.schema import CAR_SCHEMA, load_schema @@ -29,16 +29,15 @@ from tests.adapters.message_bus import BusSimulator BRANCH_NAME = "branch1" - +PROPOSED_CHANGE_NAME = "branch1-pc" DIFF_UPDATE_QUERY = """ -mutation DiffUpdate($branch_name: String!) { - DiffUpdate(data: { branch: $branch_name }) { +mutation DiffUpdate($branch_name: String!, $wait_for_completion: Boolean) { + DiffUpdate(data: { branch: $branch_name, wait_for_completion: $wait_for_completion }) { ok } } """ - CONFLICT_SELECTION_QUERY = """ mutation ResolveDiffConflict($conflict_id: String!, $selected_branch: ConflictSelection!) { ResolveDiffConflict(data: { conflict_id: $conflict_id, selected_branch: $selected_branch }) { @@ -47,8 +46,65 @@ } """ +PROPOSED_CHANGE_CREATE = """ +mutation ProposedChange( + $name: String!, + $source_branch: String!, + $destination_branch: String!, + ) { + CoreProposedChangeCreate( + data: { + name: {value: $name}, + source_branch: {value: $source_branch}, + destination_branch: {value: $destination_branch} + } + ) { + object { + id + } + } +} +""" + +PROPOSED_CHANGE_UPDATE = """ +mutation UpdateProposedChange( + $proposed_change_id: String!, + $state: String + ) { + CoreProposedChangeUpdate(data: + { + id: $proposed_change_id, + state: {value: $state} + } + ) { + ok + } +} +""" + + +@dataclass +class TrackedConflict: + conflict_id: str + keep_branch: BranchConflictKeep + conflict_selection: ConflictSelection + expected_value: Any + node_id: str + class TestDiffUpdateConflict(TestInfrahubApp): + _tracked_items: dict[str, TrackedConflict] = {} + + @classmethod + def setup_class(cls) -> None: + cls._tracked_items = {} + + def track_item(self, name: str, data: TrackedConflict) -> None: + self._tracked_items[name] = data + + def retrieve_item(self, name: str) -> TrackedConflict: + return self._tracked_items[name] + @pytest.fixture(scope="class") async def initial_dataset( self, @@ -56,14 +112,26 @@ async def initial_dataset( default_branch, client: InfrahubClient, bus_simulator: BusSimulator, - ) -> None: + ) -> dict[str, Node]: await load_schema(db, schema=CAR_SCHEMA) john = await Node.init(schema=TestKind.PERSON, db=db) await john.new(db=db, name="John", height=175, description="The famous Joe Doe") await john.save(db=db) + kara = await Node.init(schema=TestKind.PERSON, db=db) + await kara.new(db=db, name="Kara Thrace", height=165, description="Starbuck") + await kara.save(db=db) + murphy = await Node.init(schema=TestKind.PERSON, db=db) + await murphy.new(db=db, name="Alex Murphy", height=185, description="Robocop") + await murphy.save(db=db) koenigsegg = await Node.init(schema=TestKind.MANUFACTURER, db=db) - await koenigsegg.new(db=db, name="Koenigsegg") + await koenigsegg.new(db=db, name="Koenigsegg", customers=[john]) await koenigsegg.save(db=db) + omnicorp = await Node.init(schema=TestKind.MANUFACTURER, db=db) + await omnicorp.new(db=db, name="Omnicorp", customers=[murphy]) + await omnicorp.save(db=db) + cyberdyne = await Node.init(schema=TestKind.MANUFACTURER, db=db) + await cyberdyne.new(db=db, name="Cyberdyne") + await cyberdyne.save(db=db) people = await Node.init(schema=InfrahubKind.STANDARDGROUP, db=db) await people.new(db=db, name="people", members=[john]) await people.save(db=db) @@ -78,9 +146,42 @@ async def initial_dataset( manufacturer=koenigsegg, ) await jesko.save(db=db) + t_800 = await Node.init(schema=TestKind.CAR, db=db) + await t_800.new( + db=db, + name="Cyberdyne systems model 101", + color="Chrome", + description="killing machine with secret heart of gold", + owner=john, + manufacturer=cyberdyne, + ) + await t_800.save(db=db) + ed_209 = await Node.init(schema=TestKind.CAR, db=db) + await ed_209.new( + db=db, + name="ED-209", + color="Chrome", + description="still working on doing stairs", + owner=john, + manufacturer=omnicorp, + ) + await ed_209.save(db=db) bus_simulator.service.cache = RedisCache() + return { + "john": john, + "kara": kara, + "murphy": murphy, + "koenigsegg": koenigsegg, + "omnicorp": omnicorp, + "cyberdyne": cyberdyne, + "people": people, + "jesko": jesko, + "t_800": t_800, + "ed_209": ed_209, + } + @pytest.fixture(scope="class") async def create_diff(self, db: InfrahubDatabase, initial_dataset, client: InfrahubClient) -> None: branch1 = await create_branch(db=db, branch_name=BRANCH_NAME) @@ -106,6 +207,21 @@ async def get_branch_diff(db: InfrahubDatabase, branch: Branch) -> EnrichedDiffR diff_branch_name=BRANCH_NAME, ) + async def _get_proposed_change_and_data_validator(self, db) -> tuple[Node, Node]: + pcs = await NodeManager.query( + db=db, schema=InfrahubKind.PROPOSEDCHANGE, filters={"name__value": PROPOSED_CHANGE_NAME} + ) + assert len(pcs) == 1 + pc = pcs[0] + validators = await pc.validations.get_peers(db=db) + data_validator = None + for v in validators.values(): + if v.get_kind() == InfrahubKind.DATAVALIDATOR: + data_validator = v + + assert data_validator + return (pc, data_validator) + async def test_diff_first_update( self, db: InfrahubDatabase, initial_dataset, create_diff, client: InfrahubClient ) -> None: @@ -140,34 +256,176 @@ async def test_diff_second_update( assert len(diff.nodes) == 3 - async def test_diff_add_and_resolve_conflict( - self, db: InfrahubDatabase, initial_dataset, default_branch, client: InfrahubClient + async def test_diff_deleted_ed_209( + self, db: InfrahubDatabase, initial_dataset, create_diff, client: InfrahubClient ) -> None: - """Validate if the diff is properly updated the second time""" + branch1 = registry.get_branch_from_registry(branch=BRANCH_NAME) + + omnicorp_id = initial_dataset["omnicorp"].get_id() + omnicorp_node = await NodeManager.get_one(db=db, id=omnicorp_id) + omnicorp_label = await omnicorp_node.render_display_label(db=db) + john_id = initial_dataset["john"].get_id() + john_node = await NodeManager.get_one(db=db, id=john_id) + john_label = await john_node.render_display_label(db=db) + ed_209_id = initial_dataset["ed_209"].get_id() + ed_209_branch = await NodeManager.get_one(db=db, branch=branch1, id=ed_209_id) + ed_209_label = await ed_209_branch.render_display_label(db=db) + await ed_209_branch.delete(db=db) + + result = await client.execute_graphql(query=DIFF_UPDATE_QUERY, variables={"branch_name": BRANCH_NAME}) + assert result["DiffUpdate"]["ok"] + + # Validate if the diff has been updated properly + diff_branch = registry.get_branch_from_registry(branch=BRANCH_NAME) + diff = await self.get_branch_diff(db=db, branch=diff_branch) + assert len(diff.nodes) == 5 + nodes_by_id = {n.uuid: n for n in diff.nodes} + ed_209_node = nodes_by_id[ed_209_id] + assert ed_209_node.contains_conflict is False + assert ed_209_node.action is DiffAction.REMOVED + assert ed_209_node.label == ed_209_label + attributes_by_name = {a.name: a for a in ed_209_node.attributes} + assert set(attributes_by_name.keys()) == {"name", "color", "description"} + for attr_node in attributes_by_name.values(): + assert attr_node.action is DiffAction.REMOVED + assert attr_node.contains_conflict is False + properties_by_type = {p.property_type: p for p in attr_node.properties} + assert set(properties_by_type.keys()) == { + DatabaseEdgeType.HAS_VALUE, + DatabaseEdgeType.IS_PROTECTED, + DatabaseEdgeType.IS_VISIBLE, + } + for prop_diff in properties_by_type.values(): + assert prop_diff.action is DiffAction.REMOVED + assert prop_diff.new_value is None + relationships_by_name = {r.name: r for r in ed_209_node.relationships} + assert set(relationships_by_name.keys()) == {"manufacturer", "owner"} + manufacturer_rel = relationships_by_name["manufacturer"] + assert manufacturer_rel.action is DiffAction.REMOVED + assert manufacturer_rel.contains_conflict is False + assert len(manufacturer_rel.relationships) == 1 + manufacturer_element = manufacturer_rel.relationships.pop() + assert manufacturer_element.action is DiffAction.REMOVED + assert manufacturer_element.contains_conflict is False + assert manufacturer_element.peer_id == omnicorp_id + assert manufacturer_element.peer_label == omnicorp_label + properties_by_type = {p.property_type: p for p in manufacturer_element.properties} + assert set(properties_by_type.keys()) == { + DatabaseEdgeType.IS_RELATED, + DatabaseEdgeType.IS_PROTECTED, + DatabaseEdgeType.IS_VISIBLE, + } + for prop_diff in properties_by_type.values(): + assert prop_diff.action is DiffAction.REMOVED + assert prop_diff.new_value is None + related_prop = properties_by_type[DatabaseEdgeType.IS_RELATED] + assert related_prop.previous_label == omnicorp_label + assert related_prop.new_label is None + owner_rel = relationships_by_name["owner"] + assert owner_rel.action is DiffAction.REMOVED + assert owner_rel.contains_conflict is False + assert len(owner_rel.relationships) == 1 + owner_element = owner_rel.relationships.pop() + assert owner_element.action is DiffAction.REMOVED + assert owner_element.contains_conflict is False + assert owner_element.peer_id == john_id + assert owner_element.peer_label == john_label + properties_by_type = {p.property_type: p for p in owner_element.properties} + assert set(properties_by_type.keys()) == { + DatabaseEdgeType.IS_RELATED, + DatabaseEdgeType.IS_PROTECTED, + DatabaseEdgeType.IS_VISIBLE, + } + for prop_diff in properties_by_type.values(): + assert prop_diff.action is DiffAction.REMOVED + assert prop_diff.new_value is None + related_prop = properties_by_type[DatabaseEdgeType.IS_RELATED] + assert related_prop.previous_label == john_label + assert related_prop.new_label is None + omnicorp_node = nodes_by_id[omnicorp_id] + assert omnicorp_node.action is DiffAction.UPDATED + assert omnicorp_node.contains_conflict is False + assert omnicorp_node.label == omnicorp_label + assert len(omnicorp_node.attributes) == 0 + assert len(omnicorp_node.relationships) == 1 + relationship_diff = omnicorp_node.relationships.pop() + assert relationship_diff.name == "cars" + assert relationship_diff.action is DiffAction.UPDATED + assert relationship_diff.contains_conflict is False + assert len(relationship_diff.relationships) == 1 + relationship_element = relationship_diff.relationships.pop() + assert relationship_element.action is DiffAction.REMOVED + assert relationship_element.contains_conflict is False + assert relationship_element.peer_id == ed_209_id + assert relationship_element.peer_label == ed_209_label + properties_by_type = {p.property_type: p for p in relationship_element.properties} + assert set(properties_by_type.keys()) == { + DatabaseEdgeType.IS_RELATED, + DatabaseEdgeType.IS_PROTECTED, + DatabaseEdgeType.IS_VISIBLE, + } + for prop_diff in properties_by_type.values(): + assert prop_diff.action is DiffAction.REMOVED + assert prop_diff.new_value is None + related_prop = properties_by_type[DatabaseEdgeType.IS_RELATED] + assert related_prop.previous_value == ed_209_id + assert related_prop.previous_label == ed_209_label + assert related_prop.new_label is None + john_node = nodes_by_id[john_id] + assert john_node.action is DiffAction.UPDATED + assert john_node.contains_conflict is False + assert john_node.label == john_label + assert len(john_node.relationships) == 1 + relationship_diff = john_node.relationships.pop() + assert relationship_diff.name == "cars" + assert relationship_diff.action is DiffAction.UPDATED + assert relationship_diff.contains_conflict is False + assert len(relationship_diff.relationships) == 1 + relationship_element = relationship_diff.relationships.pop() + assert relationship_element.action is DiffAction.REMOVED + assert relationship_element.contains_conflict is False + assert relationship_element.peer_id == ed_209_id + assert relationship_element.peer_label == ed_209_label + assert set(properties_by_type.keys()) == { + DatabaseEdgeType.IS_RELATED, + DatabaseEdgeType.IS_PROTECTED, + DatabaseEdgeType.IS_VISIBLE, + } + for prop_diff in properties_by_type.values(): + assert prop_diff.action is DiffAction.REMOVED + assert prop_diff.new_value is None + related_prop = properties_by_type[DatabaseEdgeType.IS_RELATED] + assert related_prop.previous_label == ed_209_label + assert related_prop.new_label is None + + async def test_diff_add_attribute_value_conflict( + self, db: InfrahubDatabase, initial_dataset, default_branch, client: InfrahubClient + ) -> None: john_main = await NodeManager.get_one_by_id_or_default_filter( db=db, id="John", kind=TestKind.PERSON, branch=default_branch ) john_main.age.value = 402 await john_main.save(db=db) - to_time = Timestamp() + changes_done_time = Timestamp() - result = await client.execute_graphql(query=DIFF_UPDATE_QUERY, variables={"branch_name": BRANCH_NAME}) + result = await client.execute_graphql( + query=DIFF_UPDATE_QUERY, variables={"branch_name": BRANCH_NAME, "wait_for_completion": True} + ) assert result["DiffUpdate"]["ok"] - # Validate if the diff has been updated properly diff_branch = registry.get_branch_from_registry(branch=BRANCH_NAME) - for _ in range(10): - diff = await self.get_branch_diff(db=db, branch=diff_branch) - if diff.to_time > to_time: - break - time.sleep(0.5) + diff = await self.get_branch_diff(db=db, branch=diff_branch) - assert len(diff.nodes) == 3 + assert diff.to_time > changes_done_time + assert len(diff.nodes) == 5 nodes_by_id = {n.uuid: n for n in diff.nodes} john_node = nodes_by_id[john_main.get_id()] + assert john_node.contains_conflict assert len(john_node.attributes) == 1 age_attribute = john_node.attributes.pop() + assert age_attribute.name == "age" + assert age_attribute.contains_conflict properties_by_type = {p.property_type: p for p in age_attribute.properties} value_property = properties_by_type[DatabaseEdgeType.HAS_VALUE] assert value_property.conflict @@ -176,15 +434,354 @@ async def test_diff_add_and_resolve_conflict( assert conflict.base_branch_value == "402" assert conflict.diff_branch_action is DiffAction.ADDED assert conflict.diff_branch_value == "26" + self.track_item( + "attribute_value", + TrackedConflict( + conflict_id=conflict.uuid, + keep_branch=BranchConflictKeep.SOURCE, + conflict_selection=ConflictSelection.DIFF_BRANCH, + expected_value=26, + node_id=john_node.uuid, + ), + ) + + async def test_add_cardinality_one_peer_conflict( + self, db: InfrahubDatabase, initial_dataset, default_branch, client: InfrahubClient + ) -> None: + diff_branch = registry.get_branch_from_registry(branch=BRANCH_NAME) + jesko_id = initial_dataset["jesko"].get_id() + cyberdyne_id = initial_dataset["cyberdyne"].get_id() + cyberdyne_node = await NodeManager.get_one(db=db, id=cyberdyne_id) + cyberdyne_label = await cyberdyne_node.render_display_label(db=db) + omnicorp_id = initial_dataset["omnicorp"].get_id() + omnicorp_node = await NodeManager.get_one(db=db, id=omnicorp_id) + omnicorp_label = await omnicorp_node.render_display_label(db=db) + jesko_main = await NodeManager.get_one(db=db, branch=default_branch, id=jesko_id) + await jesko_main.manufacturer.update(db=db, data={"id": cyberdyne_id}) + await jesko_main.save(db=db) + jesko_branch = await NodeManager.get_one(db=db, branch=diff_branch, id=jesko_id) + await jesko_branch.manufacturer.update(db=db, data={"id": omnicorp_id}) + await jesko_branch.save(db=db) + changes_done_time = Timestamp() + + result = await client.execute_graphql( + query=DIFF_UPDATE_QUERY, variables={"branch_name": BRANCH_NAME, "wait_for_completion": True} + ) + assert result["DiffUpdate"]["ok"] + + diff = await self.get_branch_diff(db=db, branch=diff_branch) + + assert diff.to_time > changes_done_time + assert len(diff.nodes) == 7 + nodes_by_id = {n.uuid: n for n in diff.nodes} + jesko_node = nodes_by_id[jesko_id] + assert jesko_node.action is DiffAction.UPDATED + assert jesko_node.contains_conflict + assert len(jesko_node.attributes) == 0 + assert len(jesko_node.relationships) == 1 + rels_by_name = {r.name: r for r in jesko_node.relationships} + manufacturer_rel = rels_by_name["manufacturer"] + assert manufacturer_rel.contains_conflict + assert manufacturer_rel.action is DiffAction.UPDATED + assert len(manufacturer_rel.relationships) == 1 + elements_by_peer_id = {e.peer_id: e for e in manufacturer_rel.relationships} + manufacturer_element = elements_by_peer_id[omnicorp_id] + assert manufacturer_element.contains_conflict + assert manufacturer_element.action is DiffAction.UPDATED + assert manufacturer_element.conflict + assert manufacturer_element.conflict.base_branch_action is DiffAction.UPDATED + assert manufacturer_element.conflict.base_branch_value == cyberdyne_id + assert manufacturer_element.conflict.base_branch_label == cyberdyne_label + assert manufacturer_element.conflict.diff_branch_action is DiffAction.UPDATED + assert manufacturer_element.conflict.diff_branch_value == omnicorp_id + assert manufacturer_element.conflict.diff_branch_label == omnicorp_label + assert manufacturer_element.conflict.selected_branch is None + self.track_item( + "peer_conflict", + TrackedConflict( + conflict_id=manufacturer_element.conflict.uuid, + keep_branch=BranchConflictKeep.TARGET, + conflict_selection=ConflictSelection.BASE_BRANCH, + expected_value=cyberdyne_id, + node_id=jesko_id, + ), + ) + + async def test_add_cardinality_one_peer_property_conflict( + self, db: InfrahubDatabase, initial_dataset, default_branch, client: InfrahubClient + ) -> None: + diff_branch = registry.get_branch_from_registry(branch=BRANCH_NAME) + t_800_id = initial_dataset["t_800"].get_id() + john_id = initial_dataset["john"].get_id() + john_node = await NodeManager.get_one(db=db, id=john_id) + john_label = await john_node.render_display_label(db=db) + cyberdyne_id = initial_dataset["cyberdyne"].get_id() + cyberdyne_node = await NodeManager.get_one(db=db, id=cyberdyne_id) + cyberdyne_label = await cyberdyne_node.render_display_label(db=db) + omnicorp_id = initial_dataset["omnicorp"].get_id() + omnicorp_node = await NodeManager.get_one(db=db, id=omnicorp_id) + omnicorp_label = await omnicorp_node.render_display_label(db=db) + t_800_main = await NodeManager.get_one(db=db, branch=default_branch, id=t_800_id) + await t_800_main.owner.update(db=db, data={"id": john_id, "_relation__owner": cyberdyne_id}) + await t_800_main.save(db=db) + t_800_branch = await NodeManager.get_one(db=db, branch=diff_branch, id=t_800_id) + await t_800_branch.owner.update(db=db, data={"id": john_id, "_relation__owner": omnicorp_id}) + await t_800_branch.save(db=db) + t_800_label = await t_800_main.render_display_label(db=db) + changes_done_time = Timestamp() + + result = await client.execute_graphql( + query=DIFF_UPDATE_QUERY, variables={"branch_name": BRANCH_NAME, "wait_for_completion": True} + ) + assert result["DiffUpdate"]["ok"] + + diff = await self.get_branch_diff(db=db, branch=diff_branch) + + assert diff.to_time > changes_done_time + assert len(diff.nodes) == 8 + nodes_by_id = {n.uuid: n for n in diff.nodes} + t_800_node = nodes_by_id[t_800_id] + assert t_800_node.action is DiffAction.UPDATED + assert t_800_node.contains_conflict + assert len(t_800_node.attributes) == 0 + assert len(t_800_node.relationships) == 1 + rels_by_name = {r.name: r for r in t_800_node.relationships} + owner_rel = rels_by_name["owner"] + assert owner_rel.action is DiffAction.UPDATED + assert owner_rel.contains_conflict + assert len(owner_rel.relationships) == 1 + elements_by_peer_id = {e.peer_id: e for e in owner_rel.relationships} + owner_element = elements_by_peer_id[john_id] + assert owner_element.contains_conflict + assert owner_element.peer_label == john_label + properties_by_type = {p.property_type: p for p in owner_element.properties} + assert set(properties_by_type.keys()) == {DatabaseEdgeType.HAS_OWNER, DatabaseEdgeType.IS_RELATED} + is_related_property = properties_by_type[DatabaseEdgeType.IS_RELATED] + assert is_related_property.action is DiffAction.UNCHANGED + assert is_related_property.conflict is None + assert is_related_property.previous_value == john_id + assert is_related_property.new_value == john_id + assert is_related_property.previous_label == john_label + assert is_related_property.new_label == john_label + owner_property = properties_by_type[DatabaseEdgeType.HAS_OWNER] + assert owner_property.action is DiffAction.ADDED + assert owner_property.previous_value is None + assert owner_property.new_value == omnicorp_id + assert owner_property.new_label == omnicorp_label + assert owner_property.conflict + assert owner_property.conflict.base_branch_action is DiffAction.ADDED + assert owner_property.conflict.base_branch_value == cyberdyne_id + assert owner_property.conflict.base_branch_label == cyberdyne_label + assert owner_property.conflict.diff_branch_action is DiffAction.ADDED + assert owner_property.conflict.diff_branch_value == omnicorp_id + assert owner_property.conflict.diff_branch_label == omnicorp_label + assert owner_property.conflict.selected_branch is None + self.track_item( + "cardinality_one_property_conflict_a", + TrackedConflict( + conflict_id=owner_property.conflict.uuid, + keep_branch=BranchConflictKeep.SOURCE, + conflict_selection=ConflictSelection.DIFF_BRANCH, + expected_value=omnicorp_id, + node_id=t_800_id, + ), + ) + john_node = nodes_by_id[john_id] + assert john_node.action is DiffAction.UPDATED + assert john_node.contains_conflict + assert len(john_node.relationships) == 1 + rels_by_name = {r.name: r for r in john_node.relationships} + cars_rel = rels_by_name["cars"] + assert cars_rel.contains_conflict + assert cars_rel.action is DiffAction.UPDATED + assert len(cars_rel.relationships) == 2 + elements_by_peer_id = {e.peer_id: e for e in cars_rel.relationships} + car_element = elements_by_peer_id[t_800_id] + assert car_element.contains_conflict + assert car_element.peer_label == t_800_label + properties_by_type = {p.property_type: p for p in car_element.properties} + assert set(properties_by_type.keys()) == {DatabaseEdgeType.HAS_OWNER, DatabaseEdgeType.IS_RELATED} + is_related_property = properties_by_type[DatabaseEdgeType.IS_RELATED] + assert is_related_property.action is DiffAction.UNCHANGED + assert is_related_property.conflict is None + assert is_related_property.previous_value == t_800_id + assert is_related_property.new_value == t_800_id + assert is_related_property.previous_label == t_800_label + assert is_related_property.new_label == t_800_label + owner_property = properties_by_type[DatabaseEdgeType.HAS_OWNER] + assert owner_property.action is DiffAction.ADDED + assert owner_property.previous_value is None + assert owner_property.new_value == omnicorp_id + assert owner_property.conflict + assert owner_property.conflict.base_branch_action is DiffAction.ADDED + assert owner_property.conflict.base_branch_value == cyberdyne_id + assert owner_property.conflict.base_branch_label == cyberdyne_label + assert owner_property.conflict.diff_branch_action is DiffAction.ADDED + assert owner_property.conflict.diff_branch_value == omnicorp_id + assert owner_property.conflict.diff_branch_label == omnicorp_label + assert owner_property.conflict.selected_branch is None + self.track_item( + "cardinality_one_property_conflict_b", + TrackedConflict( + conflict_id=owner_property.conflict.uuid, + keep_branch=BranchConflictKeep.SOURCE, + conflict_selection=ConflictSelection.DIFF_BRANCH, + expected_value=omnicorp_id, + node_id=john_id, + ), + ) + + async def test_add_cardinality_many_peer_property_conflict( + self, db: InfrahubDatabase, initial_dataset, default_branch, client: InfrahubClient + ) -> None: + diff_branch = registry.get_branch_from_registry(branch=BRANCH_NAME) + omnicorp_id = initial_dataset["omnicorp"].get_id() + murphy_id = initial_dataset["murphy"].get_id() + + omnicorp_main = await NodeManager.get_one(db=db, branch=default_branch, id=omnicorp_id) + await omnicorp_main.customers.update(db=db, data={"id": murphy_id, "_relation__owner": omnicorp_id}) + await omnicorp_main.save(db=db) + omnicorp_branch = await NodeManager.get_one(db=db, branch=diff_branch, id=omnicorp_id) + await omnicorp_branch.customers.update(db=db, data={"id": murphy_id, "_relation__owner": murphy_id}) + await omnicorp_branch.save(db=db) + changes_done_time = Timestamp() + + result = await client.execute_graphql( + query=DIFF_UPDATE_QUERY, variables={"branch_name": BRANCH_NAME, "wait_for_completion": True} + ) + assert result["DiffUpdate"]["ok"] + + diff = await self.get_branch_diff(db=db, branch=diff_branch) + + assert diff.to_time > changes_done_time + assert len(diff.nodes) == 8 + nodes_by_id = {n.uuid: n for n in diff.nodes} + # Person has no relationship to Manufacturer, so should not be in diff + assert murphy_id not in nodes_by_id + omnicorp_node = nodes_by_id[omnicorp_id] + assert omnicorp_node.action is DiffAction.UPDATED + assert omnicorp_node.contains_conflict + assert len(omnicorp_node.attributes) == 0 + assert len(omnicorp_node.relationships) == 2 + rels_by_name = {r.name: r for r in omnicorp_node.relationships} + customers_rel = rels_by_name["customers"] + assert customers_rel.contains_conflict + assert customers_rel.action is DiffAction.UPDATED + assert len(customers_rel.relationships) == 1 + elements_by_peer_id = {e.peer_id: e for e in customers_rel.relationships} + customer_element = elements_by_peer_id[murphy_id] + assert customer_element.contains_conflict + properties_by_type = {p.property_type: p for p in customer_element.properties} + assert set(properties_by_type.keys()) == {DatabaseEdgeType.HAS_OWNER, DatabaseEdgeType.IS_RELATED} + is_related_property = properties_by_type[DatabaseEdgeType.IS_RELATED] + assert is_related_property.action is DiffAction.UNCHANGED + assert is_related_property.conflict is None + assert is_related_property.previous_value == murphy_id + assert is_related_property.new_value == murphy_id + owner_property = properties_by_type[DatabaseEdgeType.HAS_OWNER] + assert owner_property.action is DiffAction.ADDED + assert owner_property.previous_value is None + assert owner_property.new_value == murphy_id + assert owner_property.conflict + assert owner_property.conflict.base_branch_action is DiffAction.ADDED + assert owner_property.conflict.base_branch_value == omnicorp_id + assert owner_property.conflict.diff_branch_action is DiffAction.ADDED + assert owner_property.conflict.diff_branch_value == murphy_id + assert owner_property.conflict.selected_branch is None + self.track_item( + "cardinality_many_property_conflict", + TrackedConflict( + conflict_id=owner_property.conflict.uuid, + keep_branch=BranchConflictKeep.SOURCE, + conflict_selection=ConflictSelection.DIFF_BRANCH, + expected_value=murphy_id, + node_id=omnicorp_id, + ), + ) + + async def test_diff_add_node_conflict( + self, db: InfrahubDatabase, initial_dataset, default_branch, client: InfrahubClient + ) -> None: + kara_id = initial_dataset["kara"].get_id() + kara_main = await NodeManager.get_one(db=db, id=kara_id, kind=TestKind.PERSON, branch=default_branch) + await kara_main.delete(db=db) + kara_branch = await NodeManager.get_one(db=db, id=kara_id, kind=TestKind.PERSON, branch=BRANCH_NAME) + kara_branch.height.value += 1 + await kara_branch.save(db=db) + changes_done_time = Timestamp() + + result = await client.execute_graphql( + query=DIFF_UPDATE_QUERY, variables={"branch_name": BRANCH_NAME, "wait_for_completion": True} + ) + assert result["DiffUpdate"]["ok"] + + diff_branch = registry.get_branch_from_registry(branch=BRANCH_NAME) + diff = await self.get_branch_diff(db=db, branch=diff_branch) + + assert diff.to_time > changes_done_time + assert len(diff.nodes) == 9 + nodes_by_id = {n.uuid: n for n in diff.nodes} + kara_node = nodes_by_id[kara_id] + assert kara_node.action is DiffAction.UPDATED + assert kara_node.contains_conflict + assert kara_node.conflict + assert kara_node.conflict.base_branch_action is DiffAction.REMOVED + assert kara_node.conflict.base_branch_value is None + assert kara_node.conflict.diff_branch_action is DiffAction.UPDATED + assert kara_node.conflict.diff_branch_value is None + self.track_item( + "node_removed", + TrackedConflict( + conflict_id=kara_node.conflict.uuid, + keep_branch=BranchConflictKeep.SOURCE, + conflict_selection=ConflictSelection.DIFF_BRANCH, + expected_value=None, + node_id=kara_node.uuid, + ), + ) + assert len(kara_node.attributes) == 1 + height_attribute = kara_node.attributes.pop() + assert height_attribute.name == "height" + assert height_attribute.contains_conflict + properties_by_type = {p.property_type: p for p in height_attribute.properties} + value_property = properties_by_type[DatabaseEdgeType.HAS_VALUE] + assert value_property.conflict + attr_conflict = value_property.conflict + assert attr_conflict.base_branch_action is DiffAction.REMOVED + assert attr_conflict.base_branch_value is None + assert attr_conflict.diff_branch_action is DiffAction.UPDATED + assert attr_conflict.diff_branch_value == str(kara_branch.height.value) + self.track_item( + "node_removed_attribute_value", + TrackedConflict( + conflict_id=attr_conflict.uuid, + keep_branch=BranchConflictKeep.TARGET, + conflict_selection=ConflictSelection.BASE_BRANCH, + expected_value=None, + node_id=kara_node.uuid, + ), + ) + + async def test_diff_resolve_attribute_value_conflict( + self, db: InfrahubDatabase, initial_dataset, default_branch, client: InfrahubClient + ) -> None: + john_main = await NodeManager.get_one_by_id_or_default_filter( + db=db, id="John", kind=TestKind.PERSON, branch=default_branch + ) + attribute_value_conflict = self.retrieve_item("attribute_value") result = await client.execute_graphql( query=CONFLICT_SELECTION_QUERY, - variables={"conflict_id": conflict.uuid, "selected_branch": GraphQlConflictSelection.BASE_BRANCH.name}, # type: ignore[attr-defined] + variables={ + "conflict_id": attribute_value_conflict.conflict_id, + "selected_branch": attribute_value_conflict.conflict_selection.name, + }, ) assert result["ResolveDiffConflict"]["ok"] + diff_branch = registry.get_branch_from_registry(branch=BRANCH_NAME) diff = await self.get_branch_diff(db=db, branch=diff_branch) - assert len(diff.nodes) == 3 + assert len(diff.nodes) == 9 nodes_by_id = {n.uuid: n for n in diff.nodes} john_node = nodes_by_id[john_main.get_id()] assert len(john_node.attributes) == 1 @@ -193,8 +790,502 @@ async def test_diff_add_and_resolve_conflict( value_property = properties_by_type[DatabaseEdgeType.HAS_VALUE] assert value_property.conflict conflict = value_property.conflict + assert conflict.uuid == attribute_value_conflict.conflict_id assert conflict.base_branch_action is DiffAction.ADDED assert conflict.base_branch_value == "402" assert conflict.diff_branch_action is DiffAction.ADDED assert conflict.diff_branch_value == "26" - assert conflict.selected_branch is ConflictSelection.BASE_BRANCH + assert conflict.selected_branch is attribute_value_conflict.conflict_selection + + async def test_create_proposed_change_data_checks_created( + self, db: InfrahubDatabase, initial_dataset, default_branch, client: InfrahubClient + ) -> None: + result = await client.execute_graphql( + query=PROPOSED_CHANGE_CREATE, + variables={ + "name": PROPOSED_CHANGE_NAME, + "source_branch": BRANCH_NAME, + "destination_branch": default_branch.name, + }, + ) + assert result["CoreProposedChangeCreate"]["object"]["id"] + attribute_value_conflict = self.retrieve_item("attribute_value") + peer_conflict = self.retrieve_item("peer_conflict") + cardinality_one_property_conflict_a = self.retrieve_item("cardinality_one_property_conflict_a") + cardinality_one_property_conflict_b = self.retrieve_item("cardinality_one_property_conflict_b") + cardinality_many_property_conflict = self.retrieve_item("cardinality_many_property_conflict") + node_removed_conflict = self.retrieve_item("node_removed") + node_removed_attribute_value_conflict = self.retrieve_item("node_removed_attribute_value") + + _, data_validator = await self._get_proposed_change_and_data_validator(db=db) + core_data_checks = await data_validator.checks.get_peers(db=db) # type: ignore[attr-defined] + data_checks_by_conflict_id = {cdc.enriched_conflict_id.value: cdc for cdc in core_data_checks.values()} + assert set(data_checks_by_conflict_id.keys()) == { + attribute_value_conflict.conflict_id, + peer_conflict.conflict_id, + cardinality_one_property_conflict_a.conflict_id, + cardinality_one_property_conflict_b.conflict_id, + cardinality_many_property_conflict.conflict_id, + node_removed_conflict.conflict_id, + node_removed_attribute_value_conflict.conflict_id, + } + attr_value_data_check = data_checks_by_conflict_id[attribute_value_conflict.conflict_id] + peer_data_check = data_checks_by_conflict_id[peer_conflict.conflict_id] + cardinality_one_property_data_check_a = data_checks_by_conflict_id[ + cardinality_one_property_conflict_a.conflict_id + ] + cardinality_one_property_data_check_b = data_checks_by_conflict_id[ + cardinality_one_property_conflict_b.conflict_id + ] + cardinality_many_property_data_check = data_checks_by_conflict_id[ + cardinality_many_property_conflict.conflict_id + ] + node_removed_data_check = data_checks_by_conflict_id[node_removed_conflict.conflict_id] + node_removed_attr_value_data_check = data_checks_by_conflict_id[ + node_removed_attribute_value_conflict.conflict_id + ] + assert attr_value_data_check.keep_branch.value.value == attribute_value_conflict.keep_branch.value + assert peer_data_check.keep_branch.value is None + assert cardinality_one_property_data_check_a.keep_branch.value is None + assert cardinality_one_property_data_check_b.keep_branch.value is None + assert cardinality_many_property_data_check.keep_branch.value is None + assert node_removed_attr_value_data_check.keep_branch.value is None + assert node_removed_data_check.keep_branch.value is None + + async def test_resolve_peer_conflict( + self, db: InfrahubDatabase, initial_dataset, default_branch, client: InfrahubClient + ) -> None: + peer_conflict = self.retrieve_item("peer_conflict") + result = await client.execute_graphql( + query=CONFLICT_SELECTION_QUERY, + variables={ + "conflict_id": peer_conflict.conflict_id, + "selected_branch": peer_conflict.conflict_selection.name, + }, + ) + assert result["ResolveDiffConflict"]["ok"] + + diff_branch = registry.get_branch_from_registry(branch=BRANCH_NAME) + jesko_id = initial_dataset["jesko"].get_id() + omnicorp_id = initial_dataset["omnicorp"].get_id() + cyberdyne_id = initial_dataset["cyberdyne"].get_id() + diff = await self.get_branch_diff(db=db, branch=diff_branch) + + # check EnrichedDiff + assert len(diff.nodes) == 9 + nodes_by_id = {n.uuid: n for n in diff.nodes} + jesko_node = nodes_by_id[jesko_id] + assert jesko_node.action is DiffAction.UPDATED + assert len(jesko_node.attributes) == 0 + assert len(jesko_node.relationships) == 1 + rels_by_name = {r.name: r for r in jesko_node.relationships} + manufacturer_rel = rels_by_name["manufacturer"] + assert manufacturer_rel.action is DiffAction.UPDATED + assert len(manufacturer_rel.relationships) == 1 + + elements_by_peer_id = {e.peer_id: e for e in manufacturer_rel.relationships} + manufacturer_element = elements_by_peer_id[omnicorp_id] + assert manufacturer_element.action is DiffAction.UPDATED + assert manufacturer_element.conflict + assert manufacturer_element.conflict.uuid == peer_conflict.conflict_id + assert manufacturer_element.conflict.base_branch_action is DiffAction.UPDATED + assert manufacturer_element.conflict.base_branch_value == cyberdyne_id + assert manufacturer_element.conflict.diff_branch_action is DiffAction.UPDATED + assert manufacturer_element.conflict.diff_branch_value == omnicorp_id + assert manufacturer_element.conflict.selected_branch is peer_conflict.conflict_selection + # check CoreDataChecks + _, data_validator = await self._get_proposed_change_and_data_validator(db=db) + core_data_checks = await data_validator.checks.get_peers(db=db) # type: ignore[attr-defined] + data_checks_by_conflict_id = {cdc.enriched_conflict_id.value: cdc for cdc in core_data_checks.values()} + assert peer_conflict.conflict_id in data_checks_by_conflict_id + peer_data_check = data_checks_by_conflict_id[peer_conflict.conflict_id] + assert peer_data_check.keep_branch.value.value is peer_conflict.keep_branch.value + + async def test_resolve_peer_property_conflict( + self, db: InfrahubDatabase, initial_dataset, default_branch, client: InfrahubClient + ) -> None: + cardinality_one_property_conflict_a = self.retrieve_item("cardinality_one_property_conflict_a") + result = await client.execute_graphql( + query=CONFLICT_SELECTION_QUERY, + variables={ + "conflict_id": cardinality_one_property_conflict_a.conflict_id, + "selected_branch": cardinality_one_property_conflict_a.conflict_selection.name, + }, + ) + assert result["ResolveDiffConflict"]["ok"] + cardinality_one_property_conflict_b = self.retrieve_item("cardinality_one_property_conflict_b") + result = await client.execute_graphql( + query=CONFLICT_SELECTION_QUERY, + variables={ + "conflict_id": cardinality_one_property_conflict_b.conflict_id, + "selected_branch": cardinality_one_property_conflict_b.conflict_selection.name, + }, + ) + assert result["ResolveDiffConflict"]["ok"] + + diff_branch = registry.get_branch_from_registry(branch=BRANCH_NAME) + t_800_id = initial_dataset["t_800"].get_id() + john_id = initial_dataset["john"].get_id() + omnicorp_id = initial_dataset["omnicorp"].get_id() + cyberdyne_id = initial_dataset["cyberdyne"].get_id() + diff = await self.get_branch_diff(db=db, branch=diff_branch) + + # check EnrichedDiff + assert len(diff.nodes) == 9 + nodes_by_id = {n.uuid: n for n in diff.nodes} + # car-side + t_800_node = nodes_by_id[t_800_id] + assert t_800_node.action is DiffAction.UPDATED + assert len(t_800_node.attributes) == 0 + assert len(t_800_node.relationships) == 1 + rels_by_name = {r.name: r for r in t_800_node.relationships} + manufacturer_rel = rels_by_name["owner"] + assert manufacturer_rel.action is DiffAction.UPDATED + assert len(manufacturer_rel.relationships) == 1 + elements_by_peer_id = {e.peer_id: e for e in manufacturer_rel.relationships} + manufacturer_element = elements_by_peer_id[john_id] + properties_by_type = {p.property_type: p for p in manufacturer_element.properties} + assert set(properties_by_type.keys()) == {DatabaseEdgeType.HAS_OWNER, DatabaseEdgeType.IS_RELATED} + owner_property = properties_by_type[DatabaseEdgeType.HAS_OWNER] + assert owner_property.action is DiffAction.ADDED + assert owner_property.previous_value is None + assert owner_property.new_value == omnicorp_id + assert owner_property.conflict + assert owner_property.conflict.uuid == cardinality_one_property_conflict_a.conflict_id + assert owner_property.conflict.base_branch_action is DiffAction.ADDED + assert owner_property.conflict.base_branch_value == cyberdyne_id + assert owner_property.conflict.diff_branch_action is DiffAction.ADDED + assert owner_property.conflict.diff_branch_value == omnicorp_id + assert owner_property.conflict.selected_branch is cardinality_one_property_conflict_a.conflict_selection + # person-side + john_node = nodes_by_id[john_id] + assert john_node.action is DiffAction.UPDATED + assert len(john_node.relationships) == 1 + rels_by_name = {r.name: r for r in john_node.relationships} + cars_rel = rels_by_name["cars"] + assert cars_rel.action is DiffAction.UPDATED + assert len(cars_rel.relationships) == 2 + elements_by_peer_id = {e.peer_id: e for e in cars_rel.relationships} + car_element = elements_by_peer_id[t_800_id] + properties_by_type = {p.property_type: p for p in car_element.properties} + assert set(properties_by_type.keys()) == {DatabaseEdgeType.HAS_OWNER, DatabaseEdgeType.IS_RELATED} + is_related_property = properties_by_type[DatabaseEdgeType.IS_RELATED] + assert is_related_property.action is DiffAction.UNCHANGED + assert is_related_property.conflict is None + assert is_related_property.previous_value == t_800_id + assert is_related_property.new_value == t_800_id + owner_property = properties_by_type[DatabaseEdgeType.HAS_OWNER] + assert owner_property.action is DiffAction.ADDED + assert owner_property.previous_value is None + assert owner_property.new_value == omnicorp_id + assert owner_property.conflict + assert owner_property.conflict.uuid == cardinality_one_property_conflict_b.conflict_id + assert owner_property.conflict.base_branch_action is DiffAction.ADDED + assert owner_property.conflict.base_branch_value == cyberdyne_id + assert owner_property.conflict.diff_branch_action is DiffAction.ADDED + assert owner_property.conflict.diff_branch_value == omnicorp_id + assert owner_property.conflict.selected_branch is cardinality_one_property_conflict_b.conflict_selection + + # check CoreDataChecks + _, data_validator = await self._get_proposed_change_and_data_validator(db=db) + core_data_checks = await data_validator.checks.get_peers(db=db) # type: ignore[attr-defined] + data_checks_by_conflict_id = {cdc.enriched_conflict_id.value: cdc for cdc in core_data_checks.values()} + assert cardinality_one_property_conflict_a.conflict_id in data_checks_by_conflict_id + assert cardinality_one_property_conflict_b.conflict_id in data_checks_by_conflict_id + data_check_a = data_checks_by_conflict_id[cardinality_one_property_conflict_a.conflict_id] + assert data_check_a.keep_branch.value.value == cardinality_one_property_conflict_a.keep_branch.value + data_check_b = data_checks_by_conflict_id[cardinality_one_property_conflict_b.conflict_id] + assert data_check_b.keep_branch.value.value == cardinality_one_property_conflict_b.keep_branch.value + + async def test_resolve_cardinality_many_property_conflict( + self, db: InfrahubDatabase, initial_dataset, default_branch, client: InfrahubClient + ) -> None: + cardinality_many_property_conflict = self.retrieve_item("cardinality_many_property_conflict") + result = await client.execute_graphql( + query=CONFLICT_SELECTION_QUERY, + variables={ + "conflict_id": cardinality_many_property_conflict.conflict_id, + "selected_branch": cardinality_many_property_conflict.conflict_selection.name, + }, + ) + assert result["ResolveDiffConflict"]["ok"] + + diff_branch = registry.get_branch_from_registry(branch=BRANCH_NAME) + omnicorp_id = initial_dataset["omnicorp"].get_id() + murphy_id = initial_dataset["murphy"].get_id() + diff = await self.get_branch_diff(db=db, branch=diff_branch) + + # check EnrichedDiff + assert len(diff.nodes) == 9 + nodes_by_id = {n.uuid: n for n in diff.nodes} + omnicorp_node = nodes_by_id[omnicorp_id] + assert omnicorp_node.action is DiffAction.UPDATED + assert len(omnicorp_node.attributes) == 0 + assert len(omnicorp_node.relationships) == 2 + rels_by_name = {r.name: r for r in omnicorp_node.relationships} + customers_rel = rels_by_name["customers"] + assert customers_rel.action is DiffAction.UPDATED + assert len(customers_rel.relationships) == 1 + elements_by_peer_id = {e.peer_id: e for e in customers_rel.relationships} + customers_element = elements_by_peer_id[murphy_id] + assert customers_element.action is DiffAction.UPDATED + properties_by_type = {p.property_type: p for p in customers_element.properties} + assert set(properties_by_type.keys()) == {DatabaseEdgeType.HAS_OWNER, DatabaseEdgeType.IS_RELATED} + is_related_property = properties_by_type[DatabaseEdgeType.IS_RELATED] + assert is_related_property.action is DiffAction.UNCHANGED + assert is_related_property.conflict is None + assert is_related_property.previous_value == murphy_id + assert is_related_property.new_value == murphy_id + owner_property = properties_by_type[DatabaseEdgeType.HAS_OWNER] + assert owner_property.action is DiffAction.ADDED + assert owner_property.previous_value is None + assert owner_property.new_value == murphy_id + assert owner_property.conflict + assert owner_property.conflict.uuid == cardinality_many_property_conflict.conflict_id + assert owner_property.conflict.base_branch_action is DiffAction.ADDED + assert owner_property.conflict.base_branch_value == omnicorp_id + assert owner_property.conflict.diff_branch_action is DiffAction.ADDED + assert owner_property.conflict.diff_branch_value == murphy_id + assert owner_property.conflict.selected_branch is cardinality_many_property_conflict.conflict_selection + # check CoreDataChecks + _, data_validator = await self._get_proposed_change_and_data_validator(db=db) + core_data_checks = await data_validator.checks.get_peers(db=db) # type: ignore[attr-defined] + data_checks_by_conflict_id = {cdc.enriched_conflict_id.value: cdc for cdc in core_data_checks.values()} + assert cardinality_many_property_conflict.conflict_id in data_checks_by_conflict_id + peer_data_check = data_checks_by_conflict_id[cardinality_many_property_conflict.conflict_id] + assert peer_data_check.keep_branch.value.value is cardinality_many_property_conflict.keep_branch.value + + async def test_merge_fails_with_conflicts( + self, db: InfrahubDatabase, initial_dataset, default_branch, client: InfrahubClient + ) -> None: + pc, _ = await self._get_proposed_change_and_data_validator(db=db) + with pytest.raises(GraphQLError, match=r"Data conflicts found"): + await client.execute_graphql( + query=PROPOSED_CHANGE_UPDATE, + variables={ + "proposed_change_id": pc.get_id(), + "state": ProposedChangeState.MERGED.value, + }, + ) + + async def test_diff_resolve_node_removed_conflicts( + self, db: InfrahubDatabase, initial_dataset, default_branch, client: InfrahubClient + ) -> None: + node_removed_conflict = self.retrieve_item("node_removed") + node_removed_attribute_value_conflict = self.retrieve_item("node_removed_attribute_value") + result = await client.execute_graphql( + query=CONFLICT_SELECTION_QUERY, + variables={ + "conflict_id": node_removed_conflict.conflict_id, + "selected_branch": node_removed_conflict.conflict_selection.name, + }, + ) + assert result["ResolveDiffConflict"]["ok"] + result = await client.execute_graphql( + query=CONFLICT_SELECTION_QUERY, + variables={ + "conflict_id": node_removed_attribute_value_conflict.conflict_id, + "selected_branch": node_removed_attribute_value_conflict.conflict_selection.name, + }, + ) + assert result["ResolveDiffConflict"]["ok"] + + diff_branch = registry.get_branch_from_registry(branch=BRANCH_NAME) + kara_main = initial_dataset["kara"] + kara_branch = await NodeManager.get_one(db=db, branch=diff_branch, id=kara_main.get_id()) + diff = await self.get_branch_diff(db=db, branch=diff_branch) + + # check EnrichedDiff + assert len(diff.nodes) == 9 + nodes_by_id = {n.uuid: n for n in diff.nodes} + kara_node = nodes_by_id[kara_main.get_id()] + assert kara_node.action is DiffAction.UPDATED + assert kara_node.conflict + assert kara_node.conflict.uuid == node_removed_conflict.conflict_id + assert kara_node.conflict.base_branch_action is DiffAction.REMOVED + assert kara_node.conflict.base_branch_value is None + assert kara_node.conflict.diff_branch_action is DiffAction.UPDATED + assert kara_node.conflict.diff_branch_value is None + assert kara_node.conflict.selected_branch == node_removed_conflict.conflict_selection + assert len(kara_node.attributes) == 1 + height_attribute = kara_node.attributes.pop() + assert height_attribute.name == "height" + properties_by_type = {p.property_type: p for p in height_attribute.properties} + value_property = properties_by_type[DatabaseEdgeType.HAS_VALUE] + assert value_property.conflict + attr_conflict = value_property.conflict + assert attr_conflict.uuid == node_removed_attribute_value_conflict.conflict_id + assert attr_conflict.base_branch_action is DiffAction.REMOVED + assert attr_conflict.base_branch_value is None + assert attr_conflict.diff_branch_action is DiffAction.UPDATED + assert attr_conflict.diff_branch_value == str(kara_branch.height.value) + assert attr_conflict.selected_branch == node_removed_attribute_value_conflict.conflict_selection + # check CoreDataChecks + _, data_validator = await self._get_proposed_change_and_data_validator(db=db) + core_data_checks = await data_validator.checks.get_peers(db=db) # type: ignore[attr-defined] + data_checks_by_conflict_id = {cdc.enriched_conflict_id.value: cdc for cdc in core_data_checks.values()} + assert { + node_removed_conflict.conflict_id, + node_removed_attribute_value_conflict.conflict_id, + } <= set(data_checks_by_conflict_id.keys()) + node_removed_data_check = data_checks_by_conflict_id[node_removed_conflict.conflict_id] + node_removed_attr_value_data_check = data_checks_by_conflict_id[ + node_removed_attribute_value_conflict.conflict_id + ] + assert node_removed_data_check.keep_branch.value.value == node_removed_conflict.keep_branch.value + assert ( + node_removed_attr_value_data_check.keep_branch.value.value + == node_removed_attribute_value_conflict.keep_branch.value + ) + + async def test_expected_core_data_checks( + self, db: InfrahubDatabase, initial_dataset, default_branch, client: InfrahubClient + ) -> None: + attribute_value_conflict = self.retrieve_item("attribute_value") + peer_conflict = self.retrieve_item("peer_conflict") + cardinality_one_property_conflict_a = self.retrieve_item("cardinality_one_property_conflict_a") + cardinality_one_property_conflict_b = self.retrieve_item("cardinality_one_property_conflict_b") + cardinality_many_property_conflict = self.retrieve_item("cardinality_many_property_conflict") + node_removed_conflict = self.retrieve_item("node_removed") + node_removed_attribute_value_conflict = self.retrieve_item("node_removed_attribute_value") + tracked_conflicts = [ + attribute_value_conflict, + peer_conflict, + cardinality_one_property_conflict_a, + cardinality_one_property_conflict_b, + cardinality_many_property_conflict, + node_removed_conflict, + node_removed_attribute_value_conflict, + ] + + # check CoreDataChecks + _, data_validator = await self._get_proposed_change_and_data_validator(db=db) + core_data_checks = await data_validator.checks.get_peers(db=db) # type: ignore[attr-defined] + data_checks_by_conflict_id = {cdc.enriched_conflict_id.value: cdc for cdc in core_data_checks.values()} + assert set(data_checks_by_conflict_id.keys()) == {tc.conflict_id for tc in tracked_conflicts} + for tracked_conflict in tracked_conflicts: + data_check = data_checks_by_conflict_id[tracked_conflict.conflict_id] + assert data_check.keep_branch.value.value == tracked_conflict.keep_branch.value + + async def test_create_another_proposed_change_data_checks_created( + self, db: InfrahubDatabase, initial_dataset, default_branch, client: InfrahubClient + ) -> None: + # verify duplicate data checks can be created + result = await client.execute_graphql( + query=PROPOSED_CHANGE_CREATE, + variables={ + "name": PROPOSED_CHANGE_NAME + "2", + "source_branch": BRANCH_NAME, + "destination_branch": default_branch.name, + }, + ) + assert result["CoreProposedChangeCreate"]["object"]["id"] + pc_id = result["CoreProposedChangeCreate"]["object"]["id"] + attribute_value_conflict = self.retrieve_item("attribute_value") + peer_conflict = self.retrieve_item("peer_conflict") + cardinality_one_property_conflict_a = self.retrieve_item("cardinality_one_property_conflict_a") + cardinality_one_property_conflict_b = self.retrieve_item("cardinality_one_property_conflict_b") + cardinality_many_property_conflict = self.retrieve_item("cardinality_many_property_conflict") + node_removed_conflict = self.retrieve_item("node_removed") + node_removed_attribute_value_conflict = self.retrieve_item("node_removed_attribute_value") + + pc = await NodeManager.get_one(db=db, id=pc_id) + validators = await pc.validations.get_peers(db=db) + data_validator = None + for v in validators.values(): + if v.get_kind() == InfrahubKind.DATAVALIDATOR: + data_validator = v + assert data_validator + core_data_checks = await data_validator.checks.get_peers(db=db) # type: ignore[attr-defined] + data_checks_by_conflict_id = {cdc.enriched_conflict_id.value: cdc for cdc in core_data_checks.values()} + assert set(data_checks_by_conflict_id.keys()) == { + attribute_value_conflict.conflict_id, + peer_conflict.conflict_id, + cardinality_one_property_conflict_a.conflict_id, + cardinality_one_property_conflict_b.conflict_id, + cardinality_many_property_conflict.conflict_id, + node_removed_conflict.conflict_id, + node_removed_attribute_value_conflict.conflict_id, + } + assert len(core_data_checks) == len(data_checks_by_conflict_id) + + async def test_merge_proposed_change( + self, db: InfrahubDatabase, initial_dataset, default_branch, client: InfrahubClient + ) -> None: + pc, _ = await self._get_proposed_change_and_data_validator(db=db) + result = await client.execute_graphql( + query=PROPOSED_CHANGE_UPDATE, + variables={ + "proposed_change_id": pc.get_id(), + "state": ProposedChangeState.MERGED.value, + }, + ) + assert result["CoreProposedChangeUpdate"]["ok"] + + # added nodes + richard = await NodeManager.get_one_by_id_or_default_filter( + db=db, kind="TestingPerson", id="Richard", branch=default_branch + ) + assert richard.name.value == "Richard" + assert richard.height.value == 180 + assert richard.description.value == "The less famous Richard Doe" + bob = await NodeManager.get_one_by_id_or_default_filter( + db=db, kind="TestingPerson", id="Bob", branch=default_branch + ) + assert bob.name.value == "Bob" + assert bob.height.value == 123 + assert bob.description.value == "The less famous Bob" + + # deleted nodes + ed_209_id = initial_dataset["ed_209"].get_id() + ed_209_node = await NodeManager.get_one(db=db, branch=default_branch, id=ed_209_id) + assert ed_209_node is None + john_id = initial_dataset["john"].get_id() + john_main = await NodeManager.get_one(db=db, branch=default_branch, id=john_id) + john_car_rels = await john_main.cars.get(db=db) + john_car_rels_by_peer_id = {c.get_peer_id(): c for c in john_car_rels} + assert ed_209_id not in john_car_rels_by_peer_id + omnicorp_id = initial_dataset["omnicorp"].get_id() + omnicorp_main = await NodeManager.get_one(db=db, branch=default_branch, id=omnicorp_id) + omnicorp_car_rels = await omnicorp_main.cars.get(db=db) + omnicorp_car_rels_by_peer_id = {c.get_peer_id(): c for c in omnicorp_car_rels} + assert ed_209_id not in omnicorp_car_rels_by_peer_id + + # validate attribute property conflict + attribute_value_conflict = self.retrieve_item("attribute_value") + assert john_main.age.value == attribute_value_conflict.expected_value + + # validate node removed conflict + # TODO: node deleted on main is not un-deleted during conflict resolution + # node_removed_attribute_value_conflict = self.retrieve_item("node_removed_attribute_value") + # kara_id = initial_dataset["kara"].get_id() + # kara_main = await NodeManager.get_one(db=db, branch=default_branch, id=kara_id) + # assert kara_main.height.value == node_removed_attribute_value_conflict.expected_value + + # peer update conflict + peer_conflict = self.retrieve_item("peer_conflict") + jesko_id = initial_dataset["jesko"].get_id() + jesko_main = await NodeManager.get_one(db=db, branch=default_branch, id=jesko_id) + manufacturer_peer = await jesko_main.manufacturer.get_peer(db=db) + assert manufacturer_peer.get_id() == peer_conflict.expected_value + + # peer property conflict + cardinality_one_property_conflict_a = self.retrieve_item("cardinality_one_property_conflict_a") + t_800_id = initial_dataset["t_800"].get_id() + t_800_main = await NodeManager.get_one(db=db, branch=default_branch, id=t_800_id) + owner_rel = await t_800_main.owner.get(db=db) + owner_of_property = await owner_rel.get_owner(db=db) + assert owner_of_property.get_id() == cardinality_one_property_conflict_a.expected_value + cardinality_one_property_conflict_b = self.retrieve_item("cardinality_one_property_conflict_b") + car_element = john_car_rels_by_peer_id[t_800_id] + owner_of_property = await car_element.get_owner(db=db) + assert owner_of_property.get_id() == cardinality_one_property_conflict_b.expected_value + + # cardinality many property conflict + murphy_id = initial_dataset["murphy"].get_id() + cardinality_many_property_conflict = self.retrieve_item("cardinality_many_property_conflict") + omnicorp_customers_rels = await omnicorp_main.customers.get(db=db) + omnicorp_customers_rels_by_peer_id = {c.get_peer_id(): c for c in omnicorp_customers_rels} + murphy_customer_rel = omnicorp_customers_rels_by_peer_id[murphy_id] + owner_of_property = await murphy_customer_rel.get_owner(db=db) + assert owner_of_property.get_id() == cardinality_many_property_conflict.expected_value diff --git a/backend/tests/integration/git/test_repository.py b/backend/tests/integration/git/test_repository.py index 0c007056bf..21e66a825e 100644 --- a/backend/tests/integration/git/test_repository.py +++ b/backend/tests/integration/git/test_repository.py @@ -62,5 +62,5 @@ async def test_create_repository( ) assert repository.commit.value - assert repository.admin_status.value == "active" + assert repository.internal_status.value == "active" assert check_definition.file_path.value == "checks/car_overview.py" diff --git a/backend/tests/integration/git/test_repository_branch.py b/backend/tests/integration/git/test_repository_branch.py index d0f1ffe482..5d5e653a97 100644 --- a/backend/tests/integration/git/test_repository_branch.py +++ b/backend/tests/integration/git/test_repository_branch.py @@ -4,7 +4,7 @@ import pytest -from infrahub.core.constants import InfrahubKind, RepositoryAdminStatus +from infrahub.core.constants import InfrahubKind, RepositoryInternalStatus from infrahub.core.manager import NodeManager from infrahub.core.node import Node from tests.constants import TestKind @@ -71,7 +71,7 @@ async def test_create_repository( ) assert repository_branch.commit.value - assert repository_branch.admin_status.value == RepositoryAdminStatus.STAGING.value + assert repository_branch.internal_status.value == RepositoryInternalStatus.STAGING.value assert check_definition.file_path.value == "checks/car_overview.py" repository_main: CoreRepository = await NodeManager.get_one( @@ -79,7 +79,7 @@ async def test_create_repository( ) assert repository_main.commit.value is None - assert repository_main.admin_status.value == RepositoryAdminStatus.INACTIVE.value + assert repository_main.internal_status.value == RepositoryInternalStatus.INACTIVE.value async def test_merge_branch( self, @@ -98,5 +98,5 @@ async def test_merge_branch( db=db, id="car-dealership", kind=InfrahubKind.REPOSITORY, raise_on_error=True ) - assert repository_main.admin_status.value == RepositoryAdminStatus.ACTIVE.value + assert repository_main.internal_status.value == RepositoryInternalStatus.ACTIVE.value assert repository_main.commit.value == repository_branch.commit.value diff --git a/backend/tests/unit/core/diff/factories.py b/backend/tests/unit/core/diff/factories.py index be358c8fa1..07fd982006 100644 --- a/backend/tests/unit/core/diff/factories.py +++ b/backend/tests/unit/core/diff/factories.py @@ -18,11 +18,17 @@ ) -class EnrichedConflictFactory(DataclassFactory[EnrichedDiffConflict]): ... +class EnrichedConflictFactory(DataclassFactory[EnrichedDiffConflict]): + __set_as_default_factory_for_type__ = True + base_branch_label = None + diff_branch_label = None class EnrichedPropertyFactory(DataclassFactory[EnrichedDiffProperty]): + __set_as_default_factory_for_type__ = True conflict = None + previous_label = None + new_label = None class EnrichedAttributeFactory(DataclassFactory[EnrichedDiffAttribute]): diff --git a/backend/tests/unit/core/diff/query/test_read.py b/backend/tests/unit/core/diff/query/test_read.py index fb985b5d26..cd25b17e09 100644 --- a/backend/tests/unit/core/diff/query/test_read.py +++ b/backend/tests/unit/core/diff/query/test_read.py @@ -14,6 +14,7 @@ from infrahub.core.manager import NodeManager from infrahub.core.node import Node from infrahub.core.schema import SchemaRoot +from infrahub.core.timestamp import Timestamp from infrahub.database import InfrahubDatabase from infrahub.dependencies.registry import get_component_registry from tests.helpers.test_app import TestInfrahub @@ -176,34 +177,44 @@ async def load_data(self, db: InfrahubDatabase, default_branch: Branch, hierarch @pytest.mark.parametrize( "filters,counters", [ - pytest.param({}, DiffSummaryCounters(num_added=2, num_updated=4), id="no-filters"), + pytest.param( + {}, + DiffSummaryCounters(num_added=2, num_updated=4, from_time=Timestamp(), to_time=Timestamp()), + id="no-filters", + ), pytest.param( {"kind": {"includes": ["TestThing"]}}, - DiffSummaryCounters(num_added=2, num_updated=1), + DiffSummaryCounters(num_added=2, num_updated=1, from_time=Timestamp(), to_time=Timestamp()), id="kind-includes", ), - pytest.param({"kind": {"excludes": ["TestThing"]}}, DiffSummaryCounters(num_updated=3), id="kind-excludes"), + pytest.param( + {"kind": {"excludes": ["TestThing"]}}, + DiffSummaryCounters(num_updated=3, from_time=Timestamp(), to_time=Timestamp()), + id="kind-excludes", + ), pytest.param( {"namespace": {"includes": ["Test"]}}, - DiffSummaryCounters(num_added=2, num_updated=1), + DiffSummaryCounters(num_added=2, num_updated=1, from_time=Timestamp(), to_time=Timestamp()), id="namespace-includes", ), pytest.param( {"namespace": {"excludes": ["Location"]}}, - DiffSummaryCounters(num_added=2, num_updated=1), + DiffSummaryCounters(num_added=2, num_updated=1, from_time=Timestamp(), to_time=Timestamp()), id="namespace-excludes", ), pytest.param( - {"status": {"includes": ["updated"]}}, DiffSummaryCounters(num_updated=4), id="status-includes" + {"status": {"includes": ["updated"]}}, + DiffSummaryCounters(num_updated=4, from_time=Timestamp(), to_time=Timestamp()), + id="status-includes", ), pytest.param( {"status": {"excludes": ["unchanged"]}}, - DiffSummaryCounters(num_added=2, num_updated=4), + DiffSummaryCounters(num_added=2, num_updated=4, from_time=Timestamp(), to_time=Timestamp()), id="status-excludes", ), pytest.param( {"kind": {"includes": ["TestThing"]}, "status": {"excludes": ["added"]}}, - DiffSummaryCounters(num_updated=1), + DiffSummaryCounters(num_updated=1, from_time=Timestamp(), to_time=Timestamp()), id="kind-includes-status-excludes", ), ], @@ -220,6 +231,8 @@ async def test_summary_no_filter(self, db: InfrahubDatabase, default_branch: Bra await query.execute(db=db) summary = query.get_summary() + counters.from_time = load_data["from_time"] + counters.to_time = load_data["to_time"] assert summary == counters async def test_get_without_parent(self, db: InfrahubDatabase, default_branch: Branch, load_data): diff --git a/backend/tests/unit/core/diff/test_cardinality_one_enricher.py b/backend/tests/unit/core/diff/test_cardinality_one_enricher.py index f6abd56c9e..a17858331c 100644 --- a/backend/tests/unit/core/diff/test_cardinality_one_enricher.py +++ b/backend/tests/unit/core/diff/test_cardinality_one_enricher.py @@ -1,7 +1,7 @@ from copy import deepcopy from uuid import uuid4 -from infrahub.core.constants import DiffAction +from infrahub.core.constants import DiffAction, RelationshipCardinality from infrahub.core.constants.database import DatabaseEdgeType from infrahub.core.diff.enricher.cardinality_one import DiffCardinalityOneEnricher from infrahub.core.diff.model.path import EnrichedDiffProperty @@ -23,7 +23,10 @@ async def test_no_cardinality_one_relationships(self, db: InfrahubDatabase, car_ branch = await create_branch(db=db, branch_name="branch") enricher = DiffCardinalityOneEnricher(db=db) diff_relationship = EnrichedRelationshipGroupFactory.build( - name="cars", nodes=set(), relationships={EnrichedRelationshipElementFactory.build() for _ in range(3)} + name="cars", + nodes=set(), + cardinality=RelationshipCardinality.MANY, + relationships={EnrichedRelationshipElementFactory.build() for _ in range(3)}, ) diff_node = EnrichedNodeFactory.build(kind="TestPerson", relationships={diff_relationship}) diff_root = EnrichedRootFactory.build(diff_branch_name=branch.name, nodes={diff_node}) @@ -69,7 +72,10 @@ async def test_cardinality_one_relationship_update(self, db: InfrahubDatabase, c ) diff_relationship = EnrichedRelationshipGroupFactory.build( - name="owner", nodes=set(), relationships={diff_rel_element_1, diff_rel_element_2} + name="owner", + nodes=set(), + cardinality=RelationshipCardinality.ONE, + relationships={diff_rel_element_1, diff_rel_element_2}, ) diff_node = EnrichedNodeFactory.build(kind="TestCar", relationships={diff_relationship}) diff_root = EnrichedRootFactory.build(diff_branch_name=branch.name, nodes={diff_node}) @@ -157,7 +163,10 @@ async def test_cardinality_one_relationship_simulataneous_update(self, db: Infra ) diff_relationship = EnrichedRelationshipGroupFactory.build( - name="owner", nodes=set(), relationships={diff_rel_element_1, diff_rel_element_2} + name="owner", + nodes=set(), + cardinality=RelationshipCardinality.ONE, + relationships={diff_rel_element_1, diff_rel_element_2}, ) diff_node = EnrichedNodeFactory.build(kind="TestCar", relationships={diff_relationship}) diff_root = EnrichedRootFactory.build(diff_branch_name=branch.name, nodes={diff_node}) @@ -235,7 +244,10 @@ async def test_cardinality_one_relationship_reverted_update(self, db: InfrahubDa ) diff_relationship = EnrichedRelationshipGroupFactory.build( - name="owner", nodes=set(), relationships={diff_rel_element_1, diff_rel_element_2} + name="owner", + nodes=set(), + cardinality=RelationshipCardinality.ONE, + relationships={diff_rel_element_1, diff_rel_element_2}, ) diff_node = EnrichedNodeFactory.build(kind="TestCar", relationships={diff_relationship}) diff_root = EnrichedRootFactory.build(diff_branch_name=branch.name, nodes={diff_node}) diff --git a/backend/tests/unit/core/diff/test_conflicts_enricher.py b/backend/tests/unit/core/diff/test_conflicts_enricher.py index 1f5adac7e8..8674c6a24f 100644 --- a/backend/tests/unit/core/diff/test_conflicts_enricher.py +++ b/backend/tests/unit/core/diff/test_conflicts_enricher.py @@ -110,7 +110,9 @@ async def test_one_attribute_conflict(self, db: InfrahubDatabase): attribute_name = "smell" node_uuid = str(uuid4()) node_kind = "SomethingSmelly" - base_conflict_property = EnrichedPropertyFactory.build(property_type=property_type, action=DiffAction.UPDATED) + base_conflict_property = EnrichedPropertyFactory.build( + property_type=property_type, action=DiffAction.UPDATED, new_value="potato salad" + ) base_properties = { base_conflict_property, EnrichedPropertyFactory.build(property_type=DatabaseEdgeType.HAS_SOURCE), @@ -130,7 +132,9 @@ async def test_one_attribute_conflict(self, db: InfrahubDatabase): EnrichedNodeFactory.build(relationships=set()), } base_root = EnrichedRootFactory.build(nodes=base_nodes) - branch_conflict_property = EnrichedPropertyFactory.build(property_type=property_type, action=DiffAction.UPDATED) + branch_conflict_property = EnrichedPropertyFactory.build( + property_type=property_type, action=DiffAction.UPDATED, new_value="ham sandwich" + ) branch_properties = { branch_conflict_property, EnrichedPropertyFactory.build(property_type=DatabaseEdgeType.IS_VISIBLE), diff --git a/backend/tests/unit/core/diff/test_coordinator_lock.py b/backend/tests/unit/core/diff/test_coordinator_lock.py new file mode 100644 index 0000000000..73af4c1c78 --- /dev/null +++ b/backend/tests/unit/core/diff/test_coordinator_lock.py @@ -0,0 +1,57 @@ +import asyncio +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from infrahub import lock +from infrahub.core.branch import Branch +from infrahub.core.diff.coordinator import DiffCoordinator +from infrahub.core.diff.data_check_synchronizer import DiffDataCheckSynchronizer +from infrahub.core.initialization import create_branch +from infrahub.core.node import Node +from infrahub.database import InfrahubDatabase +from infrahub.dependencies.registry import get_component_registry + + +class TestDiffCoordinatorLocks: + @pytest.fixture + async def branch_data(self, db: InfrahubDatabase, default_branch: Branch, car_person_schema): + lock.initialize_lock(local_only=True) + branch_1 = await create_branch(branch_name="branch_1", db=db) + for _ in range(10): + person = await Node.init(db=db, schema="TestPerson", branch=default_branch) + await person.new(db=db, name=str(uuid4), height=180) + await person.save(db=db) + for _ in range(10): + person = await Node.init(db=db, schema="TestPerson", branch=branch_1) + await person.new(db=db, name=str(uuid4), height=180) + await person.save(db=db) + + return branch_1 + + async def get_diff_coordinator(self, db: InfrahubDatabase, diff_branch: Branch) -> DiffCoordinator: + component_registry = get_component_registry() + diff_coordinator = await component_registry.get_component(DiffCoordinator, db=db, branch=diff_branch) + mock_synchronizer = AsyncMock(spec=DiffDataCheckSynchronizer) + diff_coordinator.data_check_synchronizer = mock_synchronizer + wrapped_repo = AsyncMock(wraps=diff_coordinator.diff_repo) + diff_coordinator.diff_repo = wrapped_repo + wrapped_calculator = AsyncMock(wraps=diff_coordinator.diff_calculator) + diff_coordinator.diff_calculator = wrapped_calculator + return diff_coordinator + + async def test_incremental_locks_do_not_queue_up(self, db: InfrahubDatabase, default_branch: Branch, branch_data): + diff_branch = branch_data + diff_coordinator = await self.get_diff_coordinator(db=db, diff_branch=diff_branch) + + results = await asyncio.gather( + diff_coordinator.update_branch_diff(base_branch=default_branch, diff_branch=diff_branch), + diff_coordinator.update_branch_diff(base_branch=default_branch, diff_branch=diff_branch), + ) + assert len(results) == 2 + assert results[0] == results[1] + # called once to calculate diff on main and once to calculate diff on the branch + assert len(diff_coordinator.diff_calculator.calculate_diff.call_args_list) == 2 + # called instead of calculating the diff again + diff_coordinator.diff_repo.get_one.assert_awaited_once() diff --git a/backend/tests/unit/core/diff/test_diff_combiner.py b/backend/tests/unit/core/diff/test_diff_combiner.py index 0dc72bf629..e2d2f11b09 100644 --- a/backend/tests/unit/core/diff/test_diff_combiner.py +++ b/backend/tests/unit/core/diff/test_diff_combiner.py @@ -7,7 +7,7 @@ from pendulum.datetime import DateTime from infrahub.core import registry -from infrahub.core.constants import DiffAction +from infrahub.core.constants import DiffAction, RelationshipCardinality from infrahub.core.constants.database import DatabaseEdgeType from infrahub.core.diff.combiner import DiffCombiner from infrahub.core.diff.model.path import ( @@ -282,6 +282,8 @@ async def test_attributes_combined(self): previous_value=added_attr_owner_property_1.previous_value, new_value=added_attr_owner_property_2.new_value, path_identifier=added_attr_owner_property_2.path_identifier, + previous_label=added_attr_owner_property_2.previous_label, + new_label=added_attr_owner_property_2.new_label, action=DiffAction.ADDED, ) expected_updated_combined_property = EnrichedDiffProperty( @@ -290,6 +292,8 @@ async def test_attributes_combined(self): previous_value=updated_attr_value_property_1.previous_value, new_value=updated_attr_value_property_2.new_value, path_identifier=updated_attr_value_property_2.path_identifier, + previous_label=updated_attr_value_property_2.previous_label, + new_label=updated_attr_value_property_2.new_label, action=DiffAction.UPDATED, ) expected_added_combined_attr = EnrichedDiffAttribute( @@ -373,11 +377,16 @@ async def test_relationship_one_combined(self, with_schema_manager): conflict=EnrichedConflictFactory.build(), ) early_relationship = EnrichedRelationshipGroupFactory.build( - name=relationship_name, action=DiffAction.ADDED, relationships={early_element}, nodes=set() + name=relationship_name, + action=DiffAction.ADDED, + relationships={early_element}, + nodes=set(), + cardinality=RelationshipCardinality.ONE, ) later_relationship = EnrichedRelationshipGroupFactory.build( name=relationship_name, action=DiffAction.UPDATED, + cardinality=RelationshipCardinality.ONE, relationships={later_element}, nodes=set(), changed_at=Timestamp(), @@ -418,6 +427,7 @@ async def test_relationship_one_combined(self, with_schema_manager): expected_relationship = EnrichedDiffRelationship( name=relationship_name, label=later_relationship.label, + cardinality=RelationshipCardinality.ONE, changed_at=later_relationship.changed_at, action=DiffAction.ADDED, path_identifier=later_relationship.path_identifier, @@ -521,12 +531,14 @@ async def test_relationship_many_combined(self, with_schema_manager): relationship_group_1 = EnrichedRelationshipGroupFactory.build( name=relationship_name, action=DiffAction.UPDATED, + cardinality=RelationshipCardinality.MANY, relationships={added_element_1, removed_element_1, updated_element_1, canceled_element_1}, nodes=set(), ) relationship_group_2 = EnrichedRelationshipGroupFactory.build( name=relationship_name, action=DiffAction.UPDATED, + cardinality=RelationshipCardinality.MANY, relationships={added_element_2, removed_element_2, updated_element_2, canceled_element_2}, changed_at=Timestamp(), nodes=set(), @@ -580,6 +592,7 @@ async def test_relationship_many_combined(self, with_schema_manager): expected_relationship = EnrichedDiffRelationship( name=relationship_name, label=relationship_group_2.label, + cardinality=RelationshipCardinality.MANY, changed_at=relationship_group_2.changed_at, action=DiffAction.UPDATED, path_identifier=relationship_group_2.path_identifier, @@ -609,7 +622,11 @@ async def test_relationship_with_only_nodes(self, with_schema_manager): action=DiffAction.UNCHANGED, relationships=set(), attributes=set() ) early_relationship = EnrichedRelationshipGroupFactory.build( - name=relationship_name, action=DiffAction.ADDED, relationships=set(), nodes={early_parent_node} + name=relationship_name, + action=DiffAction.ADDED, + cardinality=RelationshipCardinality.MANY, + relationships=set(), + nodes={early_parent_node}, ) later_parent_node = EnrichedNodeFactory.build( action=DiffAction.UNCHANGED, relationships=set(), attributes=set() @@ -617,6 +634,7 @@ async def test_relationship_with_only_nodes(self, with_schema_manager): later_relationship = EnrichedRelationshipGroupFactory.build( name=relationship_name, action=DiffAction.UPDATED, + cardinality=RelationshipCardinality.MANY, relationships=set(), nodes={later_parent_node}, changed_at=Timestamp(), @@ -637,6 +655,7 @@ async def test_relationship_with_only_nodes(self, with_schema_manager): expected_relationship = EnrichedDiffRelationship( name=relationship_name, label=later_relationship.label, + cardinality=RelationshipCardinality.MANY, changed_at=later_relationship.changed_at, action=DiffAction.ADDED, path_identifier=later_relationship.path_identifier, @@ -768,10 +787,15 @@ async def test_unchanged_parents_correctly_updated(self): action=DiffAction.UNCHANGED, attributes=set(), relationships=set(), changed_at=Timestamp() ) parent_rel_1 = EnrichedRelationshipGroupFactory.build( - name=relationship_name, relationships=set(), nodes={parent_node_1}, action=DiffAction.UNCHANGED + name=relationship_name, + relationships=set(), + nodes={parent_node_1}, + action=DiffAction.UNCHANGED, + cardinality=RelationshipCardinality.ONE, ) parent_rel_2 = EnrichedRelationshipGroupFactory.build( name=relationship_name, + cardinality=RelationshipCardinality.ONE, relationships=set(), nodes={parent_node_2}, action=DiffAction.UNCHANGED, @@ -797,6 +821,7 @@ async def test_unchanged_parents_correctly_updated(self): name=relationship_name, label=parent_rel_2.label, changed_at=parent_rel_2.changed_at, + cardinality=RelationshipCardinality.ONE, path_identifier=parent_rel_2.path_identifier, action=DiffAction.UNCHANGED, relationships=set(), @@ -832,6 +857,7 @@ async def test_updated_parents_correctly_updated(self): name=relationship_name, relationships={child_element_1}, nodes={parent_node_1}, + cardinality=RelationshipCardinality.ONE, action=DiffAction.UPDATED, num_added=0, num_updated=0, @@ -843,6 +869,7 @@ async def test_updated_parents_correctly_updated(self): name=relationship_name, relationships=set(), nodes={parent_node_2}, + cardinality=RelationshipCardinality.ONE, action=DiffAction.UNCHANGED, changed_at=Timestamp(), ) @@ -863,6 +890,7 @@ async def test_updated_parents_correctly_updated(self): name=relationship_name, label=child_rel_2.label, changed_at=child_rel_2.changed_at, + cardinality=RelationshipCardinality.ONE, path_identifier=child_rel_2.path_identifier, action=DiffAction.UPDATED, relationships={child_element_1}, diff --git a/backend/tests/unit/core/diff/test_diff_labels_enricher.py b/backend/tests/unit/core/diff/test_diff_labels_enricher.py index 5fc6fbca37..cbfe7b26b9 100644 --- a/backend/tests/unit/core/diff/test_diff_labels_enricher.py +++ b/backend/tests/unit/core/diff/test_diff_labels_enricher.py @@ -1,35 +1,150 @@ +from infrahub.core.constants import DiffAction +from infrahub.core.constants.database import DatabaseEdgeType from infrahub.core.diff.enricher.labels import DiffLabelsEnricher from infrahub.core.initialization import create_branch +from infrahub.core.manager import NodeManager from infrahub.database import InfrahubDatabase from .factories import ( + EnrichedAttributeFactory, + EnrichedConflictFactory, EnrichedNodeFactory, + EnrichedPropertyFactory, EnrichedRelationshipElementFactory, EnrichedRelationshipGroupFactory, EnrichedRootFactory, ) -async def test_labels_added(db: InfrahubDatabase, default_branch, car_yaris_main, person_jane_main): +async def test_labels_added( + db: InfrahubDatabase, default_branch, car_yaris_main, person_jane_main, person_alfred_main, person_john_main +): branch = await create_branch(db=db, branch_name="branch") - diff_rel_element = EnrichedRelationshipElementFactory.build(peer_id=person_jane_main.id) + yaris_label_main = await car_yaris_main.render_display_label(db=db) + yaris_branch = await NodeManager.get_one(db=db, branch=branch, id=car_yaris_main.get_id()) + yaris_branch.color.value = "purple" + await yaris_branch.save(db=db) + yaris_label_branch = await yaris_branch.render_display_label(db=db) + alfred_branch = await NodeManager.get_one(db=db, branch=branch, id=person_alfred_main.get_id()) + await alfred_branch.delete(db=db) + + diff_attribute_owner_prop = EnrichedPropertyFactory.build( + property_type=DatabaseEdgeType.HAS_OWNER, previous_value=car_yaris_main.id, new_value=person_john_main.id + ) + diff_attribute_source_conflict = EnrichedConflictFactory.build( + base_branch_value=person_alfred_main.id, diff_branch_value=person_john_main.id + ) + diff_attribute_source_prop = EnrichedPropertyFactory.build( + property_type=DatabaseEdgeType.HAS_SOURCE, + previous_value=person_john_main.id, + new_value=car_yaris_main.id, + conflict=diff_attribute_source_conflict, + ) + diff_attribute_value_conflict = EnrichedConflictFactory.build( + base_branch_value=person_john_main.id, diff_branch_value=person_jane_main.id + ) + diff_attribute_value_prop = EnrichedPropertyFactory.build( + property_type=DatabaseEdgeType.HAS_VALUE, + previous_value=person_john_main.id, + new_value=person_jane_main.id, + conflict=diff_attribute_value_conflict, + ) + diff_attribute = EnrichedAttributeFactory.build( + properties={diff_attribute_owner_prop, diff_attribute_source_prop, diff_attribute_value_prop} + ) + diff_element_is_protected_conflict = EnrichedConflictFactory.build( + base_branch_value=car_yaris_main.id, diff_branch_value=person_jane_main.id + ) + diff_element_protected_prop = EnrichedPropertyFactory.build( + property_type=DatabaseEdgeType.IS_PROTECTED, + previous_value=person_john_main.id, + new_value=person_jane_main.id, + conflict=diff_element_is_protected_conflict, + ) + diff_element_is_related_conflict = EnrichedConflictFactory.build( + base_branch_value=car_yaris_main.id, diff_branch_value=person_jane_main.id + ) + diff_element_related_prop = EnrichedPropertyFactory.build( + property_type=DatabaseEdgeType.IS_RELATED, + previous_value=person_john_main.id, + new_value=person_jane_main.id, + conflict=diff_element_is_related_conflict, + ) + diff_element_conflict = EnrichedConflictFactory.build( + base_branch_value=person_alfred_main.id, diff_branch_value=person_jane_main.id + ) + diff_rel_element = EnrichedRelationshipElementFactory.build( + peer_id=person_jane_main.id, + conflict=diff_element_conflict, + properties={diff_element_protected_prop, diff_element_related_prop}, + ) diff_rel = EnrichedRelationshipGroupFactory.build(name="owner", nodes=set(), relationships={diff_rel_element}) diff_node = EnrichedNodeFactory.build( - uuid=car_yaris_main.get_id(), kind=car_yaris_main.get_kind(), relationships={diff_rel} + action=DiffAction.UPDATED, + uuid=car_yaris_main.get_id(), + kind=car_yaris_main.get_kind(), + relationships={diff_rel}, + attributes={diff_attribute}, ) + deleted_diff_node = EnrichedNodeFactory.build( + action=DiffAction.REMOVED, + uuid=person_alfred_main.get_id(), + kind=person_alfred_main.get_kind(), + relationships=set(), + attributes=set(), + ) + diff_root = EnrichedRootFactory.build( - base_branch_name=default_branch.name, diff_branch_name=branch.name, nodes={diff_node} + base_branch_name=default_branch.name, diff_branch_name=branch.name, nodes={diff_node, deleted_diff_node} ) labels_enricher = DiffLabelsEnricher(db=db) await labels_enricher.enrich(enriched_diff_root=diff_root, calculated_diffs=None) - updated_node = diff_root.nodes.pop() - assert updated_node.label == "yaris #444444" + nodes_by_id = {n.uuid: n for n in diff_root.nodes} + updated_node = nodes_by_id[car_yaris_main.get_id()] + assert updated_node.label == yaris_label_branch + diff_attribute = updated_node.attributes.pop() + properties_by_type = {p.property_type: p for p in diff_attribute.properties} + owner_prop = properties_by_type[DatabaseEdgeType.HAS_OWNER] + assert owner_prop.previous_label == yaris_label_main + assert owner_prop.new_label == "John" + source_prop = properties_by_type[DatabaseEdgeType.HAS_SOURCE] + assert source_prop.previous_label == "John" + assert source_prop.new_label == yaris_label_branch + source_prop_conflict = source_prop.conflict + assert source_prop_conflict.base_branch_label == "Alfred" + assert source_prop_conflict.diff_branch_label == "John" + value_prop = properties_by_type[DatabaseEdgeType.HAS_VALUE] + assert value_prop.previous_label is None + assert value_prop.new_label is None + value_prop_conflict = value_prop.conflict + assert value_prop_conflict.base_branch_label is None + assert value_prop_conflict.diff_branch_label is None + updated_rel = updated_node.relationships.pop() assert updated_rel.label == "Commander of Car" updated_element = updated_rel.relationships.pop() assert updated_element.peer_label == "Jane" + element_conflict = updated_element.conflict + assert element_conflict.base_branch_label == "Alfred" + assert element_conflict.diff_branch_label == "Jane" + properties_by_type = {p.property_type: p for p in updated_element.properties} + protected_prop = properties_by_type[DatabaseEdgeType.IS_PROTECTED] + assert protected_prop.previous_label is None + assert protected_prop.new_label is None + protected_prop_conflict = protected_prop.conflict + assert protected_prop_conflict.base_branch_label is None + assert protected_prop_conflict.diff_branch_label is None + related_prop = properties_by_type[DatabaseEdgeType.IS_RELATED] + assert related_prop.previous_label == "John" + assert related_prop.new_label == "Jane" + related_prop_conflict = related_prop.conflict + assert related_prop_conflict.base_branch_label == yaris_label_main + assert related_prop_conflict.diff_branch_label == "Jane" + + deleted_node = nodes_by_id[person_alfred_main.get_id()] + assert deleted_node.label == await person_alfred_main.render_display_label(db=db) async def test_labels_skipped(db: InfrahubDatabase, default_branch, car_person_schema): diff --git a/backend/tests/unit/core/diff/test_diff_query_parser.py b/backend/tests/unit/core/diff/test_diff_query_parser.py index e347341099..43db8f36b5 100644 --- a/backend/tests/unit/core/diff/test_diff_query_parser.py +++ b/backend/tests/unit/core/diff/test_diff_query_parser.py @@ -255,6 +255,56 @@ async def test_node_delete(db: InfrahubDatabase, default_branch: Branch, car_acc assert diff_property.action is DiffAction.REMOVED +async def test_node_base_delete_branch_update( + db: InfrahubDatabase, default_branch: Branch, car_accord_main, person_john_main +): + branch = await create_branch(db=db, branch_name="branch") + from_time = Timestamp() + car_main = await NodeManager.get_one(db=db, branch=default_branch, id=car_accord_main.id) + await car_main.delete(db=db) + car_branch = await NodeManager.get_one(db=db, branch=branch, id=car_accord_main.id) + car_branch.nbr_seats.value = 10 + await car_branch.save(db=db) + + diff_query = await DiffAllPathsQuery.init( + db=db, + branch=branch, + base_branch=default_branch, + diff_from=from_time, + ) + await diff_query.execute(db=db) + diff_parser = DiffQueryParser( + diff_query=diff_query, + base_branch_name=default_branch.name, + diff_branch_name=branch.name, + schema_manager=registry.schema, + from_time=from_time, + ) + diff_parser.parse() + + assert diff_parser.get_branches() == {branch.name, default_branch.name} + branch_root_path = diff_parser.get_diff_root_for_branch(branch=branch.name) + assert branch_root_path.branch == branch.name + assert len(branch_root_path.nodes) == 1 + node_diffs_by_id = {n.uuid: n for n in branch_root_path.nodes} + node_diff = node_diffs_by_id[car_accord_main.id] + assert node_diff.uuid == car_accord_main.id + assert node_diff.kind == "TestCar" + assert node_diff.action is DiffAction.UPDATED + assert len(node_diff.attributes) == 1 + assert len(node_diff.relationships) == 0 + attributes_by_name = {attr.name: attr for attr in node_diff.attributes} + assert set(attributes_by_name.keys()) == {"nbr_seats"} + attribute_diff = attributes_by_name["nbr_seats"] + assert attribute_diff.action is DiffAction.UPDATED + properties_by_type = {prop.property_type: prop for prop in attribute_diff.properties} + assert set(properties_by_type.keys()) == {DatabaseEdgeType.HAS_VALUE} + diff_property = properties_by_type[DatabaseEdgeType.HAS_VALUE] + assert diff_property.action is DiffAction.UPDATED + assert diff_property.previous_value == 5 + assert diff_property.new_value == 10 + + async def test_node_branch_add(db: InfrahubDatabase, default_branch: Branch, car_accord_main): branch = await create_branch(db=db, branch_name="branch") from_time = Timestamp(branch.created_at) @@ -350,6 +400,258 @@ async def test_attribute_property_multiple_branch_updates( assert before_last_change < property_diff.changed_at < after_last_change +async def test_relationship_one_peer_branch_and_main_update( + db: InfrahubDatabase, + default_branch: Branch, + person_alfred_main, + person_jane_main, + person_john_main, + car_accord_main, +): + branch = await create_branch(db=db, branch_name="branch") + from_time = Timestamp(branch.created_at) + car_main = await NodeManager.get_one(db=db, branch=default_branch, id=car_accord_main.id) + await car_main.owner.update(db=db, data={"id": person_jane_main.id}) + before_main_change = Timestamp() + await car_main.save(db=db) + after_main_change = Timestamp() + car_branch = await NodeManager.get_one(db=db, branch=branch, id=car_accord_main.id) + await car_branch.owner.update(db=db, data={"id": person_alfred_main.id}) + before_branch_change = Timestamp() + await car_branch.save(db=db) + after_branch_change = Timestamp() + + diff_query = await DiffAllPathsQuery.init( + db=db, + branch=branch, + base_branch=default_branch, + diff_from=from_time, + ) + await diff_query.execute(db=db) + diff_parser = DiffQueryParser( + diff_query=diff_query, + base_branch_name=default_branch.name, + diff_branch_name=branch.name, + schema_manager=registry.schema, + from_time=from_time, + ) + + diff_parser.parse() + + assert diff_parser.get_branches() == {branch.name, default_branch.name} + # check branch + root_path = diff_parser.get_diff_root_for_branch(branch=branch.name) + assert root_path.branch == branch.name + nodes_by_id = {n.uuid: n for n in root_path.nodes} + assert set(nodes_by_id.keys()) == {car_accord_main.id, person_john_main.id, person_alfred_main.id} + # check relationship on car node on branch + car_node = nodes_by_id[car_main.get_id()] + assert car_node.uuid == car_accord_main.id + assert car_node.kind == "TestCar" + assert car_node.action is DiffAction.UPDATED + assert len(car_node.attributes) == 0 + assert len(car_node.relationships) == 1 + relationship_diff = car_node.relationships[0] + assert relationship_diff.name == "owner" + assert relationship_diff.action is DiffAction.UPDATED + elements_by_peer_id = {e.peer_id: e for e in relationship_diff.relationships} + assert set(elements_by_peer_id.keys()) == {person_john_main.id, person_alfred_main.id} + removed_relationship = elements_by_peer_id[person_john_main.id] + assert removed_relationship.peer_id == person_john_main.id + assert removed_relationship.action is DiffAction.REMOVED + properties_by_type = {p.property_type: p for p in removed_relationship.properties} + assert set(properties_by_type.keys()) == { + DatabaseEdgeType.IS_RELATED, + DatabaseEdgeType.IS_PROTECTED, + DatabaseEdgeType.IS_VISIBLE, + } + assert {(p.property_type, p.action, p.previous_value, p.new_value) for p in removed_relationship.properties} == { + (DatabaseEdgeType.IS_RELATED, DiffAction.REMOVED, person_john_main.id, None), + (DatabaseEdgeType.IS_VISIBLE, DiffAction.REMOVED, True, None), + (DatabaseEdgeType.IS_PROTECTED, DiffAction.REMOVED, False, None), + } + for prop_diff in removed_relationship.properties: + assert before_branch_change < prop_diff.changed_at < after_branch_change + added_relationship = elements_by_peer_id[person_alfred_main.id] + assert added_relationship.peer_id == person_alfred_main.id + assert added_relationship.action is DiffAction.ADDED + properties_by_type = {p.property_type: p for p in added_relationship.properties} + assert set(properties_by_type.keys()) == { + DatabaseEdgeType.IS_RELATED, + DatabaseEdgeType.IS_PROTECTED, + DatabaseEdgeType.IS_VISIBLE, + } + assert {(p.property_type, p.action, p.previous_value, p.new_value) for p in added_relationship.properties} == { + (DatabaseEdgeType.IS_RELATED, DiffAction.ADDED, None, person_alfred_main.id), + (DatabaseEdgeType.IS_VISIBLE, DiffAction.ADDED, None, True), + (DatabaseEdgeType.IS_PROTECTED, DiffAction.ADDED, None, False), + } + for prop_diff in added_relationship.properties: + assert before_branch_change < prop_diff.changed_at < after_branch_change + # check relationship on removed peer on branch + john_node = nodes_by_id[person_john_main.get_id()] + assert john_node.uuid == person_john_main.get_id() + assert john_node.kind == "TestPerson" + assert john_node.action is DiffAction.UPDATED + assert len(john_node.attributes) == 0 + assert len(john_node.relationships) == 1 + relationship_diff = john_node.relationships[0] + assert relationship_diff.name == "cars" + assert relationship_diff.action is DiffAction.UPDATED + elements_by_peer_id = {e.peer_id: e for e in relationship_diff.relationships} + assert set(elements_by_peer_id.keys()) == {car_accord_main.get_id()} + single_relationship = relationship_diff.relationships[0] + assert single_relationship.peer_id == car_accord_main.id + assert single_relationship.action is DiffAction.REMOVED + properties_by_type = {p.property_type: p for p in single_relationship.properties} + assert set(properties_by_type.keys()) == { + DatabaseEdgeType.IS_RELATED, + DatabaseEdgeType.IS_PROTECTED, + DatabaseEdgeType.IS_VISIBLE, + } + assert {(p.property_type, p.action, p.previous_value, p.new_value) for p in single_relationship.properties} == { + (DatabaseEdgeType.IS_RELATED, DiffAction.REMOVED, car_accord_main.id, None), + (DatabaseEdgeType.IS_VISIBLE, DiffAction.REMOVED, True, None), + (DatabaseEdgeType.IS_PROTECTED, DiffAction.REMOVED, False, None), + } + for prop_diff in single_relationship.properties: + assert before_branch_change < prop_diff.changed_at < after_branch_change + # check relationship on added peer on branch + alfred_node = nodes_by_id[person_alfred_main.get_id()] + assert alfred_node.uuid == person_alfred_main.get_id() + assert alfred_node.kind == "TestPerson" + assert alfred_node.action is DiffAction.UPDATED + assert len(alfred_node.attributes) == 0 + assert len(alfred_node.relationships) == 1 + relationship_diff = alfred_node.relationships[0] + assert relationship_diff.name == "cars" + assert relationship_diff.action is DiffAction.UPDATED + elements_by_peer_id = {e.peer_id: e for e in relationship_diff.relationships} + assert set(elements_by_peer_id.keys()) == {car_accord_main.get_id()} + single_relationship = relationship_diff.relationships[0] + assert single_relationship.peer_id == car_accord_main.id + assert single_relationship.action is DiffAction.ADDED + properties_by_type = {p.property_type: p for p in single_relationship.properties} + assert set(properties_by_type.keys()) == { + DatabaseEdgeType.IS_RELATED, + DatabaseEdgeType.IS_PROTECTED, + DatabaseEdgeType.IS_VISIBLE, + } + assert {(p.property_type, p.action, p.previous_value, p.new_value) for p in single_relationship.properties} == { + (DatabaseEdgeType.IS_RELATED, DiffAction.ADDED, None, car_accord_main.id), + (DatabaseEdgeType.IS_VISIBLE, DiffAction.ADDED, None, True), + (DatabaseEdgeType.IS_PROTECTED, DiffAction.ADDED, None, False), + } + for prop_diff in single_relationship.properties: + assert before_branch_change < prop_diff.changed_at < after_branch_change + # check main + root_path = diff_parser.get_diff_root_for_branch(branch=default_branch.name) + assert root_path.branch == default_branch.name + nodes_by_id = {n.uuid: n for n in root_path.nodes} + assert set(nodes_by_id.keys()) == {car_accord_main.id, person_john_main.id, person_jane_main.id} + # check relationship on car node on main + car_node = nodes_by_id[car_main.get_id()] + assert car_node.uuid == car_accord_main.id + assert car_node.kind == "TestCar" + assert car_node.action is DiffAction.UPDATED + assert len(car_node.attributes) == 0 + assert len(car_node.relationships) == 1 + relationship_diff = car_node.relationships[0] + assert relationship_diff.name == "owner" + assert relationship_diff.action is DiffAction.UPDATED + elements_by_peer_id = {e.peer_id: e for e in relationship_diff.relationships} + assert set(elements_by_peer_id.keys()) == {person_john_main.id, person_jane_main.id} + removed_relationship = elements_by_peer_id[person_john_main.id] + assert removed_relationship.peer_id == person_john_main.id + assert removed_relationship.action is DiffAction.REMOVED + properties_by_type = {p.property_type: p for p in removed_relationship.properties} + assert set(properties_by_type.keys()) == { + DatabaseEdgeType.IS_RELATED, + DatabaseEdgeType.IS_PROTECTED, + DatabaseEdgeType.IS_VISIBLE, + } + assert {(p.property_type, p.action, p.previous_value, p.new_value) for p in removed_relationship.properties} == { + (DatabaseEdgeType.IS_RELATED, DiffAction.REMOVED, person_john_main.id, None), + (DatabaseEdgeType.IS_VISIBLE, DiffAction.REMOVED, True, None), + (DatabaseEdgeType.IS_PROTECTED, DiffAction.REMOVED, False, None), + } + for prop_diff in removed_relationship.properties: + assert before_main_change < prop_diff.changed_at < after_main_change + added_relationship = elements_by_peer_id[person_jane_main.id] + assert added_relationship.peer_id == person_jane_main.id + assert added_relationship.action is DiffAction.ADDED + properties_by_type = {p.property_type: p for p in added_relationship.properties} + assert set(properties_by_type.keys()) == { + DatabaseEdgeType.IS_RELATED, + DatabaseEdgeType.IS_PROTECTED, + DatabaseEdgeType.IS_VISIBLE, + } + assert {(p.property_type, p.action, p.previous_value, p.new_value) for p in added_relationship.properties} == { + (DatabaseEdgeType.IS_RELATED, DiffAction.ADDED, None, person_jane_main.id), + (DatabaseEdgeType.IS_VISIBLE, DiffAction.ADDED, None, True), + (DatabaseEdgeType.IS_PROTECTED, DiffAction.ADDED, None, False), + } + for prop_diff in added_relationship.properties: + assert before_main_change < prop_diff.changed_at < after_main_change + + # check relationship on removed peer on main + john_node = nodes_by_id[person_john_main.get_id()] + assert john_node.uuid == person_john_main.get_id() + assert john_node.kind == "TestPerson" + assert john_node.action is DiffAction.UPDATED + assert len(john_node.attributes) == 0 + assert len(john_node.relationships) == 1 + relationship_diff = john_node.relationships[0] + assert relationship_diff.name == "cars" + assert relationship_diff.action is DiffAction.UPDATED + elements_by_peer_id = {e.peer_id: e for e in relationship_diff.relationships} + assert set(elements_by_peer_id.keys()) == {car_accord_main.get_id()} + single_relationship = relationship_diff.relationships[0] + assert single_relationship.peer_id == car_accord_main.id + assert single_relationship.action is DiffAction.REMOVED + properties_by_type = {p.property_type: p for p in single_relationship.properties} + assert set(properties_by_type.keys()) == { + DatabaseEdgeType.IS_RELATED, + DatabaseEdgeType.IS_PROTECTED, + DatabaseEdgeType.IS_VISIBLE, + } + assert {(p.property_type, p.action, p.previous_value, p.new_value) for p in single_relationship.properties} == { + (DatabaseEdgeType.IS_RELATED, DiffAction.REMOVED, car_accord_main.id, None), + (DatabaseEdgeType.IS_VISIBLE, DiffAction.REMOVED, True, None), + (DatabaseEdgeType.IS_PROTECTED, DiffAction.REMOVED, False, None), + } + for prop_diff in single_relationship.properties: + assert before_main_change < prop_diff.changed_at < after_main_change + # check relationship on added peer on main + jane_node = nodes_by_id[person_jane_main.get_id()] + assert jane_node.uuid == person_jane_main.get_id() + assert jane_node.kind == "TestPerson" + assert jane_node.action is DiffAction.UPDATED + assert len(jane_node.attributes) == 0 + assert len(jane_node.relationships) == 1 + relationship_diff = jane_node.relationships[0] + assert relationship_diff.name == "cars" + assert relationship_diff.action is DiffAction.UPDATED + elements_by_peer_id = {e.peer_id: e for e in relationship_diff.relationships} + assert set(elements_by_peer_id.keys()) == {car_accord_main.get_id()} + single_relationship = relationship_diff.relationships[0] + assert single_relationship.peer_id == car_accord_main.id + assert single_relationship.action is DiffAction.ADDED + properties_by_type = {p.property_type: p for p in single_relationship.properties} + assert set(properties_by_type.keys()) == { + DatabaseEdgeType.IS_RELATED, + DatabaseEdgeType.IS_PROTECTED, + DatabaseEdgeType.IS_VISIBLE, + } + assert {(p.property_type, p.action, p.previous_value, p.new_value) for p in single_relationship.properties} == { + (DatabaseEdgeType.IS_RELATED, DiffAction.ADDED, None, car_accord_main.id), + (DatabaseEdgeType.IS_VISIBLE, DiffAction.ADDED, None, True), + (DatabaseEdgeType.IS_PROTECTED, DiffAction.ADDED, None, False), + } + for prop_diff in single_relationship.properties: + assert before_main_change < prop_diff.changed_at < after_main_change + + async def test_relationship_one_property_branch_update( db: InfrahubDatabase, default_branch: Branch, @@ -375,6 +677,7 @@ async def test_relationship_one_property_branch_update( db=db, branch=branch, base_branch=default_branch, + diff_from=from_time, ) await diff_query.execute(db=db) diff_parser = DiffQueryParser( @@ -389,14 +692,16 @@ async def test_relationship_one_property_branch_update( assert diff_parser.get_branches() == {branch.name, default_branch.name} root_path = diff_parser.get_diff_root_for_branch(branch=branch.name) assert root_path.branch == branch.name - assert len(root_path.nodes) == 1 - node_diff = root_path.nodes[0] - assert node_diff.uuid == car_accord_main.id - assert node_diff.kind == "TestCar" - assert node_diff.action is DiffAction.UPDATED - assert len(node_diff.attributes) == 0 - assert len(node_diff.relationships) == 1 - relationship_diff = node_diff.relationships[0] + nodes_by_id = {n.uuid: n for n in root_path.nodes} + assert set(nodes_by_id.keys()) == {car_accord_main.id, person_john_main.id} + # check relationship property on car node on branch + car_node = nodes_by_id[car_main.get_id()] + assert car_node.uuid == car_accord_main.id + assert car_node.kind == "TestCar" + assert car_node.action is DiffAction.UPDATED + assert len(car_node.attributes) == 0 + assert len(car_node.relationships) == 1 + relationship_diff = car_node.relationships[0] assert relationship_diff.name == "owner" assert relationship_diff.action is DiffAction.UPDATED assert len(relationship_diff.relationships) == 1 @@ -416,6 +721,34 @@ async def test_relationship_one_property_branch_update( assert property_diff.new_value == person_john_main.id assert property_diff.action is DiffAction.UNCHANGED assert property_diff.changed_at < before_branch_change + # check relationship property on person node on branch + john_node = nodes_by_id[person_john_main.get_id()] + assert john_node.uuid == person_john_main.get_id() + assert john_node.kind == "TestPerson" + assert john_node.action is DiffAction.UPDATED + assert len(john_node.attributes) == 0 + assert len(john_node.relationships) == 1 + relationship_diff = john_node.relationships[0] + assert relationship_diff.name == "cars" + assert relationship_diff.action is DiffAction.UPDATED + assert len(relationship_diff.relationships) == 1 + single_relationship = relationship_diff.relationships[0] + assert single_relationship.peer_id == car_main.get_id() + assert single_relationship.action is DiffAction.UPDATED + assert len(single_relationship.properties) == 2 + property_diff_by_type = {p.property_type: p for p in single_relationship.properties} + property_diff = property_diff_by_type[DatabaseEdgeType.IS_VISIBLE] + assert property_diff.property_type == DatabaseEdgeType.IS_VISIBLE + assert property_diff.previous_value is True + assert property_diff.new_value is False + assert before_branch_change < property_diff.changed_at < after_branch_change + property_diff = property_diff_by_type[DatabaseEdgeType.IS_RELATED] + assert property_diff.property_type == DatabaseEdgeType.IS_RELATED + assert property_diff.previous_value == car_main.get_id() + assert property_diff.new_value == car_main.get_id() + assert property_diff.action is DiffAction.UNCHANGED + assert property_diff.changed_at < before_branch_change + # check relationship peer on new peer on main root_main_path = diff_parser.get_diff_root_for_branch(branch=default_branch.name) assert root_main_path.branch == default_branch.name assert len(root_main_path.nodes) == 3 @@ -433,6 +766,7 @@ async def test_relationship_one_property_branch_update( single_relationship_diff = relationship_diff.relationships[0] assert single_relationship_diff.peer_id == car_accord_main.id assert single_relationship_diff.action is DiffAction.ADDED + # check relationship peer on old peer on main node_diff = diff_nodes_by_id[person_john_main.id] assert node_diff.uuid == person_john_main.id assert node_diff.kind == "TestPerson" @@ -446,6 +780,7 @@ async def test_relationship_one_property_branch_update( single_relationship_diff = relationship_diff.relationships[0] assert single_relationship_diff.peer_id == car_accord_main.id assert single_relationship_diff.action is DiffAction.REMOVED + # check relationship peer on car on main node_diff = diff_nodes_by_id[car_accord_main.id] assert node_diff.uuid == car_accord_main.id assert node_diff.kind == "TestCar" @@ -599,6 +934,489 @@ async def test_add_node_branch( } +async def test_many_relationship_property_update( + db: InfrahubDatabase, + default_branch: Branch, + person_john_main, + person_jane_main, + car_accord_main, +): + branch = await create_branch(db=db, branch_name="branch") + from_time = Timestamp(branch.created_at) + branch_car = await NodeManager.get_one(db=db, branch=branch, id=car_accord_main.id) + await branch_car.owner.update(db=db, data={"id": person_john_main.id, "_relation__source": person_jane_main.id}) + await branch_car.save(db=db) + + diff_query = await DiffAllPathsQuery.init( + db=db, + branch=branch, + base_branch=branch, + ) + await diff_query.execute(db=db) + + diff_parser = DiffQueryParser( + diff_query=diff_query, + base_branch_name=default_branch.name, + diff_branch_name=branch.name, + schema_manager=registry.schema, + from_time=from_time, + ) + diff_parser.parse() + + assert diff_parser.get_branches() == {branch.name} + root_path = diff_parser.get_diff_root_for_branch(branch=branch.name) + assert root_path.branch == branch.name + assert len(root_path.nodes) == 2 + nodes_by_id = {n.uuid: n for n in root_path.nodes} + assert set(nodes_by_id.keys()) == {person_john_main.get_id(), car_accord_main.get_id()} + john_node = nodes_by_id[person_john_main.get_id()] + assert john_node.action is DiffAction.UPDATED + assert john_node.attributes == [] + assert len(john_node.relationships) == 1 + cars_rel = john_node.relationships.pop() + assert cars_rel.name == "cars" + assert cars_rel.action is DiffAction.UPDATED + assert len(cars_rel.relationships) == 1 + cars_element = cars_rel.relationships.pop() + assert cars_element.action is DiffAction.UPDATED + assert cars_element.peer_id == car_accord_main.get_id() + properties_by_type = {p.property_type: p for p in cars_element.properties} + assert set(properties_by_type.keys()) == {DatabaseEdgeType.IS_RELATED, DatabaseEdgeType.HAS_SOURCE} + is_related_rel = properties_by_type[DatabaseEdgeType.IS_RELATED] + assert is_related_rel.action is DiffAction.UNCHANGED + assert is_related_rel.previous_value == car_accord_main.get_id() + assert is_related_rel.new_value == car_accord_main.get_id() + source_rel = properties_by_type[DatabaseEdgeType.HAS_SOURCE] + assert source_rel.action is DiffAction.ADDED + assert source_rel.previous_value is None + assert source_rel.new_value == person_jane_main.get_id() + car_node = nodes_by_id[car_accord_main.get_id()] + assert car_node.action is DiffAction.UPDATED + assert car_node.attributes == [] + assert len(car_node.relationships) == 1 + owner_rel = car_node.relationships.pop() + assert owner_rel.name == "owner" + assert owner_rel.action is DiffAction.UPDATED + assert len(owner_rel.relationships) == 1 + owner_element = owner_rel.relationships.pop() + assert owner_element.action is DiffAction.UPDATED + assert owner_element.peer_id == person_john_main.get_id() + properties_by_type = {p.property_type: p for p in owner_element.properties} + assert set(properties_by_type.keys()) == {DatabaseEdgeType.IS_RELATED, DatabaseEdgeType.HAS_SOURCE} + is_related_rel = properties_by_type[DatabaseEdgeType.IS_RELATED] + assert is_related_rel.action is DiffAction.UNCHANGED + assert is_related_rel.previous_value == person_john_main.get_id() + assert is_related_rel.new_value == person_john_main.get_id() + source_rel = properties_by_type[DatabaseEdgeType.HAS_SOURCE] + assert source_rel.action is DiffAction.ADDED + assert source_rel.previous_value is None + assert source_rel.new_value == person_jane_main.get_id() + + +async def test_cardinality_one_peer_conflicting_updates( + db: InfrahubDatabase, + default_branch: Branch, + person_john_main, + person_jane_main, + person_albert_main, + car_accord_main, +): + branch = await create_branch(db=db, branch_name="branch") + from_time = Timestamp(branch.created_at) + branch_car = await NodeManager.get_one(db=db, branch=branch, id=car_accord_main.id) + await branch_car.owner.update(db=db, data={"id": person_albert_main.id}) + await branch_car.save(db=db) + branch_update_done = Timestamp() + main_car = await NodeManager.get_one(db=db, branch=default_branch, id=car_accord_main.id) + await main_car.owner.update(db=db, data={"id": person_jane_main.id}) + await main_car.save(db=db) + main_update_done = Timestamp() + + diff_query = await DiffAllPathsQuery.init( + db=db, + branch=branch, + base_branch=branch, + diff_from=from_time, + ) + await diff_query.execute(db=db) + diff_parser = DiffQueryParser( + diff_query=diff_query, + base_branch_name=default_branch.name, + diff_branch_name=branch.name, + schema_manager=registry.schema, + from_time=from_time, + ) + diff_parser.parse() + + assert diff_parser.get_branches() == {branch.name, default_branch.name} + # check branch + root_path = diff_parser.get_diff_root_for_branch(branch=branch.name) + assert root_path.branch == branch.name + assert len(root_path.nodes) == 3 + nodes_by_id = {n.uuid: n for n in root_path.nodes} + assert set(nodes_by_id.keys()) == {car_accord_main.get_id(), person_john_main.get_id(), person_albert_main.get_id()} + # check car node on branch + car_node = nodes_by_id[car_accord_main.id] + assert car_node.action is DiffAction.UPDATED + assert car_node.changed_at < from_time + assert car_node.attributes == [] + assert len(car_node.relationships) == 1 + owner_rel = car_node.relationships[0] + assert owner_rel.name == "owner" + assert owner_rel.action is DiffAction.UPDATED + assert from_time < owner_rel.changed_at < branch_update_done + elements_by_id = {e.peer_id: e for e in owner_rel.relationships} + assert set(elements_by_id.keys()) == {person_john_main.id, person_albert_main.id} + # check john removed + john_element = elements_by_id[person_john_main.id] + assert john_element.action is DiffAction.REMOVED + assert from_time < john_element.changed_at < branch_update_done + properties_by_type = {p.property_type: p for p in john_element.properties} + assert set(properties_by_type.keys()) == { + DatabaseEdgeType.IS_RELATED, + DatabaseEdgeType.IS_VISIBLE, + DatabaseEdgeType.IS_PROTECTED, + } + for prop_type, previous_value in [ + (DatabaseEdgeType.IS_RELATED, person_john_main.id), + (DatabaseEdgeType.IS_VISIBLE, True), + (DatabaseEdgeType.IS_PROTECTED, False), + ]: + diff_prop = properties_by_type[prop_type] + assert diff_prop.previous_value == previous_value + assert diff_prop.new_value is None + assert from_time < diff_prop.changed_at < branch_update_done + # check albert added + albert_element = elements_by_id[person_albert_main.id] + assert albert_element.action is DiffAction.ADDED + assert from_time < albert_element.changed_at < branch_update_done + properties_by_type = {p.property_type: p for p in albert_element.properties} + assert set(properties_by_type.keys()) == { + DatabaseEdgeType.IS_RELATED, + DatabaseEdgeType.IS_VISIBLE, + DatabaseEdgeType.IS_PROTECTED, + } + for prop_type, new_value in [ + (DatabaseEdgeType.IS_RELATED, person_albert_main.id), + (DatabaseEdgeType.IS_VISIBLE, True), + (DatabaseEdgeType.IS_PROTECTED, False), + ]: + diff_prop = properties_by_type[prop_type] + assert diff_prop.previous_value is None + assert diff_prop.new_value == new_value + assert from_time < diff_prop.changed_at < branch_update_done + # check john node on branch + john_node = nodes_by_id[person_john_main.id] + assert john_node.action is DiffAction.UPDATED + assert john_node.attributes == [] + assert len(john_node.relationships) == 1 + assert john_node.changed_at < from_time + cars_rel = john_node.relationships.pop() + assert cars_rel.name == "cars" + assert cars_rel.action is DiffAction.UPDATED + assert from_time < cars_rel.changed_at < branch_update_done + assert len(cars_rel.relationships) == 1 + cars_element = cars_rel.relationships.pop() + assert cars_element.peer_id == car_accord_main.id + assert cars_element.action is DiffAction.REMOVED + assert from_time < cars_element.changed_at < branch_update_done + properties_by_type = {p.property_type: p for p in cars_element.properties} + assert set(properties_by_type.keys()) == { + DatabaseEdgeType.IS_RELATED, + DatabaseEdgeType.IS_VISIBLE, + DatabaseEdgeType.IS_PROTECTED, + } + for prop_type, previous_value in [ + (DatabaseEdgeType.IS_RELATED, car_accord_main.id), + (DatabaseEdgeType.IS_VISIBLE, True), + (DatabaseEdgeType.IS_PROTECTED, False), + ]: + diff_prop = properties_by_type[prop_type] + assert diff_prop.previous_value == previous_value + assert diff_prop.new_value is None + assert from_time < diff_prop.changed_at < branch_update_done + # check albert node on branch + albert_node = nodes_by_id[person_albert_main.id] + assert albert_node.action is DiffAction.UPDATED + assert albert_node.attributes == [] + assert len(albert_node.relationships) == 1 + assert albert_node.changed_at < from_time + cars_rel = albert_node.relationships.pop() + assert cars_rel.name == "cars" + assert cars_rel.action is DiffAction.UPDATED + assert from_time < cars_rel.changed_at < branch_update_done + assert len(cars_rel.relationships) == 1 + cars_element = cars_rel.relationships.pop() + assert cars_element.peer_id == car_accord_main.id + assert cars_element.action is DiffAction.ADDED + assert from_time < cars_element.changed_at < branch_update_done + properties_by_type = {p.property_type: p for p in cars_element.properties} + assert set(properties_by_type.keys()) == { + DatabaseEdgeType.IS_RELATED, + DatabaseEdgeType.IS_VISIBLE, + DatabaseEdgeType.IS_PROTECTED, + } + for prop_type, new_value in [ + (DatabaseEdgeType.IS_RELATED, car_accord_main.id), + (DatabaseEdgeType.IS_VISIBLE, True), + (DatabaseEdgeType.IS_PROTECTED, False), + ]: + diff_prop = properties_by_type[prop_type] + assert diff_prop.previous_value is None + assert diff_prop.new_value == new_value + assert from_time < diff_prop.changed_at < branch_update_done + # check main + root_path = diff_parser.get_diff_root_for_branch(branch=default_branch.name) + assert root_path.branch == default_branch.name + assert len(root_path.nodes) == 3 + nodes_by_id = {n.uuid: n for n in root_path.nodes} + assert set(nodes_by_id.keys()) == {car_accord_main.get_id(), person_john_main.get_id(), person_jane_main.get_id()} + # check car node on main + car_node = nodes_by_id[car_accord_main.id] + assert car_node.action is DiffAction.UPDATED + assert car_node.changed_at < from_time + assert car_node.attributes == [] + assert len(car_node.relationships) == 1 + owner_rel = car_node.relationships[0] + assert owner_rel.name == "owner" + assert owner_rel.action is DiffAction.UPDATED + assert branch_update_done < owner_rel.changed_at < main_update_done + elements_by_id = {e.peer_id: e for e in owner_rel.relationships} + assert set(elements_by_id.keys()) == {person_john_main.id, person_jane_main.id} + # check john removed + john_element = elements_by_id[person_john_main.id] + assert john_element.action is DiffAction.REMOVED + assert branch_update_done < john_element.changed_at < main_update_done + properties_by_type = {p.property_type: p for p in john_element.properties} + assert set(properties_by_type.keys()) == { + DatabaseEdgeType.IS_RELATED, + DatabaseEdgeType.IS_VISIBLE, + DatabaseEdgeType.IS_PROTECTED, + } + for prop_type, previous_value in [ + (DatabaseEdgeType.IS_RELATED, person_john_main.id), + (DatabaseEdgeType.IS_VISIBLE, True), + (DatabaseEdgeType.IS_PROTECTED, False), + ]: + diff_prop = properties_by_type[prop_type] + assert diff_prop.previous_value == previous_value + assert diff_prop.new_value is None + assert branch_update_done < diff_prop.changed_at < main_update_done + # check jane added + jane_element = elements_by_id[person_jane_main.id] + assert jane_element.action is DiffAction.ADDED + assert branch_update_done < jane_element.changed_at < main_update_done + properties_by_type = {p.property_type: p for p in jane_element.properties} + assert set(properties_by_type.keys()) == { + DatabaseEdgeType.IS_RELATED, + DatabaseEdgeType.IS_VISIBLE, + DatabaseEdgeType.IS_PROTECTED, + } + for prop_type, new_value in [ + (DatabaseEdgeType.IS_RELATED, person_jane_main.id), + (DatabaseEdgeType.IS_VISIBLE, True), + (DatabaseEdgeType.IS_PROTECTED, False), + ]: + diff_prop = properties_by_type[prop_type] + assert diff_prop.previous_value is None + assert diff_prop.new_value == new_value + assert branch_update_done < diff_prop.changed_at < main_update_done + # check john node on main + john_node = nodes_by_id[person_john_main.id] + assert john_node.action is DiffAction.UPDATED + assert john_node.attributes == [] + assert len(john_node.relationships) == 1 + assert john_node.changed_at < from_time + cars_rel = john_node.relationships.pop() + assert cars_rel.name == "cars" + assert cars_rel.action is DiffAction.UPDATED + assert branch_update_done < cars_rel.changed_at < main_update_done + assert len(cars_rel.relationships) == 1 + cars_element = cars_rel.relationships.pop() + assert cars_element.peer_id == car_accord_main.id + assert cars_element.action is DiffAction.REMOVED + assert branch_update_done < cars_element.changed_at < main_update_done + properties_by_type = {p.property_type: p for p in cars_element.properties} + assert set(properties_by_type.keys()) == { + DatabaseEdgeType.IS_RELATED, + DatabaseEdgeType.IS_VISIBLE, + DatabaseEdgeType.IS_PROTECTED, + } + for prop_type, previous_value in [ + (DatabaseEdgeType.IS_RELATED, car_accord_main.id), + (DatabaseEdgeType.IS_VISIBLE, True), + (DatabaseEdgeType.IS_PROTECTED, False), + ]: + diff_prop = properties_by_type[prop_type] + assert diff_prop.previous_value == previous_value + assert diff_prop.new_value is None + assert branch_update_done < diff_prop.changed_at < main_update_done + # check jane node on main + jane_node = nodes_by_id[person_jane_main.id] + assert jane_node.action is DiffAction.UPDATED + assert jane_node.attributes == [] + assert len(jane_node.relationships) == 1 + assert jane_node.changed_at < from_time + cars_rel = jane_node.relationships.pop() + assert cars_rel.name == "cars" + assert cars_rel.action is DiffAction.UPDATED + assert branch_update_done < cars_rel.changed_at < main_update_done + assert len(cars_rel.relationships) == 1 + cars_element = cars_rel.relationships.pop() + assert cars_element.peer_id == car_accord_main.id + assert cars_element.action is DiffAction.ADDED + assert branch_update_done < cars_element.changed_at < main_update_done + properties_by_type = {p.property_type: p for p in cars_element.properties} + assert set(properties_by_type.keys()) == { + DatabaseEdgeType.IS_RELATED, + DatabaseEdgeType.IS_VISIBLE, + DatabaseEdgeType.IS_PROTECTED, + } + for prop_type, new_value in [ + (DatabaseEdgeType.IS_RELATED, car_accord_main.id), + (DatabaseEdgeType.IS_VISIBLE, True), + (DatabaseEdgeType.IS_PROTECTED, False), + ]: + diff_prop = properties_by_type[prop_type] + assert diff_prop.previous_value is None + assert diff_prop.new_value == new_value + assert branch_update_done < diff_prop.changed_at < main_update_done + + +async def test_relationship_property_owner_conflicting_updates( + db: InfrahubDatabase, + default_branch: Branch, + person_john_main, + car_accord_main, +): + branch = await create_branch(db=db, branch_name="branch") + from_time = Timestamp(branch.created_at) + main_john = await NodeManager.get_one(db=db, branch=default_branch, id=person_john_main.id) + await main_john.cars.update(db=db, data={"id": car_accord_main.id, "_relation__owner": person_john_main.id}) + await main_john.save(db=db) + branch_john = await NodeManager.get_one(db=db, branch=branch, id=person_john_main.id) + await branch_john.cars.update(db=db, data={"id": car_accord_main.id, "_relation__owner": car_accord_main.id}) + await branch_john.save(db=db) + + diff_query = await DiffAllPathsQuery.init( + db=db, + branch=branch, + base_branch=branch, + diff_from=from_time, + ) + await diff_query.execute(db=db) + diff_parser = DiffQueryParser( + diff_query=diff_query, + base_branch_name=default_branch.name, + diff_branch_name=branch.name, + schema_manager=registry.schema, + from_time=from_time, + ) + diff_parser.parse() + + assert diff_parser.get_branches() == {branch.name, default_branch.name} + # check branch + root_path = diff_parser.get_diff_root_for_branch(branch=branch.name) + assert root_path.branch == branch.name + assert len(root_path.nodes) == 2 + nodes_by_id = {n.uuid: n for n in root_path.nodes} + assert set(nodes_by_id.keys()) == {person_john_main.get_id(), car_accord_main.get_id()} + # john node on branch + john_node = nodes_by_id[person_john_main.get_id()] + assert john_node.action is DiffAction.UPDATED + assert john_node.attributes == [] + assert len(john_node.relationships) == 1 + cars_rel = john_node.relationships.pop() + assert cars_rel.name == "cars" + assert cars_rel.action is DiffAction.UPDATED + assert len(cars_rel.relationships) == 1 + cars_element = cars_rel.relationships.pop() + assert cars_element.action is DiffAction.UPDATED + assert cars_element.peer_id == car_accord_main.get_id() + properties_by_type = {p.property_type: p for p in cars_element.properties} + assert set(properties_by_type.keys()) == {DatabaseEdgeType.IS_RELATED, DatabaseEdgeType.HAS_OWNER} + is_related_rel = properties_by_type[DatabaseEdgeType.IS_RELATED] + assert is_related_rel.action is DiffAction.UNCHANGED + assert is_related_rel.previous_value == car_accord_main.get_id() + assert is_related_rel.new_value == car_accord_main.get_id() + owner_rel = properties_by_type[DatabaseEdgeType.HAS_OWNER] + assert owner_rel.action is DiffAction.ADDED + assert owner_rel.previous_value is None + assert owner_rel.new_value == car_accord_main.get_id() + # car node on branch + car_node = nodes_by_id[car_accord_main.get_id()] + assert car_node.action is DiffAction.UPDATED + assert car_node.attributes == [] + assert len(car_node.relationships) == 1 + owner_rel = car_node.relationships.pop() + assert owner_rel.name == "owner" + assert owner_rel.action is DiffAction.UPDATED + assert len(owner_rel.relationships) == 1 + owner_element = owner_rel.relationships.pop() + assert owner_element.action is DiffAction.UPDATED + assert owner_element.peer_id == person_john_main.get_id() + properties_by_type = {p.property_type: p for p in owner_element.properties} + assert set(properties_by_type.keys()) == {DatabaseEdgeType.IS_RELATED, DatabaseEdgeType.HAS_OWNER} + is_related_rel = properties_by_type[DatabaseEdgeType.IS_RELATED] + assert is_related_rel.action is DiffAction.UNCHANGED + assert is_related_rel.previous_value == person_john_main.get_id() + assert is_related_rel.new_value == person_john_main.get_id() + owner_rel = properties_by_type[DatabaseEdgeType.HAS_OWNER] + assert owner_rel.action is DiffAction.ADDED + assert owner_rel.previous_value is None + assert owner_rel.new_value == car_accord_main.get_id() + # check main + root_path = diff_parser.get_diff_root_for_branch(branch=default_branch.name) + assert root_path.branch == default_branch.name + assert len(root_path.nodes) == 2 + nodes_by_id = {n.uuid: n for n in root_path.nodes} + assert set(nodes_by_id.keys()) == {person_john_main.get_id(), car_accord_main.get_id()} + # john node on main + john_node = nodes_by_id[person_john_main.get_id()] + assert john_node.action is DiffAction.UPDATED + assert john_node.attributes == [] + assert len(john_node.relationships) == 1 + cars_rel = john_node.relationships.pop() + assert cars_rel.name == "cars" + assert cars_rel.action is DiffAction.UPDATED + assert len(cars_rel.relationships) == 1 + cars_element = cars_rel.relationships.pop() + assert cars_element.action is DiffAction.UPDATED + assert cars_element.peer_id == car_accord_main.get_id() + properties_by_type = {p.property_type: p for p in cars_element.properties} + assert set(properties_by_type.keys()) == {DatabaseEdgeType.IS_RELATED, DatabaseEdgeType.HAS_OWNER} + is_related_rel = properties_by_type[DatabaseEdgeType.IS_RELATED] + assert is_related_rel.action is DiffAction.UNCHANGED + assert is_related_rel.previous_value == car_accord_main.get_id() + assert is_related_rel.new_value == car_accord_main.get_id() + owner_rel = properties_by_type[DatabaseEdgeType.HAS_OWNER] + assert owner_rel.action is DiffAction.ADDED + assert owner_rel.previous_value is None + assert owner_rel.new_value == person_john_main.get_id() + # car node on main + car_node = nodes_by_id[car_accord_main.get_id()] + assert car_node.action is DiffAction.UPDATED + assert car_node.attributes == [] + assert len(car_node.relationships) == 1 + owner_rel = car_node.relationships.pop() + assert owner_rel.name == "owner" + assert owner_rel.action is DiffAction.UPDATED + assert len(owner_rel.relationships) == 1 + owner_element = owner_rel.relationships.pop() + assert owner_element.action is DiffAction.UPDATED + assert owner_element.peer_id == person_john_main.get_id() + properties_by_type = {p.property_type: p for p in owner_element.properties} + assert set(properties_by_type.keys()) == {DatabaseEdgeType.IS_RELATED, DatabaseEdgeType.HAS_OWNER} + is_related_rel = properties_by_type[DatabaseEdgeType.IS_RELATED] + assert is_related_rel.action is DiffAction.UNCHANGED + assert is_related_rel.previous_value == person_john_main.get_id() + assert is_related_rel.new_value == person_john_main.get_id() + owner_rel = properties_by_type[DatabaseEdgeType.HAS_OWNER] + assert owner_rel.action is DiffAction.ADDED + assert owner_rel.previous_value is None + assert owner_rel.new_value == person_john_main.get_id() + + async def test_agnostic_source_relationship_update( db: InfrahubDatabase, default_branch: Branch, diff --git a/backend/tests/unit/core/diff/test_path_identifier_enricher.py b/backend/tests/unit/core/diff/test_path_identifier_enricher.py index 6228c6e5d9..3a654f7f5b 100644 --- a/backend/tests/unit/core/diff/test_path_identifier_enricher.py +++ b/backend/tests/unit/core/diff/test_path_identifier_enricher.py @@ -53,10 +53,9 @@ async def test_path_identifiers_added(self, db: InfrahubDatabase, car_person_sch relationship_path = f"{node_path}/{enriched_relationship.name}" assert enriched_relationship.path_identifier == relationship_path enriched_element = enriched_relationship.relationships.pop() - element_path = f"{relationship_path}/{enriched_element.peer_id}" - assert enriched_element.path_identifier == element_path + assert enriched_element.path_identifier == relationship_path element_property_paths = {p.path_identifier for p in enriched_element.properties} assert element_property_paths == { - f"{element_path}/value", - f"{element_path}/property/{DatabaseEdgeType.IS_PROTECTED.value}", + f"{relationship_path}/value", + f"{relationship_path}/property/{DatabaseEdgeType.IS_PROTECTED.value}", } diff --git a/backend/tests/unit/core/migrations/graph/test_013.py b/backend/tests/unit/core/migrations/graph/test_013.py index 645828bfe6..e515b93a24 100644 --- a/backend/tests/unit/core/migrations/graph/test_013.py +++ b/backend/tests/unit/core/migrations/graph/test_013.py @@ -5,7 +5,7 @@ from infrahub.core.manager import NodeManager from infrahub.core.migrations.graph.m013_convert_git_password_credential import ( Migration013, - Migration013AddAdminStatusData, + Migration013AddInternalStatusData, Migration013ConvertCoreRepositoryWithCred, Migration013ConvertCoreRepositoryWithoutCred, Migration013DeleteUsernamePasswordGenericSchema, @@ -275,16 +275,16 @@ async def test_migration_013_delete_username_password_schema( assert nbr_rels_after == nbr_rels_before + (2 * 3) -async def test_migration_013_add_admin_status_data( +async def test_migration_013_add_internal_status_data( db: InfrahubDatabase, reset_registry, default_branch, delete_all_nodes_in_db, migration_013_data ): nbr_rels_before = await count_relationships(db=db) - query = await Migration013AddAdminStatusData.init(db=db) + query = await Migration013AddInternalStatusData.init(db=db) await query.execute(db=db) assert query.stats.get_counter(name="nodes_created") == 3 + 1 - query = await Migration013AddAdminStatusData.init(db=db) + query = await Migration013AddInternalStatusData.init(db=db) await query.execute(db=db) assert query.stats.get_counter(name="nodes_created") == 0 diff --git a/backend/tests/unit/core/schema_manager/test_manager_schema.py b/backend/tests/unit/core/schema_manager/test_manager_schema.py index 7e7d701fc2..d2ddf74d95 100644 --- a/backend/tests/unit/core/schema_manager/test_manager_schema.py +++ b/backend/tests/unit/core/schema_manager/test_manager_schema.py @@ -1329,6 +1329,7 @@ async def test_schema_branch_process_filters( "name": "Criticality", "namespace": "Builtin", "default_filter": "name__value", + "human_friendly_id": ["name__value"], "label": "Criticality", "attributes": [ {"name": "name", "kind": "Text", "label": "Name", "unique": True}, @@ -1373,8 +1374,9 @@ async def test_schema_branch_process_filters( assert len(schema_branch.nodes) == 2 criticality_dict = schema_branch.get("BuiltinCriticality").model_dump() + tag_dict = schema_branch.get("BuiltinTag").model_dump() - expected_filters = [ + criticality_expected_filters = [ { "id": None, "name": "ids", @@ -1384,6 +1386,15 @@ async def test_schema_branch_process_filters( "description": None, "state": HashableModelState.PRESENT, }, + { + "description": None, + "enum": None, + "id": None, + "kind": FilterSchemaKind.TEXT, + "name": "hfid", + "object_kind": None, + "state": HashableModelState.PRESENT, + }, { "id": None, "name": "name__value", @@ -1646,9 +1657,176 @@ async def test_schema_branch_process_filters( "state": HashableModelState.PRESENT, }, ] + tag_expected_filters = [ + { + "description": None, + "enum": None, + "id": None, + "kind": FilterSchemaKind.TEXT, + "name": "ids", + "object_kind": None, + "state": HashableModelState.PRESENT, + }, + { + "description": None, + "enum": None, + "id": None, + "kind": FilterSchemaKind.TEXT, + "name": "name__value", + "object_kind": None, + "state": HashableModelState.PRESENT, + }, + { + "description": None, + "enum": None, + "id": None, + "kind": FilterSchemaKind.TEXT, + "name": "name__values", + "object_kind": None, + "state": HashableModelState.PRESENT, + }, + { + "description": None, + "enum": None, + "id": None, + "kind": FilterSchemaKind.BOOLEAN, + "name": "name__is_visible", + "object_kind": None, + "state": HashableModelState.PRESENT, + }, + { + "description": None, + "enum": None, + "id": None, + "kind": FilterSchemaKind.BOOLEAN, + "name": "name__is_protected", + "object_kind": None, + "state": HashableModelState.PRESENT, + }, + { + "description": None, + "enum": None, + "id": None, + "kind": FilterSchemaKind.TEXT, + "name": "name__source__id", + "object_kind": None, + "state": HashableModelState.PRESENT, + }, + { + "description": None, + "enum": None, + "id": None, + "kind": FilterSchemaKind.TEXT, + "name": "name__owner__id", + "object_kind": None, + "state": HashableModelState.PRESENT, + }, + { + "description": None, + "enum": None, + "id": None, + "kind": FilterSchemaKind.TEXT, + "name": "description__value", + "object_kind": None, + "state": HashableModelState.PRESENT, + }, + { + "description": None, + "enum": None, + "id": None, + "kind": FilterSchemaKind.TEXT, + "name": "description__values", + "object_kind": None, + "state": HashableModelState.PRESENT, + }, + { + "description": None, + "enum": None, + "id": None, + "kind": FilterSchemaKind.BOOLEAN, + "name": "description__is_visible", + "object_kind": None, + "state": HashableModelState.PRESENT, + }, + { + "description": None, + "enum": None, + "id": None, + "kind": FilterSchemaKind.BOOLEAN, + "name": "description__is_protected", + "object_kind": None, + "state": HashableModelState.PRESENT, + }, + { + "description": None, + "enum": None, + "id": None, + "kind": FilterSchemaKind.TEXT, + "name": "description__source__id", + "object_kind": None, + "state": HashableModelState.PRESENT, + }, + { + "description": None, + "enum": None, + "id": None, + "kind": FilterSchemaKind.TEXT, + "name": "description__owner__id", + "object_kind": None, + "state": HashableModelState.PRESENT, + }, + { + "description": None, + "enum": None, + "id": None, + "kind": FilterSchemaKind.TEXT, + "name": "any__value", + "object_kind": None, + "state": HashableModelState.PRESENT, + }, + { + "description": None, + "enum": None, + "id": None, + "kind": FilterSchemaKind.BOOLEAN, + "name": "any__is_visible", + "object_kind": None, + "state": HashableModelState.PRESENT, + }, + { + "description": None, + "enum": None, + "id": None, + "kind": FilterSchemaKind.BOOLEAN, + "name": "any__is_protected", + "object_kind": None, + "state": HashableModelState.PRESENT, + }, + { + "description": None, + "enum": None, + "id": None, + "kind": FilterSchemaKind.TEXT, + "name": "any__source__id", + "object_kind": None, + "state": HashableModelState.PRESENT, + }, + { + "description": None, + "enum": None, + "id": None, + "kind": FilterSchemaKind.TEXT, + "name": "any__owner__id", + "object_kind": None, + "state": HashableModelState.PRESENT, + }, + ] + + assert criticality_dict["filters"] == criticality_expected_filters + assert not DeepDiff(criticality_dict["filters"], criticality_expected_filters, ignore_order=True) - assert criticality_dict["filters"] == expected_filters - assert not DeepDiff(criticality_dict["filters"], expected_filters, ignore_order=True) + assert tag_dict["filters"] == tag_expected_filters + assert not DeepDiff(tag_dict["filters"], tag_expected_filters, ignore_order=True) async def test_process_relationships_on_delete_defaults_set(schema_all_in_one): @@ -2404,6 +2582,7 @@ async def test_load_schema_from_db( "namespace": "Test", "name": "Criticality", "default_filter": "name__value", + "human_friendly_id": ["name__value"], "label": "Criticality", "include_in_menu": True, "attributes": [ @@ -2436,6 +2615,7 @@ async def test_load_schema_from_db( "label": "Tag", "include_in_menu": False, "default_filter": "name__value", + "human_friendly_id": ["name__value"], "attributes": [ {"name": "name", "kind": "Text", "label": "Name", "unique": True}, {"name": "description", "kind": "Text", "label": "Description", "optional": True}, diff --git a/backend/tests/unit/git/conftest.py b/backend/tests/unit/git/conftest.py index ed63fc33b4..2267f1c7a5 100644 --- a/backend/tests/unit/git/conftest.py +++ b/backend/tests/unit/git/conftest.py @@ -514,7 +514,7 @@ async def mock_repositories_query(httpx_mock: HTTPXMock) -> HTTPXMock: "name": {"value": "infrahub-test-fixture-01"}, "location": {"value": "git@github.com:mock/infrahub-test-fixture-01.git"}, "commit": {"value": "aaaaaaaaaaaaaaaaaaaa"}, - "admin_status": {"value": "active"}, + "internal_status": {"value": "active"}, } } ] @@ -532,7 +532,7 @@ async def mock_repositories_query(httpx_mock: HTTPXMock) -> HTTPXMock: "name": {"value": "infrahub-test-fixture-01"}, "location": {"value": "git@github.com:mock/infrahub-test-fixture-01.git"}, "commit": {"value": "bbbbbbbbbbbbbbbbbbbb"}, - "admin_status": {"value": "active"}, + "internal_status": {"value": "active"}, } } ] diff --git a/backend/tests/unit/git/test_git_rpc.py b/backend/tests/unit/git/test_git_rpc.py index 46685c634a..3150e444ee 100644 --- a/backend/tests/unit/git/test_git_rpc.py +++ b/backend/tests/unit/git/test_git_rpc.py @@ -5,7 +5,7 @@ from infrahub_sdk import UUIDT, Config, InfrahubClient -from infrahub.core.constants import InfrahubKind, RepositoryAdminStatus +from infrahub.core.constants import InfrahubKind, RepositoryInternalStatus from infrahub.exceptions import RepositoryError from infrahub.git import InfrahubRepository from infrahub.git.repository import InfrahubReadOnlyRepository @@ -60,7 +60,7 @@ def setup_method(self): self.mock_repo = AsyncMock(spec=InfrahubRepository) self.mock_repo.default_branch = self.default_branch_name self.mock_repo.infrahub_branch_name = self.default_branch_name - self.mock_repo.admin_status = "active" + self.mock_repo.internal_status = "active" self.mock_repo_class.new.return_value = self.mock_repo def teardown_method(self): @@ -74,7 +74,7 @@ async def test_git_rpc_create_successful(self, git_upstream_repo_01: dict[str, s location=git_upstream_repo_01["path"], default_branch_name=self.default_branch_name, infrahub_branch_name=self.default_branch_name, - admin_status="active", + internal_status="active", ) await git.repository.add(message=message, service=self.services) @@ -89,7 +89,8 @@ async def test_git_rpc_create_successful(self, git_upstream_repo_01: dict[str, s client=self.client, task_report=self.git_report, infrahub_branch_name=self.default_branch_name, - admin_status="active", + internal_status="active", + default_branch_name=self.default_branch_name, ) self.mock_repo.import_objects_from_files.assert_awaited_once_with( infrahub_branch_name=self.default_branch_name, git_branch_name=self.default_branch_name @@ -115,7 +116,8 @@ async def test_git_rpc_merge( repository_name=repo.name, source_branch="branch01", destination_branch="main", - admin_status=RepositoryAdminStatus.ACTIVE.value, + internal_status=RepositoryInternalStatus.ACTIVE.value, + default_branch="main", ) client_config = Config(requester=dummy_async_request) @@ -203,7 +205,7 @@ async def test_git_rpc_add_read_only_success(self, git_upstream_repo_01: dict[st location=git_upstream_repo_01["path"], ref="branch01", infrahub_branch_name="read-only-branch", - admin_status="active", + internal_status="active", ) await git.repository.add_read_only(message=message, service=self.services) diff --git a/backend/tests/unit/graphql/mutations/test_repository.py b/backend/tests/unit/graphql/mutations/test_repository.py index a52d57c0b5..221c342841 100644 --- a/backend/tests/unit/graphql/mutations/test_repository.py +++ b/backend/tests/unit/graphql/mutations/test_repository.py @@ -5,7 +5,7 @@ import pytest from infrahub.core import registry -from infrahub.core.constants import InfrahubKind, RepositoryAdminStatus +from infrahub.core.constants import InfrahubKind, RepositoryInternalStatus from infrahub.core.initialization import create_branch from infrahub.core.manager import NodeManager from infrahub.core.node import Node @@ -63,12 +63,12 @@ async def test_repository_update(db: InfrahubDatabase, register_core_models_sche service = InfrahubServices(database=db, message_bus=recorder) UPDATE_COMMIT = """ - mutation CoreRepositoryUpdate($id: String!, $commit_id: String!, $admin_status: String!) { + mutation CoreRepositoryUpdate($id: String!, $commit_id: String!, $internal_status: String!) { CoreRepositoryUpdate( data: { id: $id commit: { value: $commit_id } - admin_status: { value: $admin_status } + internal_status: { value: $internal_status } }) { ok } @@ -81,13 +81,13 @@ async def test_repository_update(db: InfrahubDatabase, register_core_models_sche await repo.new(db=db, name="test-edge-demo", location="/tmp/edge") await repo.save(db=db) - repo.admin_status.value = RepositoryAdminStatus.STAGING.value + repo.internal_status.value = RepositoryInternalStatus.STAGING.value await repo.save(db=db) result = await graphql_mutation( query=UPDATE_COMMIT, db=db, - variables={"id": repo.id, "commit_id": commit_id, "admin_status": RepositoryAdminStatus.ACTIVE.value}, + variables={"id": repo.id, "commit_id": commit_id, "internal_status": RepositoryInternalStatus.ACTIVE.value}, service=service, ) @@ -96,7 +96,7 @@ async def test_repository_update(db: InfrahubDatabase, register_core_models_sche repo_main = await NodeManager.get_one(db=db, id=repo.id, raise_on_error=True) - assert repo_main.admin_status.value == RepositoryAdminStatus.ACTIVE.value + assert repo_main.internal_status.value == RepositoryInternalStatus.ACTIVE.value assert repo_main.commit.value == commit_id diff --git a/backend/tests/unit/graphql/mutations/test_resource_manager.py b/backend/tests/unit/graphql/mutations/test_resource_manager.py index 2d5b9c66df..9511650b0e 100644 --- a/backend/tests/unit/graphql/mutations/test_resource_manager.py +++ b/backend/tests/unit/graphql/mutations/test_resource_manager.py @@ -807,7 +807,7 @@ async def test_test_number_pool_update(db: InfrahubDatabase, default_branch: Bra ) assert update_forbidden.errors - assert "The fields 'model', 'model_attribute' or 'unique_for' can't be changed." in str(update_forbidden.errors[0]) + assert "The fields 'node' or 'node_attribute' can't be changed." in str(update_forbidden.errors[0]) assert update_invalid_range.errors assert "start_range can't be larger than end_range" in str(update_invalid_range.errors[0]) assert update_ok.data diff --git a/backend/tests/unit/graphql/queries/test_resource_pool.py b/backend/tests/unit/graphql/queries/test_resource_pool.py index 7ac763d003..a7e2d3908c 100644 --- a/backend/tests/unit/graphql/queries/test_resource_pool.py +++ b/backend/tests/unit/graphql/queries/test_resource_pool.py @@ -4,7 +4,7 @@ from infrahub.core import registry from infrahub.core.branch import Branch from infrahub.core.constants import InfrahubKind -from infrahub.core.initialization import create_branch +from infrahub.core.initialization import create_branch, initialization from infrahub.core.manager import NodeManager from infrahub.core.node import Node from infrahub.core.node.resource_manager.ip_address_pool import CoreIPAddressPool @@ -573,7 +573,7 @@ async def test_read_resources_in_pool_with_branch_with_mutations( async def test_number_pool_utilization(db: InfrahubDatabase, default_branch: Branch, register_core_models_schema): await load_schema(db=db, schema=SchemaRoot(nodes=[TICKET])) gql_params = prepare_graphql_params(db=db, include_subscription=False, branch=default_branch) - + await initialization(db=db) create_ok = await graphql( schema=gql_params.schema, source=CREATE_NUMBER_POOL, @@ -695,12 +695,16 @@ async def test_number_pool_utilization(db: InfrahubDatabase, default_branch: Bra CREATE_TICKET = """ mutation CreateTestingTicket( - $pool: String, + $pool: String!, $title: String! ) { TestingTicketCreate( data: { - ticket_id: {from_pool: $pool}, + ticket_id: { + from_pool: { + id: $pool + } + }, title: {value: $title} }) { object { diff --git a/backend/tests/unit/graphql/test_diff_tree_query.py b/backend/tests/unit/graphql/test_diff_tree_query.py index 816276b917..b8159b38f9 100644 --- a/backend/tests/unit/graphql/test_diff_tree_query.py +++ b/backend/tests/unit/graphql/test_diff_tree_query.py @@ -14,6 +14,8 @@ from infrahub.core.manager import NodeManager from infrahub.core.node import Node from infrahub.core.schema import NodeSchema +from infrahub.core.schema_manager import SchemaBranch +from infrahub.core.timestamp import Timestamp from infrahub.database import InfrahubDatabase from infrahub.dependencies.registry import get_component_registry from infrahub.graphql import prepare_graphql_params @@ -23,6 +25,11 @@ UPDATED_ACTION = "UPDATED" REMOVED_ACTION = "REMOVED" UNCHANGED_ACTION = "UNCHANGED" +CARDINALITY_ONE = "ONE" +CARDINALITY_MANY = "MANY" +IS_RELATED_TYPE = "IS_RELATED" +IS_PROTECTED_TYPE = "IS_PROTECTED" +IS_VISIBLE_TYPE = "IS_VISIBLE" DIFF_TREE_QUERY = """ query GetDiffTree($branch: String){ @@ -35,6 +42,8 @@ num_removed num_updated num_conflicts + num_untracked_base_changes + num_untracked_diff_changes nodes { uuid kind @@ -60,20 +69,36 @@ num_updated num_conflicts contains_conflict + conflict { + uuid + base_branch_action + base_branch_value + base_branch_changed_at + base_branch_label + diff_branch_action + diff_branch_value + diff_branch_changed_at + diff_branch_label + selected_branch + } properties { property_type last_changed_at previous_value new_value + previous_label + new_label status conflict { uuid base_branch_action base_branch_value base_branch_changed_at + base_branch_label diff_branch_action diff_branch_value diff_branch_changed_at + diff_branch_label selected_branch } } @@ -82,6 +107,7 @@ name last_changed_at status + cardinality contains_conflict elements { status @@ -93,9 +119,11 @@ base_branch_action base_branch_changed_at base_branch_value + base_branch_label diff_branch_action diff_branch_value diff_branch_changed_at + diff_branch_label selected_branch } properties { @@ -103,15 +131,19 @@ last_changed_at previous_value new_value + previous_label + new_label status conflict { uuid base_branch_action base_branch_value base_branch_changed_at + base_branch_label diff_branch_action diff_branch_value diff_branch_changed_at + diff_branch_label selected_branch } } @@ -147,6 +179,8 @@ num_updated num_conflicts num_unchanged + num_untracked_base_changes + num_untracked_diff_changes } } """ @@ -173,7 +207,43 @@ async def diff_coordinator(db: InfrahubDatabase, diff_branch: Branch) -> DiffCoo return coordinator -async def test_diff_tree_empty_diff( +async def test_diff_tree_no_changes( + db: InfrahubDatabase, + default_branch: Branch, + criticality_low, + diff_coordinator: DiffCoordinator, + diff_branch: Branch, +): + enriched_diff = await diff_coordinator.update_branch_diff(base_branch=default_branch, diff_branch=diff_branch) + from_time = datetime.fromisoformat(diff_branch.created_at) + to_time = datetime.fromisoformat(enriched_diff.to_time.to_string()) + + params = prepare_graphql_params(db=db, include_mutation=False, include_subscription=False, branch=default_branch) + result = await graphql( + schema=params.schema, + source=DIFF_TREE_QUERY, + context_value=params.context, + root_value=None, + variable_values={"branch": diff_branch.name}, + ) + + assert result.errors is None + assert result.data["DiffTree"] == { + "base_branch": default_branch.name, + "diff_branch": diff_branch.name, + "from_time": from_time.isoformat(), + "to_time": to_time.isoformat(), + "num_added": 0, + "num_removed": 0, + "num_updated": 0, + "num_conflicts": 0, + "num_untracked_base_changes": 0, + "num_untracked_diff_changes": 0, + "nodes": [], + } + + +async def test_diff_tree_no_diffs( db: InfrahubDatabase, default_branch: Branch, criticality_schema: NodeSchema, diff_branch: Branch ): params = prepare_graphql_params(db=db, include_mutation=False, include_subscription=False, branch=default_branch) @@ -226,6 +296,14 @@ async def test_diff_tree_one_attr_change( await diff_repository.update_conflict_by_id( conflict_id=enriched_conflict.uuid, selection=ConflictSelection.DIFF_BRANCH ) + # add some untracked changes + main_crit = await NodeManager.get_one(db=db, id=criticality_low.id, branch=default_branch) + main_crit.color.value = "blurple" + branch_crit = await NodeManager.get_one(db=db, id=criticality_low.id, branch=diff_branch) + branch_crit.color.value = "walrus" + await main_crit.save(db=db) + await branch_crit.save(db=db) + params = prepare_graphql_params(db=db, include_mutation=False, include_subscription=False, branch=default_branch) result = await graphql( schema=params.schema, @@ -261,6 +339,8 @@ async def test_diff_tree_one_attr_change( "num_removed": 0, "num_updated": 1, "num_conflicts": 1, + "num_untracked_base_changes": 1, + "num_untracked_diff_changes": 1, "nodes": [ { "uuid": criticality_low.id, @@ -285,27 +365,28 @@ async def test_diff_tree_one_attr_change( "num_conflicts": 1, "status": UPDATED_ACTION, "contains_conflict": True, + "conflict": { + "uuid": enriched_conflict.uuid, + "base_branch_action": UPDATED_ACTION, + "base_branch_value": "#fedcba", + "base_branch_changed_at": enriched_conflict.base_branch_changed_at.to_string(with_z=False), + "base_branch_label": None, + "diff_branch_action": UPDATED_ACTION, + "diff_branch_value": "#abcdef", + "diff_branch_changed_at": enriched_conflict.diff_branch_changed_at.to_string(with_z=False), + "diff_branch_label": None, + "selected_branch": GraphQLConfictSelection.DIFF_BRANCH.name, + }, "properties": [ { "property_type": "HAS_VALUE", "last_changed_at": property_changed_at, "previous_value": criticality_low.color.value, - "new_value": branch_crit.color.value, + "new_value": "#abcdef", + "previous_label": None, + "new_label": None, "status": UPDATED_ACTION, - "conflict": { - "uuid": enriched_conflict.uuid, - "base_branch_action": UPDATED_ACTION, - "base_branch_value": "#fedcba", - "base_branch_changed_at": enriched_conflict.base_branch_changed_at.to_string( - with_z=False - ), - "diff_branch_action": UPDATED_ACTION, - "diff_branch_value": "#abcdef", - "diff_branch_changed_at": enriched_conflict.diff_branch_changed_at.to_string( - with_z=False - ), - "selected_branch": GraphQLConfictSelection.DIFF_BRANCH.name, - }, + "conflict": None, } ], } @@ -315,6 +396,247 @@ async def test_diff_tree_one_attr_change( } +async def test_diff_tree_one_relationship_change( + db: InfrahubDatabase, + default_branch: Branch, + car_person_schema: SchemaBranch, + car_accord_main: Node, + person_john_main: Node, + person_jane_main: Node, + diff_branch: Branch, + diff_coordinator: DiffCoordinator, + diff_repository: DiffRepository, +): + branch_car = await NodeManager.get_one(db=db, id=car_accord_main.id, branch=diff_branch) + await branch_car.owner.update(db=db, data=[person_jane_main]) + before_change_datetime = datetime.now(tz=UTC) + await branch_car.save(db=db) + after_change_datetime = datetime.now(tz=UTC) + accord_label = await branch_car.render_display_label(db=db) + john_label = await person_john_main.render_display_label(db=db) + jane_label = await person_jane_main.render_display_label(db=db) + + enriched_diff = await diff_coordinator.update_branch_diff(base_branch=default_branch, diff_branch=diff_branch) + params = prepare_graphql_params(db=db, include_mutation=False, include_subscription=False, branch=default_branch) + result = await graphql( + schema=params.schema, + source=DIFF_TREE_QUERY, + context_value=params.context, + root_value=None, + variable_values={"branch": diff_branch.name}, + ) + from_time = datetime.fromisoformat(diff_branch.created_at) + to_time = datetime.fromisoformat(enriched_diff.to_time.to_string()) + + assert result.errors is None + + assert result.data["DiffTree"] + diff_tree_response = result.data["DiffTree"].copy() + nodes_response = diff_tree_response.pop("nodes") + assert diff_tree_response == { + "base_branch": "main", + "diff_branch": diff_branch.name, + "from_time": from_time.isoformat(), + "to_time": to_time.isoformat(), + "num_added": 0, + "num_removed": 0, + "num_updated": 3, + "num_conflicts": 0, + "num_untracked_base_changes": 0, + "num_untracked_diff_changes": 0, + } + assert len(nodes_response) == 3 + node_response_by_id = {n["uuid"]: n for n in nodes_response} + assert set(node_response_by_id.keys()) == {car_accord_main.id, person_john_main.id, person_jane_main.id} + # car node + car_response = node_response_by_id[car_accord_main.id] + car_relationship_response = car_response.pop("relationships") + car_changed_at = car_response["last_changed_at"] + assert datetime.fromisoformat(car_changed_at) < before_change_datetime + assert car_response == { + "uuid": car_accord_main.id, + "kind": car_accord_main.get_kind(), + "label": await car_accord_main.render_display_label(db=db), + "last_changed_at": car_changed_at, + "num_added": 0, + "num_removed": 0, + "num_updated": 1, + "num_conflicts": 0, + "parent": {"kind": person_jane_main.get_kind(), "relationship_name": "cars", "uuid": person_jane_main.get_id()}, + "status": UPDATED_ACTION, + "contains_conflict": False, + "attributes": [], + } + car_relationships_by_name = {r["name"]: r for r in car_relationship_response} + assert set(car_relationships_by_name.keys()) == {"owner"} + owner_rel = car_relationships_by_name["owner"] + owner_changed_at = owner_rel["last_changed_at"] + assert before_change_datetime < datetime.fromisoformat(owner_changed_at) < after_change_datetime + owner_elements = owner_rel.pop("elements") + assert owner_rel == { + "name": "owner", + "last_changed_at": owner_changed_at, + "status": UPDATED_ACTION, + "cardinality": "ONE", + "contains_conflict": False, + } + assert len(owner_elements) == 1 + owner_element = owner_elements[0] + owner_element_changed_at = owner_element["last_changed_at"] + assert before_change_datetime < datetime.fromisoformat(owner_element_changed_at) < after_change_datetime + owner_properties = owner_element.pop("properties") + assert owner_element == { + "status": UPDATED_ACTION, + "peer_id": person_jane_main.id, + "last_changed_at": owner_element_changed_at, + "contains_conflict": False, + "conflict": None, + } + owner_properties_by_type = {p["property_type"]: p for p in owner_properties} + assert set(owner_properties_by_type.keys()) == {IS_RELATED_TYPE} + owner_prop = owner_properties_by_type[IS_RELATED_TYPE] + owner_prop_changed_at = owner_prop["last_changed_at"] + assert before_change_datetime < datetime.fromisoformat(owner_prop_changed_at) < after_change_datetime + assert owner_prop == { + "property_type": IS_RELATED_TYPE, + "last_changed_at": owner_prop_changed_at, + "previous_value": person_john_main.id, + "new_value": person_jane_main.id, + "previous_label": john_label, + "new_label": jane_label, + "status": UPDATED_ACTION, + "conflict": None, + } + # john node + john_response = node_response_by_id[person_john_main.id] + john_relationship_response = john_response.pop("relationships") + john_changed_at = john_response["last_changed_at"] + assert datetime.fromisoformat(john_changed_at) < before_change_datetime + assert john_response == { + "uuid": person_john_main.id, + "kind": person_john_main.get_kind(), + "label": await person_john_main.render_display_label(db=db), + "last_changed_at": john_changed_at, + "num_added": 0, + "num_removed": 0, + "num_updated": 1, + "num_conflicts": 0, + "parent": None, + "status": UPDATED_ACTION, + "contains_conflict": False, + "attributes": [], + } + john_relationships_by_name = {r["name"]: r for r in john_relationship_response} + assert set(john_relationships_by_name.keys()) == {"cars"} + cars_rel = john_relationships_by_name["cars"] + cars_changed_at = cars_rel["last_changed_at"] + assert before_change_datetime < datetime.fromisoformat(cars_changed_at) < after_change_datetime + cars_elements = cars_rel.pop("elements") + assert cars_rel == { + "name": "cars", + "last_changed_at": cars_changed_at, + "status": UPDATED_ACTION, + "cardinality": "MANY", + "contains_conflict": False, + } + assert len(cars_elements) == 1 + cars_element = cars_elements[0] + cars_element_changed_at = cars_element["last_changed_at"] + assert before_change_datetime < datetime.fromisoformat(cars_element_changed_at) < after_change_datetime + cars_properties = cars_element.pop("properties") + assert cars_element == { + "status": REMOVED_ACTION, + "peer_id": car_accord_main.id, + "last_changed_at": cars_element_changed_at, + "contains_conflict": False, + "conflict": None, + } + cars_properties_by_type = {p["property_type"]: p for p in cars_properties} + assert set(cars_properties_by_type.keys()) == {IS_RELATED_TYPE, IS_VISIBLE_TYPE, IS_PROTECTED_TYPE} + for property_type, previous_value, previous_label in [ + (IS_RELATED_TYPE, car_accord_main.id, accord_label), + (IS_PROTECTED_TYPE, "False", None), + (IS_VISIBLE_TYPE, "True", None), + ]: + cars_prop = cars_properties_by_type[property_type] + cars_prop_changed_at = cars_prop["last_changed_at"] + assert before_change_datetime < datetime.fromisoformat(cars_prop_changed_at) < after_change_datetime + assert cars_prop == { + "property_type": property_type, + "last_changed_at": cars_prop_changed_at, + "previous_value": previous_value, + "previous_label": previous_label, + "new_value": None, + "new_label": None, + "status": REMOVED_ACTION, + "conflict": None, + } + # jane node + jane_response = node_response_by_id[person_jane_main.id] + jane_relationship_response = jane_response.pop("relationships") + jane_changed_at = jane_response["last_changed_at"] + assert datetime.fromisoformat(jane_changed_at) < before_change_datetime + assert jane_response == { + "uuid": person_jane_main.id, + "kind": person_jane_main.get_kind(), + "label": await person_jane_main.render_display_label(db=db), + "last_changed_at": jane_changed_at, + "num_added": 0, + "num_removed": 0, + "num_updated": 1, + "num_conflicts": 0, + "parent": None, + "status": UPDATED_ACTION, + "contains_conflict": False, + "attributes": [], + } + jane_relationships_by_name = {r["name"]: r for r in jane_relationship_response} + assert set(jane_relationships_by_name.keys()) == {"cars"} + cars_rel = jane_relationships_by_name["cars"] + cars_changed_at = cars_rel["last_changed_at"] + assert before_change_datetime < datetime.fromisoformat(cars_changed_at) < after_change_datetime + cars_elements = cars_rel.pop("elements") + assert cars_rel == { + "name": "cars", + "last_changed_at": cars_changed_at, + "status": UPDATED_ACTION, + "cardinality": "MANY", + "contains_conflict": False, + } + assert len(cars_elements) == 1 + cars_element = cars_elements[0] + cars_element_changed_at = cars_element["last_changed_at"] + assert before_change_datetime < datetime.fromisoformat(cars_element_changed_at) < after_change_datetime + cars_properties = cars_element.pop("properties") + assert cars_element == { + "status": ADDED_ACTION, + "peer_id": car_accord_main.id, + "last_changed_at": cars_element_changed_at, + "contains_conflict": False, + "conflict": None, + } + cars_properties_by_type = {p["property_type"]: p for p in cars_properties} + assert set(cars_properties_by_type.keys()) == {IS_RELATED_TYPE, IS_VISIBLE_TYPE, IS_PROTECTED_TYPE} + for property_type, new_value, new_label in [ + (IS_RELATED_TYPE, car_accord_main.id, accord_label), + (IS_PROTECTED_TYPE, "False", None), + (IS_VISIBLE_TYPE, "True", None), + ]: + cars_prop = cars_properties_by_type[property_type] + cars_prop_changed_at = cars_prop["last_changed_at"] + assert before_change_datetime < datetime.fromisoformat(cars_prop_changed_at) < after_change_datetime + assert cars_prop == { + "property_type": property_type, + "last_changed_at": cars_prop_changed_at, + "previous_value": None, + "previous_label": None, + "new_value": new_value, + "new_label": new_label, + "status": ADDED_ACTION, + "conflict": None, + } + + async def test_diff_tree_hierarchy_change( db: InfrahubDatabase, default_branch: Branch, @@ -359,12 +681,70 @@ async def test_diff_tree_hierarchy_change( assert nodes_parent == expected_nodes_parent +async def test_diff_tree_summary_no_diffs( + db: InfrahubDatabase, default_branch: Branch, criticality_schema: NodeSchema, diff_branch: Branch +): + params = prepare_graphql_params(db=db, include_mutation=False, include_subscription=False, branch=default_branch) + result = await graphql( + schema=params.schema, + source=DIFF_TREE_QUERY_SUMMARY, + context_value=params.context, + root_value=None, + variable_values={"branch": diff_branch.name}, + ) + + assert result.errors is None + assert result.data["DiffTreeSummary"] is None + + +async def test_diff_tree_summary_no_changes( + db: InfrahubDatabase, + default_branch: Branch, + criticality_low, + diff_coordinator: DiffCoordinator, + diff_branch: Branch, +): + enriched_diff = await diff_coordinator.update_branch_diff(base_branch=default_branch, diff_branch=diff_branch) + from_time = datetime.fromisoformat(diff_branch.created_at) + to_time = datetime.fromisoformat(enriched_diff.to_time.to_string()) + + params = prepare_graphql_params(db=db, include_mutation=False, include_subscription=False, branch=default_branch) + result = await graphql( + schema=params.schema, + source=DIFF_TREE_QUERY_SUMMARY, + context_value=params.context, + root_value=None, + variable_values={"branch": diff_branch.name}, + ) + + assert result.errors is None + assert result.data["DiffTreeSummary"] == { + "base_branch": default_branch.name, + "diff_branch": diff_branch.name, + "from_time": from_time.isoformat(), + "to_time": to_time.isoformat(), + "num_added": 0, + "num_removed": 0, + "num_updated": 0, + "num_unchanged": 0, + "num_conflicts": 0, + "num_untracked_base_changes": 0, + "num_untracked_diff_changes": 0, + } + + @pytest.mark.parametrize( "filters,counters", [ - pytest.param({}, DiffSummaryCounters(num_added=2, num_updated=4), id="no-filters"), pytest.param( - {"kind": {"includes": ["TestThing"]}}, DiffSummaryCounters(num_added=2, num_updated=1), id="kind-includes" + {}, + DiffSummaryCounters(num_added=2, num_updated=5, num_removed=2, from_time=Timestamp(), to_time=Timestamp()), + id="no-filters", + ), + pytest.param( + {"kind": {"includes": ["TestThing"]}}, + DiffSummaryCounters(num_added=2, num_updated=1, num_removed=2, from_time=Timestamp(), to_time=Timestamp()), + id="kind-includes", ), ], ) @@ -400,16 +780,15 @@ async def test_diff_summary_filters( thing1_branch.name.value = "THING1" await thing1_branch.save(db=db) - # FIXME, there is an issue related to label for deleted nodes right now that makes it complicated to use REMOVED nodes in this test - # thing2_branch = await NodeManager.get_one(db=db, id=thing2_main.id, branch=diff_branch) - # await thing2_branch.delete(db=db) + thing2_branch = await NodeManager.get_one(db=db, id=thing2_main.id, branch=diff_branch) + await thing2_branch.delete(db=db) # ---------------------------- # Generate Diff in DB # ---------------------------- component_registry = get_component_registry() diff_coordinator = await component_registry.get_component(DiffCoordinator, db=db, branch=diff_branch) - await diff_coordinator.update_branch_diff(base_branch=default_branch, diff_branch=diff_branch) + enriched_diff = await diff_coordinator.update_branch_diff(base_branch=default_branch, diff_branch=diff_branch) params = prepare_graphql_params(db=db, include_mutation=False, include_subscription=False, branch=default_branch) result = await graphql( @@ -421,27 +800,37 @@ async def test_diff_summary_filters( ) assert result.errors is None + counters.from_time = enriched_diff.from_time + counters.to_time = enriched_diff.to_time diff: dict = result.data["DiffTreeSummary"] + from_timestamp = Timestamp(result.data["DiffTreeSummary"]["from_time"]) + to_timestamp = Timestamp(result.data["DiffTreeSummary"]["to_time"]) summary = DiffSummaryCounters( num_added=diff["num_added"], num_updated=diff["num_updated"], num_unchanged=diff["num_unchanged"], num_removed=diff["num_removed"], num_conflicts=diff["num_conflicts"], + from_time=from_timestamp, + to_time=to_timestamp, ) assert summary == counters + assert result.data["DiffTreeSummary"]["num_untracked_base_changes"] == 0 + assert result.data["DiffTreeSummary"]["num_untracked_diff_changes"] == 0 @pytest.mark.parametrize( "filters,labels", [ - pytest.param({}, ["THING1", "europe", "paris", "paris rack2", "paris-r1", "thing3"], id="no-filters"), - pytest.param({"kind": {"includes": ["TestThing"]}}, ["THING1", "thing3"], id="kind-includes"), + pytest.param({}, ["THING1", "thing2", "europe", "paris", "paris rack2", "paris-r1", "thing3"], id="no-filters"), + pytest.param({"kind": {"includes": ["TestThing"]}}, ["THING1", "thing2", "thing3"], id="kind-includes"), pytest.param( {"kind": {"excludes": ["TestThing"]}}, ["europe", "paris", "paris rack2", "paris-r1"], id="kind-excludes" ), - pytest.param({"namespace": {"includes": ["Test"]}}, ["THING1", "thing3"], id="namespace-includes"), - pytest.param({"namespace": {"excludes": ["Location"]}}, ["THING1", "thing3"], id="namespace-excludes"), + pytest.param({"namespace": {"includes": ["Test"]}}, ["THING1", "thing2", "thing3"], id="namespace-includes"), + pytest.param( + {"namespace": {"excludes": ["Location"]}}, ["THING1", "thing2", "thing3"], id="namespace-excludes" + ), pytest.param( {"status": {"includes": ["UPDATED"]}}, ["THING1", "europe", "paris", "paris rack2", "paris-r1"], @@ -449,12 +838,12 @@ async def test_diff_summary_filters( ), pytest.param( {"status": {"excludes": ["UNCHANGED"]}}, - ["THING1", "europe", "paris", "paris rack2", "paris-r1", "thing3"], + ["THING1", "thing2", "europe", "paris", "paris rack2", "paris-r1", "thing3"], id="status-excludes", ), pytest.param( {"kind": {"includes": ["TestThing"]}, "status": {"excludes": ["ADDED"]}}, - ["THING1"], + ["THING1", "thing2"], id="kind-includes-status-excludes", ), ], @@ -491,9 +880,8 @@ async def test_diff_get_filters( thing1_branch.name.value = "THING1" await thing1_branch.save(db=db) - # FIXME, there is an issue related to label for deleted nodes right now that makes it complicated to use REMOVED nodes in this test - # thing2_branch = await NodeManager.get_one(db=db, id=thing2_main.id, branch=diff_branch) - # await thing2_branch.delete(db=db) + thing2_branch = await NodeManager.get_one(db=db, id=thing2_main.id, branch=diff_branch) + await thing2_branch.delete(db=db) component_registry = get_component_registry() diff_coordinator = await component_registry.get_component(DiffCoordinator, db=db, branch=diff_branch) diff --git a/backend/tests/unit/graphql/test_graphql_partial_match.py b/backend/tests/unit/graphql/test_graphql_partial_match.py index fd5d2f5410..e1a5931b19 100644 --- a/backend/tests/unit/graphql/test_graphql_partial_match.py +++ b/backend/tests/unit/graphql/test_graphql_partial_match.py @@ -179,3 +179,44 @@ async def test_query_filter_relationships_with_generic_filter_mutliple_partial_m assert result.data assert len(result.data["TestCar"]["edges"]) == 1 assert result.data["TestCar"]["edges"][0]["node"]["id"] == smolt_car.id + + +async def test_query_filter_local_attrs_partial_match_values( + db: InfrahubDatabase, default_branch: Branch, criticality_schema +) -> None: + """Ensure that query partial_match filtering works when using arrays/lists as part of the query input.""" + obj1 = await Node.init(db=db, schema=criticality_schema) + await obj1.new(db=db, name="red", level=1) + await obj1.save(db=db) + obj2 = await Node.init(db=db, schema=criticality_schema) + await obj2.new(db=db, name="green", level=2) + await obj2.save(db=db) + obj3 = await Node.init(db=db, schema=criticality_schema) + await obj3.new(db=db, name="blue", level=3) + await obj3.save(db=db) + obj4 = await Node.init(db=db, schema=criticality_schema) + await obj4.new(db=db, name="grey", level=4) + await obj4.save(db=db) + + query = """ + query { + TestCriticality(name__values: ["red", "green", "grey"], any__value: "gr", partial_match: true) { + edges { + node { + name { + value + } + } + } + } + } + """ + + result = await graphql_query(query=query, db=db, branch=default_branch) + + assert result.errors is None + assert result.data + assert len(result.data["TestCriticality"]["edges"]) == 2 + assert ["green", "grey"] == sorted( + [node["node"]["name"]["value"] for node in result.data["TestCriticality"]["edges"]] + ) diff --git a/backend/tests/unit/graphql/test_manager.py b/backend/tests/unit/graphql/test_manager.py index 5d6a39fe80..0657841991 100644 --- a/backend/tests/unit/graphql/test_manager.py +++ b/backend/tests/unit/graphql/test_manager.py @@ -214,6 +214,7 @@ async def test_generate_filters(db: InfrahubDatabase, default_branch: Branch, da "height__source__id", "height__value", "height__values", + "hfid", "member_of_groups__description__value", "member_of_groups__description__values", "member_of_groups__group_type__value", diff --git a/changelog/3435.fixed.md b/changelog/3435.fixed.md new file mode 100644 index 0000000000..d91c9c7382 --- /dev/null +++ b/changelog/3435.fixed.md @@ -0,0 +1 @@ +Add ability to import repositories with default branch other than 'main'. diff --git a/changelog/3908.added.md b/changelog/3908.added.md new file mode 100644 index 0000000000..8612bf2e8d --- /dev/null +++ b/changelog/3908.added.md @@ -0,0 +1 @@ +Add support to search a node by human friendly ID within a GraphQL query \ No newline at end of file diff --git a/changelog/4062.fixed.md b/changelog/4062.fixed.md new file mode 100644 index 0000000000..dfbbe0e503 --- /dev/null +++ b/changelog/4062.fixed.md @@ -0,0 +1 @@ +Allow users to run artifacts and generators on nodes without name attribute \ No newline at end of file diff --git a/docs/docs/development/docs.mdx b/docs/docs/development/docs.mdx index 63f237994c..065df60240 100644 --- a/docs/docs/development/docs.mdx +++ b/docs/docs/development/docs.mdx @@ -185,7 +185,7 @@ For a deeper dive into reference docs, refer to the [diátaxis reference page](h ## Creating and updating application screenshots -To ensure that Infrahub's screenshots remain up to date and to check that our guides work properly, we use [end-to-end (e2e) tests](/development/frontend/testing-guidelines#e2e-tests). You'll find the e2e tests specifically designed for tutorials located in `frontend/tests/e2e/tutorial`. +To ensure that Infrahub's screenshots remain up to date and to check that our guides work properly, we use [end-to-end (e2e) tests](/development/frontend/testing-guidelines#e2e-tests). You'll find the e2e tests specifically designed for tutorials located in `frontend/app/tests/e2e/tutorial`. To add a new screenshot in the documentation, use this command within the tutorial e2e test: diff --git a/docs/docs/guides/generator.mdx b/docs/docs/guides/generator.mdx index 03c38a4cd8..69f7f8c813 100644 --- a/docs/docs/guides/generator.mdx +++ b/docs/docs/guides/generator.mdx @@ -1,6 +1,8 @@ --- title: Creating a Generator --- +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; # Creating a generator in Infrahub @@ -87,7 +89,7 @@ Create a local directory on your computer where we will store the generator file ```bash mkdir example_generator -```` +``` Within that directory store the above GraphQL query as widget_query.gql. @@ -133,16 +135,28 @@ generator_definitions: class_name: WidgetGenerator parameters: name: "name__value" -``` -This defines a generator definition with the following properties: +queries: + - name: widget_query + file_path: "widget_query.gql" +``` -- **name**: a unique name for the generator -- **file_path**: the relative file path to the file containing the generator as seen from within a Git repository -- **targets**: the name of a group of which the members will be a target for this generator -- **query**: the name of the GraphQL query used within this generator -- **parameters**: the parameter to pass to the generator GraphQL query, in this case this we will pass the name of the object (widget) as the name parameter -- **query**: the name of the GraphQL query used within this generator + + + This defines a generator definition with the following properties: + - **name**: a unique name for the generator + - **file_path**: the relative file path to the file containing the generator as seen from within a Git repository + - **targets**: the name of a group of which the members will be a target for this generator + - **query**: the name of the GraphQL query used within this generator + - **parameters**: the parameter to pass to the generator GraphQL query, in this case this we will pass the name of the object (widget) as the name parameter + - **query**: the name of the GraphQL query used within this generator + + + Here the `name` refers to the query's name and `file_path` should point to the Gql file within the repository. + + + +See [this topic](/topics/infrahub-yml) for a full explanation of everything that can be defined in the `.infrahub.yml` file. ## 4. Create a Git repository diff --git a/docs/docs/guides/python-transform.mdx b/docs/docs/guides/python-transform.mdx index d28ac773ba..daa219bb32 100644 --- a/docs/docs/guides/python-transform.mdx +++ b/docs/docs/guides/python-transform.mdx @@ -1,6 +1,8 @@ --- title: Creating a Python transform --- +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; # Creating a Python transform @@ -217,7 +219,7 @@ If you are unsure of the format of the data you can set a debug marker when test ## 3. Create a .infrahub.yml file -In the `.infrahub.yml` file you define what transforms you have in your repository that you want to make available for Infrahub. +In the `.infrahub.yml` file you define what transforms and GraphQl queries you have in your repository that you want to make available for Infrahub. Create a `.infrahub.yml` file in the root of the directory. @@ -227,9 +229,22 @@ python_transforms: - name: tags_transform class_name: TagsTransform file_path: "tags_transform.py" + +queries: + - name: tags_query + file_path: "tags_query.gql" ``` -Two parts here are required, first the `name` of the transform which should be unique across Infrahub and also the `file_path` that should point to the Python file within the repository. In this example we have also defined `class_name`, the reason for this is that we gave our class the name `TagsTransform` instead of the default `Transform`. + + + Two parts here are required, first the `name` of the transform which should be unique across Infrahub and also the `file_path` that should point to the Python file within the repository. In this example we have also defined `class_name`, the reason for this is that we gave our class the name `TagsTransform` instead of the default `Transform`. + + + Here the `name` refers to the query's name and `file_path` should point to the Gql file within the repository. + + + +See [this topic](/topics/infrahub-yml) for a full explanation of everything that can be defined in the `.infrahub.yml` file. ## 4. Create a Git repository diff --git a/docs/docs/guides/repository.mdx b/docs/docs/guides/repository.mdx index 631a92417b..d6e88dfa4a 100644 --- a/docs/docs/guides/repository.mdx +++ b/docs/docs/guides/repository.mdx @@ -1,5 +1,5 @@ --- -title: Adding/updating external repositories +title: Adding external repositories --- import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; @@ -8,24 +8,15 @@ import TabItem from '@theme/TabItem'; Infrahub supports two different types of connections to external Git repositories: -- [**CoreRepository**](/topics/repository#core-repository) fully integrates with Git version control, including branch tracking and two-way branch synchronization. +- [**Repository**](/topics/repository#repository) fully integrates with Git version control, including branch tracking and two-way branch synchronization. - [**Read-only Repository**](/topics/repository#read-only-repository) links a particular branch in Infrahub to a particular ref in the Git repository. It will only read from the Git repository. It will never make any changes to the external repository. -## GitHub personal access token {#personal-access-token} +## Recommendations -
- Generate a GitHub fine-grained personal access token - - 1. Go to settings > Developer Settings > Personal access tokens [New GitHub token](https://github.com/settings/personal-access-tokens/new) - 2. Select Fine-grained tokens - 3. Limit the scope of the token in **Repository Access** > **Only Select Repositories** - 4. Grant the token permission: - - - a. If you want to create a CoreRepository using this token, then you will need to give it `Read/Write` access for the **Content** of the repository. - - b. If you want to create a Read-only Repository using this token, then you will only need to give it `Read` access for the **Content** of the repository. - - ![Fine-Grained Token](../media/github_fined_grain_access_token_setup.png) -
+- We recommend to use a dedicated repository for Infrahub resources. +- We recommend to configure the branch `main` as `default_branch`. +- Use `Read-only Repository` preferably for slow moving assets as pulling the changes is a manual operation. It goes well with putting a release tag as a ref and bump the ref from release to release. +- Use `Read-only Repository` for shared assets across multiple instances or repository external to your organization. ## Adding a repository {#add-repository} @@ -33,39 +24,40 @@ You will need to submit an access token with your request to create a repository - [Via the web interface](#via-the-web-interface) - [Via the GraphQL interface](#via-the-graphql-interface) +- [Via the Infrahub SDK](#via-the-infrahub-sdk) + +:::info + +If your repository is private you might want to have a personal token to access the repository. If you use GitHub as your Git Server you can find further information in this [Guide](#github-access-token) + +::: ### Via the web interface ![Add a Git Repository ](../media/create_repository.png) +{/*TODO: Generate this screen*/} 1. Log in to the Infrahub UI -2. Go to `Unified Storage` > `Repository` or `Read-only Repository` -3. Click on the `+` plus sign -4. Complete the required information: +2. Go to `Unified Storage` > `Repository` +3. Click on the `+ Add Genericrepository` button +4. Select the repository type +5. Complete the required information: - - **CoreRepository only:** First branch to import during initialization. All other branches on the repository will be imported in a background task after this one. + + The URL of the external repository, for example `https://github.com/opsmill/infrahub.git`. - - (Optional): This field will be populated with the hash of the commit that the Infrahub Repository is currently using once it has pulled the data from the external repository. - - **CoreRepository**: Ignored during creation and will be overwritten. - - **Read-only Repository**: Can be used to point at a specific commit within the given `ref`. For example, if you want to extract data from a specific commit on the `main` branch of the external repository other than the latest commit. + + (Optional): Credential object holding your username / password or personal token with access to the Git repository specified. - + The name you want to give the repository in Infrahub for identification purposes. (Optional): A description or comment about the repository used for informational purposes. - - The URL of the external repository, for example `https://github.com/opsmill/infrahub.git`. - - - Your username on the external Git provider. - - - The password or Fine-grained personal access token with access to the Git repository specified in `Location`. + + (Optional): Branch, tag or commit reference to pull. (Optional): Assign any tags to be associated with the repository. @@ -74,127 +66,224 @@ You will need to submit an access token with your request to create a repository :::success Validate that everything is correct -In the UI, you should see your new repository. Click on the row for the repository to see more detailed information. If the repository you added doesn't have the `Commit` property populated it means that the initial sync didn't work. Verify the location and credentials. +In the UI, you should see your new repository. If the repository you added has `Unknown` as Operational Status it means that Infrahub didn't managed to reach your repository. Verify the location and credentials. ::: ### Via the GraphQL interface -Using the GraphQL Interface, it is possible to add a CoreRepository or Read-only Repository via a [Mutation](/topics/graphql). - -:::info - -If you are using GitHub as your Git Server, you need to have a [fine-grained personal access token](#personal-access-token) to be able to access the repository. - -::: +Using the GraphQL Interface, it is possible to add a `Repository` or `Read-only Repository` via a [Mutation](/topics/graphql). +{/*TODO: Detail usage if using external GraphQL browser i.e. insomnia*/} 1. Open the [GraphQL Interface](http://localhost:8000/graphql). -2. Add your [authentication token](/topics/auth) with the `Headers` +2. If needed, i.e., your repository is private, create a Credential object to hold your username / password. + +```GraphQL + # Endpoint: http://127.0.0.1:8000/graphql/main + mutation { + CorePasswordCredentialCreate( + data: { + name: {value: "My Git Credential"}, + username: {value: "MY_USERNAME"}, + password: {value: "MY_TOKEN_OR_PASSWORD"} + } + ) { + ok + object { + id + } + } + } +``` + 3. Copy-paste the correct mutation from below and complete the information - - - ```GraphQL - # Endpoint: http://127.0.0.1:8000/graphql/main - mutation { - CoreRepositoryCreate( - data: { - name: { value: "YOUR_REPOSITORY_NAME" } - location: { value: "https://GIT_SERVER/YOUR_GIT_USERNAME/YOUR_REPOSITORY_NAME.git" } - username: { value: "YOUR_GIT_USERNAME" } - password: { value: "YOUR_PERSONAL_ACCESS_TOKEN" } - # default_branch: { value: "main" } <-- optional - } - ) { - ok - object { - id - } - } + + +```GraphQL + # Endpoint: http://127.0.0.1:8000/graphql/main + mutation { + CoreRepositoryCreate( + data: { + name: {value: "My Git Repository"}, + location: {value: "https://GIT_SERVER/YOUR_GIT_USERNAME/YOUR_REPOSITORY_NAME.git"}, + # credential: {id: "CREDENTIAL_ID_FROM_PREVIOUS_REQUEST"} <-- optional: This needs to be the credential id created at step 2 } - ``` + ) { + ok + object { + id + } + } + } +``` + - **Make sure that you are on the correct Infrahub branch.** Unlike a CoreRepository, a Read-only Repository will only pull files into the Infrahub branch on which it was created. - - ```GraphQL - # Endpoint : http://127.0.0.1:8000/graphql/ - mutation { - CoreReadOnlyRepositoryCreate( - data: { - name: { value: "YOUR_REPOSITORY_NAME" } - location: { value: "https://GIT_SERVER/YOUR_GIT_USERNAME/YOUR_REPOSITORY_NAME.git" } - username: { value: "YOUR_GIT_USERNAME" } - password: { value: "YOUR_PERSONAL_ACCESS_TOKEN" } - ref: { value: "BRANCH/TAG/COMMIT TO TRACK" } - } - ) { - ok - object { - id - } - } + **Make sure that you are on the correct Infrahub branch.** Unlike a Repository, a Read-only Repository will only pull files into the Infrahub branch on which it was created. + +```GraphQL + # Endpoint : http://127.0.0.1:8000/graphql/ + mutation { + CoreReadOnlyRepositoryCreate( + data: { + name: {value: "My Git Repository"}, + location: {value: "https://GIT_SERVER/YOUR_GIT_USERNAME/YOUR_REPOSITORY_NAME.git"}, + ref: { value: "BRANCH/TAG/COMMIT_TO_TRACK" }, + # credential: {id: "CREDENTIAL_ID_FROM_PREVIOUS_REQUEST"} <-- optional: This needs to be the credential id created at step 2 + } + ) { + ok + object { + id } - ``` + } + } +``` + :::success Validate that everything is correct -In the UI, new objects that have been imported from the Git Repository should now be available: - -The repository should be visible under [Unified Storage / Repository](http://localhost:8000/objects/CoreRepository/) or [Unified Storage / Read-only Repository](http://localhost:8000/objects/CoreReadOnlyRepository/) depending on which type of repository you created. If the repository you added doesn't have the commit property populated, then it means that the initial sync didn't work. Verify the location and credentials. +The repository should be visible under [Unified Storage / Repository](http://localhost:8000/objects/CoreGenericRepository). If the repository you added has `Unknown` as Operational Status it means that Infrahub didn't managed to reach your repository. Verify the location and credentials. ::: -## Updates from the external repository +### Via the Infrahub SDK -Read-only repositories and CoreRepositories work in different ways when it comes to tracking changes on the remote repository. +1. Install and setup the [Infrahub SDK](/python-sdk) +2. If needed, i.e., your repository is private, create a Credential object to hold your username / password. -### CoreRepository +```python + # Create credential object ... + credential = client.create( + "CorePasswordCredential", + name="My Git Credential", + username="MY_USERNAME", + password="MY_TOKEN_OR_PASSWORD", + ) -The [Infrahub Git agent](/reference/git-agent) checks for changes in external repositories several times per minute. If there are no conflicts between the remote and the Infrahub branches, the Git agent will automatically pull any new changes into the appropriate Infrahub branch. + # ... and save it! + credential.save() +``` - -### Read-only Repository - +3. Create the correct repository object -Infrahub does not automatically update Read-only Repositories with changes on the external repository. To pull in changes from the external repository you must either set the `ref` **and/or** `commit` of the Read-only Repository to the desired value. You can perform this update either through the user interface or via an update mutation through the GraphQL API. Either way, the Infrahub web server will use the Git agent to retrieve the appropriate changes in a background task. + + -
- Example update mutation - - ```GraphQL - # Endpoint : http://127.0.0.1:8000/graphql/main - mutation { - CoreReadOnlyRepositoryUpdate( - data: { - id: "ID_OF_THE_REPOSITORY" - ref: { value: "BRANCH/TAG/COMMIT TO TRACK" } - commit: { value: "NEW COMMIT ON THE REF TO PULL" } - } - ) { - ok - object { - id - } - } +```python + # Create repository object ... + repository = client.create( + "CoreRepository", + name="My Git repository", + location="https://GIT_SERVER/YOUR_GIT_USERNAME/YOUR_REPOSITORY_NAME.git", + # credential=credential, <-- optional: This needs to be the credential object created at step 2 + ) + + # and save it ... + repository.save() +``` + + + + +```python + # Create repository object ... + repository = client.create( + "CoreRepository", + name="My Git repository", + location="https://GIT_SERVER/YOUR_GIT_USERNAME/YOUR_REPOSITORY_NAME.git", + ref="BRANCH/TAG/COMMIT_TO_TRACK", + # credential=credential, <-- optional: This needs to be the credential object created at step 2 + ) + + # and save it ... + repository.save() +``` + + + + +{/*TODO: Via the Infrahub CTL*/} + +## Pulling changes for read-only repository + +`Read-only Repository` and `Repository` work in different ways when it comes to tracking changes on the remote repository. Please refer to the [Repository Topic](/topics/repository#read-only-vs-core) for futher details. + +:::warning + +Unlike `Repository`, Infrahub does not automatically update `Read-only Repository` with changes from the external repository. To pull in changes from the external repository you must update the `ref` of the `Read-only Repository` to the desired value. + +::: + +### Via the web interface + +1. Log in to the Infrahub UI +2. Go to `Unified Storage` > `Repository` +3. Click on the `CoreReadOnlyRepository` record +4. Click on the `Edit Read-Only Repository` button +5. Change the required information: + + + + (Optional): Branch, tag or commit reference to pull. + + + +### Via the GraphQL interface + +1. Open the [GraphQL Interface](http://localhost:8000/graphql). +2. Copy-paste the correct mutation from below and complete the information + +```GraphQL + # Endpoint : http://127.0.0.1:8000/graphql/main + mutation { + CoreReadOnlyRepositoryUpdate( + data: { + id: "ID_OF_THE_REPOSITORY" + ref: { value: "BRANCH/TAG/COMMIT_TO_TRACK" } } - ``` -
+ ) { + ok + object { + id + } + } + } +``` + +## Troubleshooting repository -## Updates to the external repository +Various issues could affect repositories. The [Repository Status](/topics/repository#repository-statuses) gives you futher details about the root cause. -### CoreRepository +Also, directly from the UI you can access low level operations's output: -When a [Proposed Change](/topics/proposed-change) is merged, if the source and destination Infrahub branches are both linked to branches on the same external Git repository, then Infrahub will handle merging the branches on the external Git repository. This is the only time that Infrahub will push changes to the external repository. Other changes made within Infrahub will not be pushed to an external repository and **could potentially be overwritten** when Infrahub pulls new commits from the external repository. +1. Log in to the Infrahub UI +2. Go to `Unified Storage` > `Repository` +3. Click on the `CoreReadOnlyRepository` record +4. Click on the `Tasks` tab + +{/*TODO: Add further use cases e.g. sync-issue*/} - -### Read-only Repository - +## GitHub access token {#github-access-token} -No changes to objects owned by a Read-only Repository are ever pushed to the external Git repository. +If you are using GitHub as your Git Server and your repository is private, you need to have an access token to be able to access the repository. You can use either a classic [Personal Access Token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic) or a [Fine-grained Access Token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token). + +
+ Generate a GitHub fine-grained personal access token + + 1. Go to settings > Developer Settings > Personal access tokens [New GitHub token](https://github.com/settings/personal-access-tokens/new) + 2. Select Fine-grained tokens + 3. Limit the scope of the token in **Repository Access** > **Only Select Repositories** + 4. Grant the token permission: + - a. If you want to create a `Repository` using this token, then you will need to give it `Read/Write` access for the **Content** of the repository. + - b. If you want to create a `Read-only Repositor`y using this token, then you will only need to give it `Read` access for the **Content** of the repository. + + ![Fine-Grained Token](../media/github_fined_grain_access_token_setup.png) +
## Installing custom CA certificates @@ -211,30 +300,30 @@ This guide makes the assumption that you have saved the CA certificate file as ` Next we will have to create a Dockerfile. The file should be saved as `Dockerfile` in the same directory as the directory containing the CA certificate you want to install. - ```dockerfile - ARG INFRAHUB_VERSION=0.15.3 - FROM registry.opsmill.io/opsmill/infrahub:${INFRAHUB_VERSION} - - COPY mycacertificate.crt /usr/local/share/ca-certificates/ - RUN update-ca-certificates - ``` +```dockerfile +ARG INFRAHUB_VERSION=0.16.0 +FROM registry.opsmill.io/opsmill/infrahub:${INFRAHUB_VERSION} + +COPY mycacertificate.crt /usr/local/share/ca-certificates/ +RUN update-ca-certificates +``` We then have to build a Docker image from the Dockerfile. We can do this by executing this command - ```bash - INFRAHUB_VERSION=0.15.3 && docker build --build-arg INFRAHUB_VERSION=$INFRAHUB_VERSION -f Dockerfile -t custom/infrahub:${INFRAHUB_VERSION} - ``` +```bash +INFRAHUB_VERSION=0.16.0 && docker build --build-arg INFRAHUB_VERSION=$INFRAHUB_VERSION -f Dockerfile -t custom/infrahub:${INFRAHUB_VERSION} +``` -This will build a custom docker image and tag it as `custom/infrahub:0.15.3`. You can change the version by changing the version we set in the `INFRAHUB_VERSION` shell variable and you can change the tag to you own preference. +This will build a custom docker image and tag it as `custom/infrahub:0.16.0`. You can change the version by changing the version we set in the `INFRAHUB_VERSION` shell variable and you can change the tag to you own preference. As a last step we have to create a `docker-compose.override.yml` file with the following contents in the `development` directory of your clone of the Infrahub repository. - ```yaml - --- - services: - infrahub-git: - image: custom/infrahub:0.15.3 - ``` +```yaml +--- +services: + infrahub-git: + image: custom/infrahub:0.16.0 +``` A development environment can then be spun up with `invoke demo.start` command as, explained in the [Installing Infrahub guide](/guides/installation). @@ -256,29 +345,29 @@ In this process we will have to build a custom docker image. This docker image w We will have to create a Dockerfile, that should be saved as `Dockerfile` on your local filesystem. - ```dockerfile - ARG INFRAHUB_VERSION=0.15.3 - FROM registry.opsmill.io/opsmill/infrahub:${INFRAHUB_VERSION} - - RUN git config --global http.sslVerify "false" - ``` +```dockerfile +ARG INFRAHUB_VERSION=0.16.0 +FROM registry.opsmill.io/opsmill/infrahub:${INFRAHUB_VERSION} + +RUN git config --global http.sslVerify "false" +``` We then have to build a Docker image from the Dockerfile. We can do this by executing this command - ```bash - INFRAHUB_VERSION=0.15.3 && docker build --build-arg INFRAHUB_VERSION=$INFRAHUB_VERSION -f Dockerfile -t custom/infrahub:${INFRAHUB_VERSION} - ``` +```bash +INFRAHUB_VERSION=0.16.0 && docker build --build-arg INFRAHUB_VERSION=$INFRAHUB_VERSION -f Dockerfile -t custom/infrahub:${INFRAHUB_VERSION} +``` -This will build a custom docker image and tag it as `custom/infrahub:0.15.3`. You can change the version by changing the version we set in the `INFRAHUB_VERSION` shell variable and you can change the tag to you own preference. +This will build a custom docker image and tag it as `custom/infrahub:0.16.0`. You can change the version by changing the version we set in the `INFRAHUB_VERSION` shell variable and you can change the tag to you own preference. As a last step we have to create a `docker-compose.override.yml` file with the following contents in the `development` directory of your clone of the Infrahub repository. - ```yaml - --- - services: - infrahub-git: - image: custom/infrahub:0.15.3 - ``` +```yaml +--- +services: + infrahub-git: + image: custom/infrahub:0.16.0 +``` A development environment can then be spun up with `invoke demo.start` command as, explained in the [Installing Infrahub guide](/guides/installation). @@ -300,28 +389,28 @@ In this process we will have to build a custom docker image. This docker image w We will have to create a Dockerfile, that should be saved as `Dockerfile` on your local filesystem. In the Dockerfile you will have to adapt the URL for the proxy to your environment. Replace `user:password` with the username and password you have to use for the proxy, if required. Replace the FQDN `internal.proxy` to the FQDN of the proxy and modify the port, if required. - ```dockerfile - ARG INFRAHUB_VERSION=0.15.3 - FROM registry.opsmill.io/opsmill/infrahub:${INFRAHUB_VERSION} +```dockerfile +ARG INFRAHUB_VERSION=0.16.0 +FROM registry.opsmill.io/opsmill/infrahub:${INFRAHUB_VERSION} - RUN git config --global http.proxy http://user:password@internal.proxy:8080 - ``` +RUN git config --global http.proxy http://user:password@internal.proxy:8080 +``` We then have to build a Docker image from the Dockerfile. We can do this by executing this command - ```bash - INFRAHUB_VERSION=0.15.3 && docker build --build-arg INFRAHUB_VERSION=$INFRAHUB_VERSION -f Dockerfile -t custom/infrahub:${INFRAHUB_VERSION} - ``` +```bash +INFRAHUB_VERSION=0.16.0 && docker build --build-arg INFRAHUB_VERSION=$INFRAHUB_VERSION -f Dockerfile -t custom/infrahub:${INFRAHUB_VERSION} +``` -This will build a custom docker image and tag it as `custom/infrahub:0.15.3`. You can change the version by changing the version we set in the `INFRAHUB_VERSION` shell variable and you can change the tag to you own preference. +This will build a custom docker image and tag it as `custom/infrahub:0.16.0`. You can change the version by changing the version we set in the `INFRAHUB_VERSION` shell variable and you can change the tag to you own preference. As a last step we have to create a `docker-compose.override.yml` file with the following contents in the `development` directory of your clone of the Infrahub repository. - ```yaml - --- - services: - infrahub-git: - image: custom/infrahub:0.15.3 - ``` +```yaml +--- +services: + infrahub-git: + image: custom/infrahub:0.16.0 +``` A development environment can then be spun up with `invoke demo.start` command as, explained in the [Installing Infrahub guide](/guides/installation). diff --git a/docs/docs/media/create_repository.png b/docs/docs/media/create_repository.png index 2ab16cd400..c067b276af 100644 Binary files a/docs/docs/media/create_repository.png and b/docs/docs/media/create_repository.png differ diff --git a/docs/docs/reference/message-bus-events.mdx b/docs/docs/reference/message-bus-events.mdx index e93e35340e..72626343a8 100644 --- a/docs/docs/reference/message-bus-events.mdx +++ b/docs/docs/reference/message-bus-events.mdx @@ -398,7 +398,7 @@ For more detailed explanations on how to use these events within Infrahub, see t | **created_by** | The user ID of the user that created the repository | N/A | None | | **default_branch_name** | Default branch for this repository | N/A | None | | **infrahub_branch_name** | Infrahub branch on which to sync the remote repository | string | None | -| **admin_status** | Administrative status of the repository | string | None | +| **internal_status** | Administrative status of the repository | string | None | #### Event git.repository.connectivity @@ -429,9 +429,10 @@ For more detailed explanations on how to use these events within Infrahub, see t | **meta** | Meta properties for the message | N/A | None | | **repository_id** | The unique ID of the Repository | string | None | | **repository_name** | The name of the repository | string | None | -| **admin_status** | Administrative status of the repository | string | None | +| **internal_status** | Administrative status of the repository | string | None | | **source_branch** | The source branch | string | None | | **destination_branch** | The source branch | string | None | +| **default_branch** | The default branch in Git | string | None | #### Event git.repository.add_read_only @@ -451,7 +452,7 @@ For more detailed explanations on how to use these events within Infrahub, see t | **ref** | Ref to track on the external repository | string | None | | **created_by** | The user ID of the user that created the repository | N/A | None | | **infrahub_branch_name** | Infrahub branch on which to sync the remote repository | string | None | -| **admin_status** | Administrative status of the repository | string | None | +| **internal_status** | Internal status of the repository | string | None | #### Event git.repository.import_objects @@ -1611,7 +1612,7 @@ For more detailed explanations on how to use these events within Infrahub, see t | **created_by** | The user ID of the user that created the repository | N/A | None | | **default_branch_name** | Default branch for this repository | N/A | None | | **infrahub_branch_name** | Infrahub branch on which to sync the remote repository | string | None | -| **admin_status** | Administrative status of the repository | string | None | +| **internal_status** | Administrative status of the repository | string | None | #### Event git.repository.connectivity @@ -1644,9 +1645,10 @@ For more detailed explanations on how to use these events within Infrahub, see t | **meta** | Meta properties for the message | N/A | None | | **repository_id** | The unique ID of the Repository | string | None | | **repository_name** | The name of the repository | string | None | -| **admin_status** | Administrative status of the repository | string | None | +| **internal_status** | Administrative status of the repository | string | None | | **source_branch** | The source branch | string | None | | **destination_branch** | The source branch | string | None | +| **default_branch** | The default branch in Git | string | None | #### Event git.repository.add_read_only @@ -1667,7 +1669,7 @@ For more detailed explanations on how to use these events within Infrahub, see t | **ref** | Ref to track on the external repository | string | None | | **created_by** | The user ID of the user that created the repository | N/A | None | | **infrahub_branch_name** | Infrahub branch on which to sync the remote repository | string | None | -| **admin_status** | Administrative status of the repository | string | None | +| **internal_status** | Internal status of the repository | string | None | #### Event git.repository.import_objects diff --git a/docs/docs/topics/graphql.mdx b/docs/docs/topics/graphql.mdx index f09bdb8fe8..2a132920a5 100644 --- a/docs/docs/topics/graphql.mdx +++ b/docs/docs/topics/graphql.mdx @@ -210,7 +210,9 @@ Every time a `GraphQLQuery` is created or updated, the content of the query will ### Import from a Git repository -The Git agent will automatically try to import all files with the extension `.gql` into a `GraphQLQuery` with the name of the file as the name of the query. +GraphQL queries could be defined in file(s) with a `.gql` extension in a remote repository. Then queries must also be explicitly identified in the `.infrahub.yml` file under `queries`. + +More details on the `.infrahub.yml` file format can be found in [.infrahub.yml topic](../topics/infrahub-yml.mdx). ### Executing stored GraphQL queries diff --git a/docs/docs/topics/infrahub-yml.mdx b/docs/docs/topics/infrahub-yml.mdx index 26778c87b0..d0a4f2a832 100644 --- a/docs/docs/topics/infrahub-yml.mdx +++ b/docs/docs/topics/infrahub-yml.mdx @@ -30,7 +30,18 @@ To help with the development process of a repository configuration file, you can ## GraphQL query {#graphql-query} -This is the easiest type of object to link to Infrahub through an external repository because they do not need to be specified in the `.infrahub.yml` file. When Infrahub creates a Repository or pulls changes from the associated external repository, it will scan all the files in the repository and save any that have a `.gql` extension as GraphQLQuery objects in Infrahub. +GraphQL queries could be defined in file(s) with a `.gql` extension. Then queries must also be explicitly identified in the `.infrahub.yml` file under `queries`. + +
+ Example + + ```yaml + queries: + - name: topology_info # Here goes the query's name + file_path: "topology/topology_info.gql" # Here is the path to query's file + ``` + +
## Schema {#schema} diff --git a/docs/docs/topics/repository.mdx b/docs/docs/topics/repository.mdx index de8c5c7ef1..58a2dbae2d 100644 --- a/docs/docs/topics/repository.mdx +++ b/docs/docs/topics/repository.mdx @@ -1,6 +1,8 @@ --- title: Repository --- +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; # Repository @@ -8,14 +10,14 @@ title: Repository Infrahub supports two different types of connections to external Git repositories -- **CoreRepository** fully integrates with Git version control, including branch tracking and two-way branch synchronization. +- **Repository** fully integrates with Git version control, including branch tracking and two-way branch synchronization. - **Read-only Repository** links a particular branch in Infrahub to a particular ref in the Git repository. It will only read from the Git repository. It will never make any changes to the external repository. -See the [guide](/guides/repository) for instructions on creating and updating repositories in Infrahub. +See the [guide](/guides/repository) for instructions on creating repositories in Infrahub. ## `.infrahub.yml` file {#infrahub-yaml} -The `.infrahub.yml` configuration file specifies exactly what should be imported into Infrahub from the external repository. All GraphQL queries (`.gql` extension) within the repository will be imported automatically, but the `.infrahub.yml` file is required if you wish to import anything else, such as schemas, transformations, or artifact definitions. +The `.infrahub.yml` configuration file specifies exactly what should be imported into Infrahub from the external repository, it could be transformations, GraphQL query, artifact definitions, generators ... See [this topic](/topics/infrahub-yml) for a full explanation of everything that can be defined in the `.infrahub.yml` file. @@ -25,28 +27,93 @@ The [Infrahub web server](/reference/api-server) will never connect directly wit ![](../media/repository_architecture.excalidraw.svg) -Infrahub stores all of the data that it needs for every remote repository in a directory defined by the `git.repositories_directory` setting in `infrahub.toml`. When the Git agent receives an instruction to update a remote repository, it pulls data from the remote repositories and saves it to the filesystem in the `git.repositories_directory` directory. The Git agent then parses the new data and sends the necessary GraphQL mutations to the Infrahub web server. Infrahub attempts to update each CoreRepository with any changes in the remote repository several times per minute. Read-only repositories are only updated when specifically requested. +Infrahub stores all of the data that it needs for every remote repository in a directory defined by the `git.repositories_directory` setting in `infrahub.toml`. When the Git agent receives an instruction to update a remote repository, it pulls data from the remote repositories and saves it to the filesystem in the `git.repositories_directory` directory. The Git agent then parses the new data and sends the necessary GraphQL mutations to the Infrahub web server. Infrahub attempts to update `Repository` with any changes in the remote repository several times per minute. Read-only repositories are only updated when specifically requested. Please note that each Git agent must have access to the same directory on the file system so that they can share work among each other. -## Read-only Repository vs. CoreRepository {#read-only-vs-core} +## Read-only Repository vs. Repository {#read-only-vs-core} -Feature | CoreRepository | Read-only Repository +Feature | Repository | Read-only Repository ------------------------|-------------------------------|--------------------- Branches | Tracks all remote branches | Data from one remote commit imported to one Infrahub branch -Updates **from** remote | Automatic via background task | Manually, by updating `ref` or `commit` +Updates **from** remote | Automatic via background task | Manually, by updating `ref` Updates **to** remote | When merging Proposed Change | No ### Read-only Repository {#read-only-repository} -Read-only Repositories will only pull data from an external repository into Infrahub and will never push any data to the external repository. A Read-only Repository will pull changes from a single `ref` (branch, tag, or commit) into the Infrahub branch(es) on which it exists. Read-only repositories are not automatically updated. To update a Read-only Repository, you must manually update the `commit` and/or `ref` property to a new value, then the Git agent will pull the appropriate commit and create the appropriate objects in Infrahub. - -### CoreRepository {#core-repository} - -When you create a CoreRepository, Infrahub will try to pull every branch defined in the external repository and create an associated Infrahub branch with the same name and matching data according to what is defined in the `.infrahub.yml` configuration file on the particular remote branch. Infrahub will attempt to sync updates from the external repository several times per minute in a background task that runs on the Git agent(s). - -Editing a given GraphQL Query, Transform, Artifact Definition, or Schema within Infrahub **will not** result in those changes being pushed to the external repository. Infrahub will only push changes to an external repository when a [Proposed Change](/topics/proposed-change) is merged for which the source and destination branch are both linked to branches on the same external repository. In this case, Infrahub will attempt to create a merge commit and push that commit to the destination branch on the external repository. +Read-only Repositories will only pull data from an external repository into Infrahub and will never push any data to the external repository. A Read-only Repository will pull changes from a single `ref` (branch, tag, or commit) into the Infrahub branch(es) on which it exists. Read-only repositories are not automatically updated. To update a Read-only Repository, you must manually update the `ref` property to a new value, then the Git agent will pull the appropriate commit and create the appropriate objects in Infrahub. + +See the [guide](/guides/repository) for instructions on pulling changes from read-only repositories in Infrahub. + +### Repository {#repository} + +When you create a `Repository`, Infrahub will try to pull every branch defined in the external repository and create an associated Infrahub branch with the same name and matching data according to what is defined in the `.infrahub.yml` configuration file on the particular remote branch. Infrahub will attempt to sync updates from the external repository several times per minute in a background task that runs on the Git agent(s). + +Editing a given GraphQL Query, Transform, Artifact Definition, or Schema within Infrahub **will not** result in those changes being pushed to the external repository and **could potentially be overwritten** when Infrahub pulls new commits from the external repository. Infrahub will only push changes to an external repository when a [Proposed Change](/topics/proposed-change) is merged for which the source and destination branch are both linked to branches on the same external repository. In this case, Infrahub will attempt to create a merge commit and push that commit to the destination branch on the external repository. + +## Repository statuses + +Repository object has three status fields, all tracking various metrics. + +See this [guide](/guides/repository) for instructions on troubleshooting repositories. + +### Admin status + +Admin status keeps track of Infrahub usage of a repository. + + + + Infrahub is actively using this repository. + + + Infrahub isn't using this repository. + + + Infrahub is waiting the next proposed change to bring this repository up. + + + +### Operational status + +Operational status keeps track of the connectivity between Infrahub and the Repository. + + + + Can't compute the operational status. + + + Infrahub can't authenticate to the repository. + + + Infrahub can't reach the repository. + + + Issue happened when adding the repository. + + + Repository is up and running. + + + +### Sync status + +Sync status keeps track of the synchronisation operation's output. + + + + Can't compute the sync status. + + + All assets stored on the repository are synced in Infrahub. + + + Something wrong happened while importing repository to Infrahub. + + + Sync operation is ongoing. + + diff --git a/docs/docs/topics/schema.mdx b/docs/docs/topics/schema.mdx index 71238cdcc5..3419268773 100644 --- a/docs/docs/topics/schema.mdx +++ b/docs/docs/topics/schema.mdx @@ -666,11 +666,6 @@ the recommendation is to create a new branch and to integrate the changes into t ::: -### Isolated mode - -When a new schema is loaded into a branch, the branch will automatically be converted into isolated mode in order to apply the required data migrations for this branch. -A branch rebase will be required to bring the latest changes from main into the branch. - ### State: absent or present The format of the schema is declarative and incremental to allow schema to be composed from multiple sources. diff --git a/docs/docs/tutorials/getting-started/git-integration.mdx b/docs/docs/tutorials/getting-started/git-integration.mdx index 5665e419e4..7098239cd3 100644 --- a/docs/docs/tutorials/getting-started/git-integration.mdx +++ b/docs/docs/tutorials/getting-started/git-integration.mdx @@ -8,6 +8,8 @@ One of the three pillars Infrahub is built on is the idea of having unified stor When integrating a Git repository with Infrahub, the Git agent will ensure that both systems stay in sync at any time. Changes to branches or files in a Git repository will be synced to Infrahub automatically. +Please refer to [Repository](/topics/repository) to learn more about it. + ## Fork & Clone the repository for the demo Create a fork of the repository: @@ -22,7 +24,7 @@ The goal is to have a copy of this repository under your name. This way your tes Once you have created a fork in GitHub, you'll need a Personal Access Token to authorize Infrahub to access this repository. -[How to create a Personal Access Token in GitHub](/guides/repository#personal-access-token) +[How to create a Personal Access Token in GitHub](/guides/repository#github-access-token) :::note @@ -49,10 +51,10 @@ Refer to [Adding a repository guide](/guides/repository). After adding the `infrahub-demo-edge` repository you will be able to see several new [Transformations](/topics/transformation) and related objects: -- 3 Jinja Rendered File under [Jinja2 Transformation](http://localhost:8000/objects/CoreTransformJinja2/) -- 4 Python Transformation under [Python Transformation](http://localhost:8000/objects/CoreTransformPython) -- 4 [Artifact Definition](http://localhost:8000/objects/CoreArtifactDefinition) -- 7 GraphQL [Queries](/topics/graphql) under [Objects / GraphQL Query](http://localhost:8000/objects/GraphQLQuery/) +- 2 Jinja Rendered File under [Jinja2 Transformation](http://localhost:8000/objects/CoreTransformJinja2/) +- 2 Python Transformation under [Python Transformation](http://localhost:8000/objects/CoreTransformPython) +- 2 [Artifact Definition](http://localhost:8000/objects/CoreArtifactDefinition) +- 9 GraphQL [Queries](/topics/graphql) under [Objects / GraphQL Query](http://localhost:8000/objects/CoreGraphQLQuery/) :::note Troubleshooting diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 540547e08f..7871802132 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -96,6 +96,7 @@ const sidebars: SidebarsConfig = { 'topics/local-demo-environment', 'topics/generator', 'topics/graphql', + 'topics/metadata', 'topics/object-storage', 'topics/version-control', 'topics/proposed-change', diff --git a/frontend/app/package-lock.json b/frontend/app/package-lock.json index 2114bcdefa..2a74320684 100644 --- a/frontend/app/package-lock.json +++ b/frontend/app/package-lock.json @@ -98,7 +98,7 @@ "eslint-plugin-unused-imports": "^3.2.0", "husky": "^8.0.3", "jsdom": "^24.0.0", - "lint-staged": "^13.2.0", + "lint-staged": "^15.2.10", "openapi-typescript": "^7.0.2", "postcss": "^8.4.23", "prettier": "2.8.8", @@ -9199,9 +9199,10 @@ "dev": true }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "license": "MIT", "dependencies": { "ms": "2.1.2" }, @@ -9635,6 +9636,19 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -10490,7 +10504,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/execa": { "version": "4.1.0", @@ -10972,6 +10987,19 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.2.0.tgz", + "integrity": "sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-func-name": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", @@ -12825,42 +12853,44 @@ } }, "node_modules/lint-staged": { - "version": "13.3.0", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-13.3.0.tgz", - "integrity": "sha512-mPRtrYnipYYv1FEE134ufbWpeggNTo+O/UPzngoaKzbzHAthvR55am+8GfHTnqNRQVRRrYQLGW9ZyUoD7DsBHQ==", - "dev": true, - "dependencies": { - "chalk": "5.3.0", - "commander": "11.0.0", - "debug": "4.3.4", - "execa": "7.2.0", - "lilconfig": "2.1.0", - "listr2": "6.6.1", - "micromatch": "4.0.5", - "pidtree": "0.6.0", - "string-argv": "0.3.2", - "yaml": "2.3.1" + "version": "15.2.10", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.2.10.tgz", + "integrity": "sha512-5dY5t743e1byO19P9I4b3x8HJwalIznL5E1FWYnU6OWw33KxNBSLAc6Cy7F2PsFEO8FKnLwjwm5hx7aMF0jzZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "~5.3.0", + "commander": "~12.1.0", + "debug": "~4.3.6", + "execa": "~8.0.1", + "lilconfig": "~3.1.2", + "listr2": "~8.2.4", + "micromatch": "~4.0.8", + "pidtree": "~0.6.0", + "string-argv": "~0.3.2", + "yaml": "~2.5.0" }, "bin": { "lint-staged": "bin/lint-staged.js" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": ">=18.12.0" }, "funding": { "url": "https://opencollective.com/lint-staged" } }, "node_modules/lint-staged/node_modules/ansi-escapes": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-5.0.0.tgz", - "integrity": "sha512-5GFMVX8HqE/TB+FuBJGuO5XG0WrsA6ptUqoODaT/n9mmUaZFkqnBueB4leqGBCmrUHnCnC4PCZTCd0E7QQ83bA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", + "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", "dev": true, + "license": "MIT", "dependencies": { - "type-fest": "^1.0.2" + "environment": "^1.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -12871,6 +12901,7 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -12883,6 +12914,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -12903,93 +12935,100 @@ } }, "node_modules/lint-staged/node_modules/cli-cursor": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", - "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", "dev": true, + "license": "MIT", "dependencies": { - "restore-cursor": "^4.0.0" + "restore-cursor": "^5.0.0" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/lint-staged/node_modules/cli-truncate": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-3.1.0.tgz", - "integrity": "sha512-wfOBkjXteqSnI59oPcJkcPl/ZmwvMMOj340qUIY1SKZCv0B9Cf4D4fAucRkIKQmsIuYK3x1rrgU7MeGRruiuiA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", "dev": true, + "license": "MIT", "dependencies": { "slice-ansi": "^5.0.0", - "string-width": "^5.0.0" + "string-width": "^7.0.0" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/lint-staged/node_modules/commander": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-11.0.0.tgz", - "integrity": "sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ==", + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", "dev": true, + "license": "MIT", "engines": { - "node": ">=16" + "node": ">=18" } }, "node_modules/lint-staged/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, + "license": "MIT" }, "node_modules/lint-staged/node_modules/execa": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-7.2.0.tgz", - "integrity": "sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", "dev": true, + "license": "MIT", "dependencies": { "cross-spawn": "^7.0.3", - "get-stream": "^6.0.1", - "human-signals": "^4.3.0", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", - "signal-exit": "^3.0.7", + "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" }, "engines": { - "node": "^14.18.0 || ^16.14.0 || >=18.0.0" + "node": ">=16.17" }, "funding": { "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, "node_modules/lint-staged/node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", "dev": true, + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/lint-staged/node_modules/human-signals": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-4.3.1.tgz", - "integrity": "sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=14.18.0" + "node": ">=16.17.0" } }, "node_modules/lint-staged/node_modules/is-fullwidth-code-point": { @@ -12997,6 +13036,7 @@ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -13009,6 +13049,7 @@ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", "dev": true, + "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, @@ -13016,55 +13057,96 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lint-staged/node_modules/lilconfig": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz", + "integrity": "sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, "node_modules/lint-staged/node_modules/listr2": { - "version": "6.6.1", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-6.6.1.tgz", - "integrity": "sha512-+rAXGHh0fkEWdXBmX+L6mmfmXmXvDGEKzkjxO+8mP3+nI/r/CWznVBvsibXdxda9Zz0OW2e2ikphN3OwCT/jSg==", + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.2.4.tgz", + "integrity": "sha512-opevsywziHd3zHCVQGAj8zu+Z3yHNkkoYhWIGnq54RrCVwLz0MozotJEDnKsIBLvkfLGN6BLOyAeRrYI0pKA4g==", "dev": true, + "license": "MIT", "dependencies": { - "cli-truncate": "^3.1.0", + "cli-truncate": "^4.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", - "log-update": "^5.0.1", - "rfdc": "^1.3.0", - "wrap-ansi": "^8.1.0" + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" + } + }, + "node_modules/lint-staged/node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, - "peerDependencies": { - "enquirer": ">= 2.3.0 < 3" + "engines": { + "node": ">=18" }, - "peerDependenciesMeta": { - "enquirer": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lint-staged/node_modules/log-update": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-5.0.1.tgz", - "integrity": "sha512-5UtUDQ/6edw4ofyljDNcOVJQ4c7OjDro4h3y8e1GQL5iYElYclVHJ3zeWchylvMaKnDbDilC8irOVyexnA/Slw==", + "node_modules/lint-staged/node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", + "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==", "dev": true, + "license": "MIT", "dependencies": { - "ansi-escapes": "^5.0.0", - "cli-cursor": "^4.0.0", - "slice-ansi": "^5.0.0", - "strip-ansi": "^7.0.1", - "wrap-ansi": "^8.0.1" + "get-east-asian-width": "^1.0.0" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lint-staged/node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", + "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/lint-staged/node_modules/mimic-fn": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -13073,10 +13155,11 @@ } }, "node_modules/lint-staged/node_modules/npm-run-path": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.2.0.tgz", - "integrity": "sha512-W4/tgAXFqFA0iL7fk0+uQ3g7wkL8xJmx3XdK0VGb4cHW//eZTtKGvFBBoRKVTpY7n6ze4NL9ly7rgXcHufqXKg==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^4.0.0" }, @@ -13092,6 +13175,7 @@ "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", "dev": true, + "license": "MIT", "dependencies": { "mimic-fn": "^4.0.0" }, @@ -13107,6 +13191,7 @@ "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -13115,50 +13200,57 @@ } }, "node_modules/lint-staged/node_modules/restore-cursor": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", - "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", "dev": true, + "license": "MIT", "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lint-staged/node_modules/restore-cursor/node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/lint-staged/node_modules/restore-cursor/node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", "dev": true, + "license": "MIT", "dependencies": { - "mimic-fn": "^2.1.0" + "mimic-function": "^5.0.0" }, "engines": { - "node": ">=6" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lint-staged/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/lint-staged/node_modules/slice-ansi": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" @@ -13171,17 +13263,18 @@ } }, "node_modules/lint-staged/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, + "license": "MIT", "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=12" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -13192,6 +13285,7 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", "dev": true, + "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" }, @@ -13207,6 +13301,7 @@ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -13214,44 +13309,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lint-staged/node_modules/type-fest": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", - "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/lint-staged/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", "dev": true, + "license": "MIT", "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=12" + "node": ">=18" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/lint-staged/node_modules/yaml": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.1.tgz", - "integrity": "sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==", - "dev": true, - "engines": { - "node": ">= 14" - } - }, "node_modules/listr2": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/listr2/-/listr2-4.0.5.tgz", @@ -14522,11 +14597,12 @@ ] }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -14563,6 +14639,19 @@ "node": ">=6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mini-svg-data-uri": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", @@ -16968,10 +17057,11 @@ } }, "node_modules/rfdc": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.1.tgz", - "integrity": "sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg==", - "dev": true + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" }, "node_modules/rimraf": { "version": "3.0.2", @@ -19653,9 +19743,10 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, "node_modules/yaml": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.1.tgz", - "integrity": "sha512-pIXzoImaqmfOrL7teGUBt/T7ZDnyeGBWyXQBvOVhLkWLN37GXv8NMLK406UY6dS51JfcQHsmcW5cJ441bHg6Lg==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.0.tgz", + "integrity": "sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==", + "license": "ISC", "bin": { "yaml": "bin.mjs" }, @@ -25978,9 +26069,9 @@ "dev": true }, "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", "requires": { "ms": "2.1.2" } @@ -26299,6 +26390,12 @@ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" }, + "environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true + }, "error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -27313,6 +27410,12 @@ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true }, + "get-east-asian-width": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.2.0.tgz", + "integrity": "sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==", + "dev": true + }, "get-func-name": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", @@ -28621,30 +28724,30 @@ } }, "lint-staged": { - "version": "13.3.0", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-13.3.0.tgz", - "integrity": "sha512-mPRtrYnipYYv1FEE134ufbWpeggNTo+O/UPzngoaKzbzHAthvR55am+8GfHTnqNRQVRRrYQLGW9ZyUoD7DsBHQ==", + "version": "15.2.10", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.2.10.tgz", + "integrity": "sha512-5dY5t743e1byO19P9I4b3x8HJwalIznL5E1FWYnU6OWw33KxNBSLAc6Cy7F2PsFEO8FKnLwjwm5hx7aMF0jzZg==", "dev": true, "requires": { - "chalk": "5.3.0", - "commander": "11.0.0", - "debug": "4.3.4", - "execa": "7.2.0", - "lilconfig": "2.1.0", - "listr2": "6.6.1", - "micromatch": "4.0.5", - "pidtree": "0.6.0", - "string-argv": "0.3.2", - "yaml": "2.3.1" + "chalk": "~5.3.0", + "commander": "~12.1.0", + "debug": "~4.3.6", + "execa": "~8.0.1", + "lilconfig": "~3.1.2", + "listr2": "~8.2.4", + "micromatch": "~4.0.8", + "pidtree": "~0.6.0", + "string-argv": "~0.3.2", + "yaml": "~2.5.0" }, "dependencies": { "ansi-escapes": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-5.0.0.tgz", - "integrity": "sha512-5GFMVX8HqE/TB+FuBJGuO5XG0WrsA6ptUqoODaT/n9mmUaZFkqnBueB4leqGBCmrUHnCnC4PCZTCd0E7QQ83bA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", + "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", "dev": true, "requires": { - "type-fest": "^1.0.2" + "environment": "^1.0.0" } }, "ansi-regex": { @@ -28666,63 +28769,63 @@ "dev": true }, "cli-cursor": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", - "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", "dev": true, "requires": { - "restore-cursor": "^4.0.0" + "restore-cursor": "^5.0.0" } }, "cli-truncate": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-3.1.0.tgz", - "integrity": "sha512-wfOBkjXteqSnI59oPcJkcPl/ZmwvMMOj340qUIY1SKZCv0B9Cf4D4fAucRkIKQmsIuYK3x1rrgU7MeGRruiuiA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", "dev": true, "requires": { "slice-ansi": "^5.0.0", - "string-width": "^5.0.0" + "string-width": "^7.0.0" } }, "commander": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-11.0.0.tgz", - "integrity": "sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ==", + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", "dev": true }, "emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", "dev": true }, "execa": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-7.2.0.tgz", - "integrity": "sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", "dev": true, "requires": { "cross-spawn": "^7.0.3", - "get-stream": "^6.0.1", - "human-signals": "^4.3.0", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", - "signal-exit": "^3.0.7", + "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", "dev": true }, "human-signals": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-4.3.1.tgz", - "integrity": "sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", "dev": true }, "is-fullwidth-code-point": { @@ -28737,31 +28840,58 @@ "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", "dev": true }, + "lilconfig": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz", + "integrity": "sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==", + "dev": true + }, "listr2": { - "version": "6.6.1", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-6.6.1.tgz", - "integrity": "sha512-+rAXGHh0fkEWdXBmX+L6mmfmXmXvDGEKzkjxO+8mP3+nI/r/CWznVBvsibXdxda9Zz0OW2e2ikphN3OwCT/jSg==", + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.2.4.tgz", + "integrity": "sha512-opevsywziHd3zHCVQGAj8zu+Z3yHNkkoYhWIGnq54RrCVwLz0MozotJEDnKsIBLvkfLGN6BLOyAeRrYI0pKA4g==", "dev": true, "requires": { - "cli-truncate": "^3.1.0", + "cli-truncate": "^4.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", - "log-update": "^5.0.1", - "rfdc": "^1.3.0", - "wrap-ansi": "^8.1.0" + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" } }, "log-update": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-5.0.1.tgz", - "integrity": "sha512-5UtUDQ/6edw4ofyljDNcOVJQ4c7OjDro4h3y8e1GQL5iYElYclVHJ3zeWchylvMaKnDbDilC8irOVyexnA/Slw==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", "dev": true, "requires": { - "ansi-escapes": "^5.0.0", - "cli-cursor": "^4.0.0", - "slice-ansi": "^5.0.0", - "strip-ansi": "^7.0.1", - "wrap-ansi": "^8.0.1" + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "dependencies": { + "is-fullwidth-code-point": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", + "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==", + "dev": true, + "requires": { + "get-east-asian-width": "^1.0.0" + } + }, + "slice-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", + "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", + "dev": true, + "requires": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + } + } } }, "mimic-fn": { @@ -28771,9 +28901,9 @@ "dev": true }, "npm-run-path": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.2.0.tgz", - "integrity": "sha512-W4/tgAXFqFA0iL7fk0+uQ3g7wkL8xJmx3XdK0VGb4cHW//eZTtKGvFBBoRKVTpY7n6ze4NL9ly7rgXcHufqXKg==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", "dev": true, "requires": { "path-key": "^4.0.0" @@ -28795,32 +28925,32 @@ "dev": true }, "restore-cursor": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", - "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", "dev": true, "requires": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" }, "dependencies": { - "mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true - }, "onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", "dev": true, "requires": { - "mimic-fn": "^2.1.0" + "mimic-function": "^5.0.0" } } } }, + "signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true + }, "slice-ansi": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", @@ -28832,14 +28962,14 @@ } }, "string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "requires": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" } }, "strip-ansi": { @@ -28857,28 +28987,16 @@ "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", "dev": true }, - "type-fest": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", - "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", - "dev": true - }, "wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", "dev": true, "requires": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" } - }, - "yaml": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.1.tgz", - "integrity": "sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==", - "dev": true } } }, @@ -29734,11 +29852,11 @@ "integrity": "sha512-oNh6S2WMHWRZrmutsRmDDfkzKtxF+bc2VxLC9dvtrDIRFln627VsFP6fLMgTryGDljgLPjkrzQSDcPrjPyDJ5w==" }, "micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "requires": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" } }, @@ -29763,6 +29881,12 @@ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "dev": true }, + "mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true + }, "mini-svg-data-uri": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", @@ -31429,9 +31553,9 @@ "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==" }, "rfdc": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.1.tgz", - "integrity": "sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "dev": true }, "rimraf": { @@ -33292,9 +33416,9 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, "yaml": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.1.tgz", - "integrity": "sha512-pIXzoImaqmfOrL7teGUBt/T7ZDnyeGBWyXQBvOVhLkWLN37GXv8NMLK406UY6dS51JfcQHsmcW5cJ441bHg6Lg==" + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.0.tgz", + "integrity": "sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==" }, "yaml-ast-parser": { "version": "0.0.43", diff --git a/frontend/app/package.json b/frontend/app/package.json index e11a8ddbac..3b2db61c1e 100644 --- a/frontend/app/package.json +++ b/frontend/app/package.json @@ -120,7 +120,7 @@ "eslint-plugin-unused-imports": "^3.2.0", "husky": "^8.0.3", "jsdom": "^24.0.0", - "lint-staged": "^13.2.0", + "lint-staged": "^15.2.10", "openapi-typescript": "^7.0.2", "postcss": "^8.4.23", "prettier": "2.8.8", diff --git a/frontend/app/src/components/buttons/button-primitive.tsx b/frontend/app/src/components/buttons/button-primitive.tsx index 699c125e1d..537df4a76a 100644 --- a/frontend/app/src/components/buttons/button-primitive.tsx +++ b/frontend/app/src/components/buttons/button-primitive.tsx @@ -7,17 +7,17 @@ import { Link, LinkProps } from "react-router-dom"; import { Spinner } from "@/components/ui/spinner"; const buttonVariants = cva( - "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium disabled:opacity-60 disabled:cursor-disabled", + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium disabled:opacity-60 disabled:cursor-not-allowed", { variants: { variant: { - primary: "text-white bg-custom-blue-700 shadow hover:bg-custom-blue-700/90", - danger: "text-white bg-red-500 shadow hover:bg-red-500/90", - active: "text-white bg-green-600 shadow hover:bg-green-600/90", - outline: "border bg-custom-white shadow-sm hover:bg-gray-100", + primary: "text-white bg-custom-blue-700 shadow enabled:hover:bg-custom-blue-700/90", + danger: "text-white bg-red-500 shadow enabled:hover:bg-red-500/90", + active: "text-white bg-green-600 shadow enabled:hover:bg-green-600/90", + outline: "border bg-custom-white shadow-sm enabled:hover:bg-gray-100", "primary-outline": - "text-custom-blue-700 border border-custom-blue-700 bg-custom-white shadow-sm hover:bg-gray-100", - dark: "border bg-gray-200 shadow-sm hover:bg-gray-300", + "text-custom-blue-700 border border-custom-blue-700 bg-custom-white shadow-sm enabled:hover:bg-gray-100", + dark: "border bg-gray-200 shadow-sm enabled:hover:bg-gray-300", ghost: "hover:bg-gray-100", }, size: { diff --git a/frontend/app/src/components/buttons/retry.tsx b/frontend/app/src/components/buttons/retry.tsx index f555f577ed..4ce2ee4fa4 100644 --- a/frontend/app/src/components/buttons/retry.tsx +++ b/frontend/app/src/components/buttons/retry.tsx @@ -34,7 +34,7 @@ export const Retry = (props: tRetryProps) => { )} onClick={handleClick}> diff --git a/frontend/app/src/components/buttons/tabs-buttons.tsx b/frontend/app/src/components/buttons/tabs-buttons.tsx index f9b7bd7c3e..e3447ab444 100644 --- a/frontend/app/src/components/buttons/tabs-buttons.tsx +++ b/frontend/app/src/components/buttons/tabs-buttons.tsx @@ -1,10 +1,11 @@ import { QSP } from "@/config/qsp"; import { StringParam, useQueryParam } from "use-query-params"; import { Button } from "./button-primitive"; +import { ReactNode } from "react"; type Tab = { name?: string; - label?: string; + label?: ReactNode; disabled?: boolean; }; diff --git a/frontend/app/src/components/buttons/toggle-buttons.tsx b/frontend/app/src/components/buttons/toggle-buttons.tsx index 2397c8c0b7..0c7b204dc6 100644 --- a/frontend/app/src/components/buttons/toggle-buttons.tsx +++ b/frontend/app/src/components/buttons/toggle-buttons.tsx @@ -1,5 +1,5 @@ import { MouseEventHandler } from "react"; -import { Button } from "./button-primitive"; +import { Button, ButtonProps } from "./button-primitive"; type Tab = { label?: string; @@ -7,14 +7,12 @@ type Tab = { onClick: MouseEventHandler; }; -type TabsProps = { +interface TabsProps extends ButtonProps { tabs: Tab[]; isLoading?: boolean; -}; - -export const ToggleButtons = (props: TabsProps) => { - const { tabs, isLoading } = props; +} +export const ToggleButtons = ({ tabs, ...props }: TabsProps) => { return (
@@ -22,9 +20,10 @@ export const ToggleButtons = (props: TabsProps) => { ))} diff --git a/frontend/app/src/components/conversations/thread.tsx b/frontend/app/src/components/conversations/thread.tsx index 9be792d1af..56a07c7fde 100644 --- a/frontend/app/src/components/conversations/thread.tsx +++ b/frontend/app/src/components/conversations/thread.tsx @@ -3,13 +3,7 @@ import { Checkbox } from "@/components/inputs/checkbox"; import ModalConfirm from "@/components/modals/modal-confirm"; import { ALERT_TYPES, Alert } from "@/components/ui/alert"; import { Tooltip } from "@/components/ui/tooltip"; -import { - DIFF_TABS, - PROPOSED_CHANGES_ARTIFACT_THREAD_OBJECT, - PROPOSED_CHANGES_CHANGE_THREAD_OBJECT, - PROPOSED_CHANGES_OBJECT_THREAD_OBJECT, - PROPOSED_CHANGES_THREAD_COMMENT_OBJECT, -} from "@/config/constants"; +import { PROPOSED_CHANGES_THREAD_COMMENT_OBJECT } from "@/config/constants"; import graphqlClient from "@/graphql/graphqlClientApollo"; import { createObject } from "@/graphql/mutations/objects/createObject"; import { updateObjectWithId } from "@/graphql/mutations/objects/updateObjectWithId"; @@ -27,8 +21,7 @@ import { useState } from "react"; import { toast } from "react-toastify"; import { AddComment } from "./add-comment"; import { Comment } from "./comment"; -import { StringParam, useQueryParam } from "use-query-params"; -import { QSP } from "@/config/qsp"; +import { Card } from "@/components/ui/card"; type tThread = { thread: any; @@ -48,7 +41,6 @@ export const Thread = (props: tThread) => { const auth = useAuth(); - const [qspTab] = useQueryParam(QSP.PROPOSED_CHANGES_TAB, StringParam); const branch = useAtomValue(currentBranchAtom); const date = useAtomValue(datetimeAtom); const [isLoading, setIsLoading] = useState(false); @@ -110,21 +102,6 @@ export const Thread = (props: tThread) => { } }; - const getMutation = () => { - // for artifacts view - if (qspTab === DIFF_TABS.ARTIFACTS) { - return PROPOSED_CHANGES_ARTIFACT_THREAD_OBJECT; - } - - // for conversations view - if (displayContext) { - return PROPOSED_CHANGES_CHANGE_THREAD_OBJECT; - } - - // for object views - return PROPOSED_CHANGES_OBJECT_THREAD_OBJECT; - }; - const handleResolve = async () => { if (!thread.id) { return; @@ -138,7 +115,7 @@ export const Thread = (props: tThread) => { } const mutationString = updateObjectWithId({ - kind: getMutation(), + kind: thread.__typename, data: stringifyWithoutQuotes({ id: thread.id, resolved: { @@ -197,37 +174,30 @@ export const Thread = (props: tThread) => { ); return ( -
{displayContext && getThreadTitle(thread)} -
- {sortedComments.map((comment: any, index: number) => ( - - ))} -
+ {sortedComments.map((comment: any, index: number) => ( + + ))} {displayAddComment ? ( -
- setDisplayAddComment(false)} - additionalButtons={MarkAsResolvedWithTooltip} - /> -
+ setDisplayAddComment(false)} + additionalButtons={MarkAsResolvedWithTooltip} + /> ) : ( -
+
{MarkAsResolved}
+ ); }; diff --git a/frontend/app/src/components/form/node-form.tsx b/frontend/app/src/components/form/node-form.tsx index b870b91125..ed8d177980 100644 --- a/frontend/app/src/components/form/node-form.tsx +++ b/frontend/app/src/components/form/node-form.tsx @@ -38,6 +38,7 @@ export type NodeFormProps = { onSuccess?: (newObject: any) => void; currentObject?: Record; isFilterForm?: boolean; + isUpdate?: boolean; onSubmit?: (data: NodeFormSubmitParams) => void; }; @@ -49,6 +50,7 @@ export const NodeForm = ({ onSuccess, isFilterForm, onSubmit, + isUpdate, ...props }: NodeFormProps) => { const branch = useAtomValue(currentBranchAtom); @@ -80,6 +82,7 @@ export const NodeForm = ({ isFilterForm, filters, pools: numberPools, + isUpdate, }); async function onSubmitCreate(data: Record) { diff --git a/frontend/app/src/components/form/object-form.tsx b/frontend/app/src/components/form/object-form.tsx index 9e21fa4f4e..72ded9f3d3 100644 --- a/frontend/app/src/components/form/object-form.tsx +++ b/frontend/app/src/components/form/object-form.tsx @@ -25,6 +25,7 @@ export interface ObjectFormProps extends Omit; currentProfiles?: ProfileData[]; isFilterForm?: boolean; + isUpdate?: boolean; onSubmit?: (data: NodeFormSubmitParams) => void; onUpdateComplete?: () => void; } diff --git a/frontend/app/src/components/form/pool-selector.tsx b/frontend/app/src/components/form/pool-selector.tsx index cd550a8b3d..401396c7c2 100644 --- a/frontend/app/src/components/form/pool-selector.tsx +++ b/frontend/app/src/components/form/pool-selector.tsx @@ -39,11 +39,14 @@ export const PoolSelector = forwardRef( }, })); + const displayFromPool = + typeof value.value === "object" && value.value && "from_pool" in value.value; + return (
- {value.source?.type !== "pool" || override ? ( + {value.source?.type !== "pool" || override || !displayFromPool ? ( setOverride(false)} ref={ref}> {children} diff --git a/frontend/app/src/components/form/type.ts b/frontend/app/src/components/form/type.ts index f13f8910a9..0ba6a62230 100644 --- a/frontend/app/src/components/form/type.ts +++ b/frontend/app/src/components/form/type.ts @@ -22,14 +22,16 @@ export type AttributeValueFromProfile = { value: string | number | boolean | null; }; +export type PoolSource = { + type: "pool"; + label: string | null; + kind: string; + id: string; +}; + export type AttributeValueFormPool = { - source: { - type: "pool"; - label: string | null; - kind: string; - id: string; - }; - value: { from_pool: string }; + source: PoolSource; + value: { from_pool: { id: string } }; }; export type AttributeValueForCheckbox = { @@ -53,12 +55,7 @@ export type FormAttributeValue = | AttributeValueFormPool; export type RelationshipValueFormPool = { - source: { - type: "pool"; - label: string | null; - kind: string; - id: string; - }; + source: PoolSource; value: { id: string } | { from_pool: { id: string } }; }; diff --git a/frontend/app/src/components/form/utils/getFieldDefaultValue.ts b/frontend/app/src/components/form/utils/getFieldDefaultValue.ts index 1765ad1f33..87399efc5b 100644 --- a/frontend/app/src/components/form/utils/getFieldDefaultValue.ts +++ b/frontend/app/src/components/form/utils/getFieldDefaultValue.ts @@ -1,11 +1,13 @@ import { FieldSchema, AttributeType } from "@/utils/getObjectItemDisplayValue"; import { ProfileData } from "@/components/form/object-form"; import { + AttributeValueFormPool, AttributeValueFromProfile, AttributeValueFromUser, FormAttributeValue, } from "@/components/form/type"; import * as R from "ramda"; +import { LineageSource } from "@/generated/graphql"; export type GetFieldDefaultValue = { fieldSchema: FieldSchema; @@ -28,6 +30,7 @@ export const getFieldDefaultValue = ({ return ( getCurrentFieldValue(fieldSchema.name, initialObject) ?? getDefaultValueFromProfiles(fieldSchema.name, profiles) ?? + getDefaultValueFromPool(fieldSchema.name, initialObject) ?? getDefaultValueFromSchema(fieldSchema) ?? { source: null, value: null } ); }; @@ -45,6 +48,10 @@ export const getCurrentFieldValue = ( return null; } + if (currentField.source?.__typename?.match(/Pool$/g)) { + return null; + } + return { source: { type: "user" }, value: currentField.value }; }; @@ -82,6 +89,35 @@ const getDefaultValueFromProfiles = ( }; }; +const getDefaultValueFromPool = ( + fieldName: string, + objectData?: Record +): AttributeValueFormPool | null => { + if (!objectData) return null; + + const currentField = objectData[fieldName]; + if (!currentField) return null; + + if (!currentField.source?.__typename?.match(/Pool$/g)) { + return null; + } + + const pool = currentField.source as LineageSource; + + if (!pool) return null; + if (!pool.id) return null; + + return { + source: { + type: "pool", + id: pool.id, + label: pool.display_label || null, + kind: pool.__typename, + }, + value: currentField.value, + }; +}; + export const getDefaultValueFromSchema = ( fieldSchema: FieldSchema ): AttributeValueFromUser | null => { diff --git a/frontend/app/src/components/form/utils/getFormFieldsFromSchema.ts b/frontend/app/src/components/form/utils/getFormFieldsFromSchema.ts index 623ca41d13..c2b7836ad7 100644 --- a/frontend/app/src/components/form/utils/getFormFieldsFromSchema.ts +++ b/frontend/app/src/components/form/utils/getFormFieldsFromSchema.ts @@ -17,11 +17,7 @@ import { FormFieldValue, NumberPoolData, } from "@/components/form/type"; -import { - getObjectRelationshipsForForm, - getOptionsFromAttribute, - getRelationshipOptions, -} from "@/utils/getSchemaObjectColumns"; +import { getOptionsFromAttribute, getRelationshipOptions } from "@/utils/getSchemaObjectColumns"; import { isGeneric, sortByOrderWeight } from "@/utils/common"; import { getFieldDefaultValue } from "@/components/form/utils/getFieldDefaultValue"; import { SchemaAttributeType } from "@/screens/edit-form-hook/dynamic-control-types"; @@ -33,6 +29,7 @@ import { getRelationshipDefaultValue } from "@/components/form/utils/getRelation import { Filter } from "@/hooks/useFilters"; import { getRelationshipParent } from "@/components/form/utils/getRelationshipParent"; import { isRequired } from "@/components/form/utils/validation"; +import { getRelationshipsForForm } from "@/components/form/utils/getRelationshipsForForm"; type GetFormFieldsFromSchema = { schema: iNodeSchema | iGenericSchema; @@ -42,6 +39,7 @@ type GetFormFieldsFromSchema = { isFilterForm?: boolean; filters?: Array; pools?: Array; + isUpdate?: boolean; }; export const getFormFieldsFromSchema = ({ @@ -52,10 +50,11 @@ export const getFormFieldsFromSchema = ({ isFilterForm, filters, pools = [], + isUpdate, }: GetFormFieldsFromSchema): Array => { const unorderedFields = [ ...(schema.attributes ?? []), - ...getObjectRelationshipsForForm(schema), + ...getRelationshipsForForm(schema.relationships ?? [], isUpdate), ].filter((attribute) => !attribute.read_only); const orderedFields: typeof unorderedFields = sortByOrderWeight(unorderedFields); diff --git a/frontend/app/src/components/form/utils/getRelationshipsForForm.ts b/frontend/app/src/components/form/utils/getRelationshipsForForm.ts new file mode 100644 index 0000000000..efffb958f0 --- /dev/null +++ b/frontend/app/src/components/form/utils/getRelationshipsForForm.ts @@ -0,0 +1,19 @@ +import { components } from "@/infraops"; +import { peersKindForForm } from "@/config/constants"; + +export const getRelationshipsForForm = ( + relationships: components["schemas"]["RelationshipSchema-Output"][], + isUpdate?: boolean +) => { + // Filter relationships based on cardinality and kind for form inclusion + // For create forms, include relationships with cardinality 'one', eligible kinds, or mandatory cardinality 'many' + // For update forms, only include relationships with cardinality 'one' or those with eligible kinds (Attribute or Parent). Other should be display as tabs on details view + return relationships.filter((relationship) => { + if (relationship.cardinality === "one") return true; + + const isPeerKindEligibleForForm = peersKindForForm.includes(relationship?.kind ?? ""); + if (isUpdate) return isPeerKindEligibleForForm; + + return isPeerKindEligibleForForm || !relationship.optional; + }); +}; diff --git a/frontend/app/src/components/form/utils/mutations/getUpdateMutationFromFormData.ts b/frontend/app/src/components/form/utils/mutations/getUpdateMutationFromFormData.ts index 8980d6020a..e89da1ab97 100644 --- a/frontend/app/src/components/form/utils/mutations/getUpdateMutationFromFormData.ts +++ b/frontend/app/src/components/form/utils/mutations/getUpdateMutationFromFormData.ts @@ -17,6 +17,14 @@ export const getUpdateMutationFromFormData = ({ return acc; } + if ( + fieldData.source?.type === "pool" && + field.defaultValue?.source?.id === fieldData?.source?.id + ) { + // If the same pool is selected, then remove from the updates + return acc; + } + switch (fieldData.source?.type) { case "pool": case "user": { diff --git a/frontend/app/src/components/form/utils/updateFormFieldValue.ts b/frontend/app/src/components/form/utils/updateFormFieldValue.ts index c839ee9411..59f73c5156 100644 --- a/frontend/app/src/components/form/utils/updateFormFieldValue.ts +++ b/frontend/app/src/components/form/utils/updateFormFieldValue.ts @@ -35,7 +35,9 @@ export const updateAttributeFieldValue = ( label: newValue.from_pool.name, }, value: { - from_pool: newValue.from_pool.id, + from_pool: { + id: newValue.from_pool.id, + }, }, }; } diff --git a/frontend/app/src/components/search/search-anywhere.tsx b/frontend/app/src/components/search/search-anywhere.tsx index 5f5a556e10..f72c0cbea9 100644 --- a/frontend/app/src/components/search/search-anywhere.tsx +++ b/frontend/app/src/components/search/search-anywhere.tsx @@ -16,6 +16,7 @@ import { Link, LinkProps, useNavigate } from "react-router-dom"; import { SearchActions } from "./search-actions"; import { SearchDocs } from "./search-docs"; import { SearchNodes } from "./search-nodes"; +import { Card } from "@/components/ui/card"; type SearchInputProps = { className?: string; @@ -128,7 +129,7 @@ const SearchAnywhereDialog = forwardRef( return ( { @@ -142,27 +143,22 @@ const SearchAnywhereDialog = forwardRef( onSelection(url); }}> -
- -