diff --git a/README.md b/README.md index 0ab7e3e2..1ba4fa20 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,7 @@ When running againts multiple destination organizations, a seperate working dire | rum_metrics | Sync Datadog RUM-based metrics. | | rum_operations | Sync Datadog RUM operations. | | rum_replay_playlists | Sync Datadog RUM replay playlists (shell only). | +| rum_teams_ownership_mappings | Sync Datadog RUM teams ownership mappings. | | rum_operation_strong_links | Sync Datadog RUM operation strong links. | | rum_permanent_retention_filters | Sync Datadog permanent RUM retention filters (configure-only). | | rum_retention_filters | Sync Datadog RUM retention filters (generic + exclusion). | @@ -371,6 +372,7 @@ See [Supported resources](#supported-resources) section below for potential reso | rum_metrics | - | | rum_operations | rum_applications | | rum_replay_playlists | - | +| rum_teams_ownership_mappings | rum_applications, teams | | rum_operation_strong_links | rum_operations, rum_applications | | rum_permanent_retention_filters | rum_applications | | rum_retention_filters | rum_applications | diff --git a/datadog_sync/model/rum_teams_ownership_mappings.py b/datadog_sync/model/rum_teams_ownership_mappings.py new file mode 100644 index 00000000..cf1f1653 --- /dev/null +++ b/datadog_sync/model/rum_teams_ownership_mappings.py @@ -0,0 +1,82 @@ +# Unless explicitly stated otherwise all files in this repository are licensed +# under the 3-clause BSD style license (see LICENSE). +# This product includes software developed at Datadog (https://www.datadoghq.com/). +# Copyright 2019 Datadog, Inc. + +from __future__ import annotations +from typing import TYPE_CHECKING, Optional, List, Dict, Tuple + +from datadog_sync.utils.base_resource import BaseResource, ResourceConfig + +if TYPE_CHECKING: + from datadog_sync.utils.custom_client import CustomClient + + +class RUMTeamsOwnershipMappings(BaseResource): + """RUM teams-ownership mappings. + + Mappings have no PATCH endpoint, so update is implemented as + delete-then-recreate. ``attributes.application_id`` is a RUM application + uuid remapped via ``resource_connections``. ``attributes.team_handle`` is a + stable handle (teams are mapped by name:handle and the handle is preserved + across orgs), so it is NOT remapped -- the dependency on teams is soft + (documented) rather than a resource_connection (which would mis-remap a + handle as a uuid). + """ + + resource_type = "rum_teams_ownership_mappings" + resource_config = ResourceConfig( + base_path="/api/v2/rum/config/teams-ownership/mappings", + excluded_attributes=[ + "id", + "attributes.created_at", + "attributes.created_by", + "attributes.org_id", + ], + resource_connections={ + "rum_applications": ["attributes.application_id"], + }, + skip_resource_mapping=True, + ) + # Additional RUMTeamsOwnershipMappings specific attributes + + async def get_resources(self, client: CustomClient) -> List[Dict]: + resp = await client.get(self.resource_config.base_path) + + return resp["data"] + + async def import_resource(self, _id: Optional[str] = None, resource: Optional[Dict] = None) -> Tuple[str, Dict]: + if _id: + source_client = self.config.source_client + resource = (await source_client.get(self.resource_config.base_path + f"/{_id}"))["data"] + + return resource["id"], resource + + async def pre_resource_action_hook(self, _id, resource: Dict) -> None: + pass + + async def pre_apply_hook(self) -> None: + pass + + async def create_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: + destination_client = self.config.destination_client + # create data has no id (server-assigned) + resource.pop("id", None) + payload = {"data": resource} + resp = await destination_client.post(self.resource_config.base_path, payload) + return _id, resp["data"] + + async def update_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: + # No PATCH endpoint -- implement update as delete-then-recreate. + # Use _delete_resource (the state-aware wrapper) so the state entry is + # removed after a successful delete. If DELETE succeeds but POST + # fails, the next retry recovers via the create path instead of + # DELETEing a stale id that 404s forever. + await self._delete_resource(_id) + return await self.create_resource(_id, resource) + + async def delete_resource(self, _id: str) -> None: + destination_client = self.config.destination_client + await destination_client.delete( + self.resource_config.base_path + f"/{self.config.state.destination[self.resource_type][_id]['id']}" + ) diff --git a/datadog_sync/models/__init__.py b/datadog_sync/models/__init__.py index 30aa822a..924a8786 100644 --- a/datadog_sync/models/__init__.py +++ b/datadog_sync/models/__init__.py @@ -33,6 +33,7 @@ from datadog_sync.model.rum_operation_strong_links import RUMOperationStrongLinks from datadog_sync.model.rum_permanent_retention_filters import RUMPermanentRetentionFilters from datadog_sync.model.rum_replay_playlists import RUMReplayPlaylists +from datadog_sync.model.rum_teams_ownership_mappings import RUMTeamsOwnershipMappings from datadog_sync.model.rum_retention_filters import RUMRetentionFilters from datadog_sync.model.rum_retention_filters_order import RUMRetentionFiltersOrder from datadog_sync.model.security_monitoring_rules import SecurityMonitoringRules diff --git a/tests/unit/test_rum_teams_ownership_mappings.py b/tests/unit/test_rum_teams_ownership_mappings.py new file mode 100644 index 00000000..b57141ca --- /dev/null +++ b/tests/unit/test_rum_teams_ownership_mappings.py @@ -0,0 +1,173 @@ +# Unless explicitly stated otherwise all files in this repository are licensed +# under the 3-clause BSD style license (see LICENSE). +# This product includes software developed at Datadog (https://www.datadoghq.com/). +# Copyright 2019 Datadog, Inc. + +""" +Unit tests for the RUMTeamsOwnershipMappings resource model. + +RUM teams-ownership mappings have no PATCH endpoint, so update is implemented +as delete-then-recreate. ``attributes.application_id`` is a RUM application +uuid remapped via ``resource_connections``. ``attributes.team_handle`` is a +stable handle (teams are mapped by name:handle and the handle is preserved +across orgs), so it is NOT remapped -- the dependency on teams is soft +(documented) rather than a resource_connection (which would mis-remap a handle +as a uuid). +""" + +import asyncio +from collections import defaultdict +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from datadog_sync.model.rum_teams_ownership_mappings import RUMTeamsOwnershipMappings + + +def _run(coro): + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +def _m(_id, app_id="app-src", handle="team-platform"): + return { + "id": _id, + "type": "teams_ownership_mappings", + "attributes": { + "application_id": app_id, + "team_handle": handle, + "match_type": "exact", + "service": "web", + "view_name": "checkout", + }, + } + + +def test_get_resources_hits_list_endpoint(): + m = RUMTeamsOwnershipMappings(MagicMock()) + client = AsyncMock() + client.get = AsyncMock(return_value={"data": [_m("m-1")]}) + + resources = _run(m.get_resources(client)) + + assert resources == [_m("m-1")] + client.get.assert_awaited_once_with("/api/v2/rum/config/teams-ownership/mappings") + + +def test_import_resource_by_id_gets_and_returns(): + m = RUMTeamsOwnershipMappings(MagicMock()) + source = AsyncMock() + source.get = AsyncMock(return_value={"data": _m("m-1")}) + m.config.source_client = source + + _id, data = _run(m.import_resource(_id="m-1")) + + assert _id == "m-1" + source.get.assert_awaited_once_with("/api/v2/rum/config/teams-ownership/mappings/m-1") + + +def test_import_resource_passthrough(): + m = RUMTeamsOwnershipMappings(MagicMock()) + m.config.source_client = AsyncMock() + resource = _m("m-1") + _id, data = _run(m.import_resource(resource=resource)) + assert _id == "m-1" + assert data is resource + + +def test_create_resource_posts_without_id(): + m = RUMTeamsOwnershipMappings(MagicMock()) + dest = AsyncMock() + dest.post = AsyncMock(return_value={"data": _m("m-dst", app_id="app-dst")}) + m.config.destination_client = dest + + resource = _m("m-1", app_id="app-dst") + _id, data = _run(m.create_resource("m-1", resource)) + + assert _id == "m-1" + assert data["id"] == "m-dst" + assert "id" not in resource + dest.post.assert_awaited_once() + post_url, post_payload = dest.post.await_args.args + assert post_url == "/api/v2/rum/config/teams-ownership/mappings" + assert post_payload == {"data": resource} + + +def test_update_resource_deletes_then_recreates(): + m = RUMTeamsOwnershipMappings(MagicMock()) + dest = AsyncMock() + dest.delete = AsyncMock() + dest.post = AsyncMock(return_value={"data": _m("m-dst-new", app_id="app-dst")}) + m.config.destination_client = dest + m.config.state = MagicMock() + m.config.state.destination = defaultdict(dict) + m.config.state.destination["rum_teams_ownership_mappings"]["m-1"] = {"id": "m-dst-old"} + + resource = _m("m-1", app_id="app-dst") + _id, data = _run(m.update_resource("m-1", resource)) + + assert _id == "m-1" + assert data["id"] == "m-dst-new" + # delete the old destination mapping, then create a new one + dest.delete.assert_awaited_once_with("/api/v2/rum/config/teams-ownership/mappings/m-dst-old") + dest.post.assert_awaited_once() + assert "id" not in resource + + +def test_update_resource_removes_state_after_delete_for_retry_recovery(): + """If DELETE succeeds but POST fails, the state entry must be removed so + the next retry recovers via the create path instead of DELETEing a stale + id that 404s forever.""" + from datadog_sync.utils.resource_utils import CustomClientHTTPError + + m = RUMTeamsOwnershipMappings(MagicMock()) + dest = AsyncMock() + dest.delete = AsyncMock() # DELETE succeeds + # POST fails (e.g. 500) + resp = MagicMock() + resp.status = 500 + resp.message = "Internal Server Error" + dest.post = AsyncMock(side_effect=CustomClientHTTPError(resp, message="server error")) + m.config.destination_client = dest + m.config.state = MagicMock() + m.config.state.destination = defaultdict(dict) + m.config.state.destination["rum_teams_ownership_mappings"]["m-1"] = {"id": "m-dst-old"} + + resource = _m("m-1", app_id="app-dst") + with pytest.raises(CustomClientHTTPError): + _run(m.update_resource("m-1", resource)) + + # State entry must be removed after the successful delete + assert "m-1" not in m.config.state.destination["rum_teams_ownership_mappings"] + + +def test_delete_resource_deletes_destination_id(): + m = RUMTeamsOwnershipMappings(MagicMock()) + dest = AsyncMock() + m.config.destination_client = dest + m.config.state = MagicMock() + m.config.state.destination = defaultdict(dict) + m.config.state.destination["rum_teams_ownership_mappings"]["m-1"] = {"id": "m-dst"} + + _run(m.delete_resource("m-1")) + + dest.delete.assert_awaited_once_with("/api/v2/rum/config/teams-ownership/mappings/m-dst") + + +def test_connect_resources_remaps_application_id_only(): + m = RUMTeamsOwnershipMappings(MagicMock()) + m.config.state = MagicMock() + m.config.state.destination = defaultdict(dict) + m.config.state.destination["rum_applications"]["app-src"] = {"id": "app-dst"} + m.config.skip_failed_resource_connections = False + m.config.logger = MagicMock() + + resource = _m("m-1", app_id="app-src", handle="team-platform") + m.connect_resources("m-1", resource) + + assert resource["attributes"]["application_id"] == "app-dst" + # team_handle is a stable handle, NOT remapped + assert resource["attributes"]["team_handle"] == "team-platform"