From f83f5a9899522e38fa05d4a55e26bf823e3cc2e0 Mon Sep 17 00:00:00 2001 From: Michael Richey Date: Fri, 25 Sep 2026 16:28:43 -0400 Subject: [PATCH 1/2] feat(rum): add rum_teams_ownership_mappings resource 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). - datadog_sync/model/rum_teams_ownership_mappings.py (new) - datadog_sync/models/__init__.py -- register - tests/unit/test_rum_teams_ownership_mappings.py (new) -- 7 unit tests - README.md -- add rum_teams_ownership_mappings (depends on rum_applications, teams) Integration tests + VCR cassettes deferred (require sandbox-org API access). --- README.md | 2 + .../model/rum_teams_ownership_mappings.py | 78 ++++++++++ datadog_sync/models/__init__.py | 1 + .../unit/test_rum_teams_ownership_mappings.py | 144 ++++++++++++++++++ 4 files changed, 225 insertions(+) create mode 100644 datadog_sync/model/rum_teams_ownership_mappings.py create mode 100644 tests/unit/test_rum_teams_ownership_mappings.py 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..cc8b5363 --- /dev/null +++ b/datadog_sync/model/rum_teams_ownership_mappings.py @@ -0,0 +1,78 @@ +# 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. + 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..468ae738 --- /dev/null +++ b/tests/unit/test_rum_teams_ownership_mappings.py @@ -0,0 +1,144 @@ +# 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 + +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_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" From 41e8074d079272c4fab3a6d904c25c6d5e18bc11 Mon Sep 17 00:00:00 2001 From: Michael Richey Date: Mon, 28 Sep 2026 10:09:51 -0400 Subject: [PATCH 2/2] fix(rum): use state-aware _delete_resource in teams_ownership_mappings update Per review: update_resource called delete_resource directly, which left the old destination ID in persisted state if DELETE succeeded but POST failed. Every retry then DELETEed that stale ID, failed with 404, and never reached POST. Now uses _delete_resource (the state-aware wrapper) which removes the state entry after a successful delete and tolerates an already-missing destination, so the next retry recovers via the create path. New test verifies state is cleaned up when DELETE succeeds but POST fails. --- .../model/rum_teams_ownership_mappings.py | 6 +++- .../unit/test_rum_teams_ownership_mappings.py | 29 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/datadog_sync/model/rum_teams_ownership_mappings.py b/datadog_sync/model/rum_teams_ownership_mappings.py index cc8b5363..cf1f1653 100644 --- a/datadog_sync/model/rum_teams_ownership_mappings.py +++ b/datadog_sync/model/rum_teams_ownership_mappings.py @@ -68,7 +68,11 @@ async def create_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: async def update_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: # No PATCH endpoint -- implement update as delete-then-recreate. - await self.delete_resource(_id) + # 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: diff --git a/tests/unit/test_rum_teams_ownership_mappings.py b/tests/unit/test_rum_teams_ownership_mappings.py index 468ae738..b57141ca 100644 --- a/tests/unit/test_rum_teams_ownership_mappings.py +++ b/tests/unit/test_rum_teams_ownership_mappings.py @@ -19,6 +19,8 @@ from collections import defaultdict from unittest.mock import AsyncMock, MagicMock +import pytest + from datadog_sync.model.rum_teams_ownership_mappings import RUMTeamsOwnershipMappings @@ -115,6 +117,33 @@ def test_update_resource_deletes_then_recreates(): 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()