Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down Expand Up @@ -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 |
Expand Down
82 changes: 82 additions & 0 deletions datadog_sync/model/rum_teams_ownership_mappings.py
Original file line number Diff line number Diff line change
@@ -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']}"
)
1 change: 1 addition & 0 deletions datadog_sync/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
173 changes: 173 additions & 0 deletions tests/unit/test_rum_teams_ownership_mappings.py
Original file line number Diff line number Diff line change
@@ -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"
Loading