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 @@ -257,6 +257,7 @@ When running againts multiple destination organizations, a seperate working dire
| restriction_policies | Sync Datadog restriction policies. |
| roles | Sync Datadog roles. |
| rum_applications | Sync Datadog RUM applications. |
| rum_config | Sync Datadog RUM configuration (singleton). |
| rum_metrics | Sync Datadog RUM-based metrics. |
| rum_operations | Sync Datadog RUM operations. |
| rum_replay_playlists | Sync Datadog RUM replay playlists (shell only). |
Expand Down Expand Up @@ -369,6 +370,7 @@ See [Supported resources](#supported-resources) section below for potential reso
| restriction_policies | dashboards, service_level_objectives, notebooks, users, roles |
| roles | - |
| rum_applications | - |
| rum_config | - |
| rum_metrics | - |
| rum_operations | rum_applications |
| rum_replay_playlists | - |
Expand Down
102 changes: 102 additions & 0 deletions datadog_sync/model/rum_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# 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
from datadog_sync.utils.resource_utils import CustomClientHTTPError

if TYPE_CHECKING:
from datadog_sync.utils.custom_client import CustomClient


class RUMConfig(BaseResource):
"""RUM config (singleton org setting).

RUM config is a singleton (no DELETE, no per-id path). Only
``enforced_application_tags`` is configurable; all other attributes are
server-managed and excluded from diffs. ``create_resource`` checks whether
the destination singleton already exists and delegates to ``update_resource``
if so (mirroring ``logs_archives_order``); ``delete_resource`` is a no-op.
"""

resource_type = "rum_config"
resource_config = ResourceConfig(
base_path="/api/v2/rum/config",
excluded_attributes=[
"id",
"attributes.disabled",
"attributes.enforced_application_tags_updated_at",
"attributes.enforced_application_tags_updated_by",
"attributes.ootb_metrics_version",
"attributes.ootb_metrics_version_installed_at",
"attributes.retention_filters_enabled",
"attributes.retention_filters_enabled_updated_at",
"attributes.retention_filters_enabled_updated_by",
],
concurrent=False,
skip_resource_mapping=True,
)
# Additional RUMConfig specific attributes
default_id: str = "rum-config"

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]:
# Singleton: always keyed by the default id.
return self.default_id, resource

async def pre_resource_action_hook(self, _id, resource: Dict) -> None:
pass

async def pre_apply_hook(self) -> None:
pass

async def _existing_destination(self) -> Optional[Dict]:
destination_client = self.config.destination_client
try:
resp = await destination_client.get(self.resource_config.base_path)
return resp["data"]
except CustomClientHTTPError as e:
if e.status_code == 404:
self.config.logger.debug("rum_config: destination singleton not present (404)")
return None
raise

async def create_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]:
# If the destination singleton already exists, hydrate state and delegate
# to update (mirrors logs_archives_order). Otherwise POST a new one.
existing = await self._existing_destination()
if existing is not None:
self.config.state.destination[self.resource_type][_id] = existing
return await self.update_resource(_id, resource)

destination_client = self.config.destination_client
payload = {
"data": {
"type": self.resource_type,
"attributes": {"enforced_application_tags": resource["attributes"]["enforced_application_tags"]},
}
}
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]:
destination_client = self.config.destination_client
payload = {
"data": {
"type": self.resource_type,
"attributes": {"enforced_application_tags": resource["attributes"]["enforced_application_tags"]},
}
}
resp = await destination_client.patch(self.resource_config.base_path, payload)
return _id, resp["data"]

async def delete_resource(self, _id: str) -> None:
self.config.logger.warning("rum_config cannot be deleted. Removing resource from state only.")
1 change: 1 addition & 0 deletions datadog_sync/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from datadog_sync.model.restriction_policies import RestrictionPolicies
from datadog_sync.model.roles import Roles
from datadog_sync.model.rum_applications import RUMApplications
from datadog_sync.model.rum_config import RUMConfig
from datadog_sync.model.rum_metrics import RUMMetrics
from datadog_sync.model.rum_operations import RUMOperations
from datadog_sync.model.rum_operation_strong_links import RUMOperationStrongLinks
Expand Down
152 changes: 152 additions & 0 deletions tests/unit/test_rum_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# 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 RUMConfig resource model.

RUM config is a singleton org setting (no DELETE, no per-id path). Only
``enforced_application_tags`` is configurable; all other attributes are
server-managed and excluded from diffs. ``create_resource`` checks whether the
destination singleton already exists and delegates to ``update_resource`` if so
(mirroring ``logs_archives_order``); ``delete_resource`` is a no-op.
"""

import asyncio
from collections import defaultdict
from unittest.mock import AsyncMock, MagicMock

import pytest

from datadog_sync.model.rum_config import RUMConfig


def _run(coro):
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()


def _cfg(enforced=True):
return {
"id": "rum-config",
"type": "rum_config",
"attributes": {
"enforced_application_tags": enforced,
"disabled": False,
"retention_filters_enabled": True,
"ootb_metrics_version": 2,
},
}


def test_get_resources_returns_single_element_list():
cfg = RUMConfig(MagicMock())
client = AsyncMock()
client.get = AsyncMock(return_value={"data": _cfg(True)})

resources = _run(cfg.get_resources(client))

assert len(resources) == 1
assert resources[0]["attributes"]["enforced_application_tags"] is True
client.get.assert_awaited_once_with("/api/v2/rum/config")


def test_import_resource_returns_default_id():
cfg = RUMConfig(MagicMock())
cfg.config.source_client = AsyncMock()
resource = _cfg(True)
_id, data = _run(cfg.import_resource(resource=resource))
assert _id == "rum-config"
assert data is resource


def test_create_resource_posts_when_destination_absent():
"""When the destination singleton returns 404, create POSTs a new one."""
from datadog_sync.utils.resource_utils import CustomClientHTTPError

cfg = RUMConfig(MagicMock())
dest = AsyncMock()
resp = MagicMock()
resp.status = 404
resp.message = "Not Found"
dest.get = AsyncMock(side_effect=CustomClientHTTPError(resp, message="not found"))
dest.post = AsyncMock(return_value={"data": _cfg(True)})
cfg.config.destination_client = dest

resource = _cfg(True)
_id, data = _run(cfg.create_resource("rum-config", resource))

assert _id == "rum-config"
dest.post.assert_awaited_once()
post_url, post_payload = dest.post.await_args.args
assert post_url == "/api/v2/rum/config"
# only enforced_application_tags is sent on create
assert post_payload == {"data": {"type": "rum_config", "attributes": {"enforced_application_tags": True}}}


def test_create_resource_reraises_on_non_404_error():
"""Transient/auth errors (500, 403, etc.) must propagate, not be swallowed
as 'singleton absent'."""
from datadog_sync.utils.resource_utils import CustomClientHTTPError

cfg = RUMConfig(MagicMock())
dest = AsyncMock()
resp = MagicMock()
resp.status = 500
resp.message = "Internal Server Error"
dest.get = AsyncMock(side_effect=CustomClientHTTPError(resp, message="server error"))
dest.post = AsyncMock()
cfg.config.destination_client = dest

resource = _cfg(True)
with pytest.raises(CustomClientHTTPError):
_run(cfg.create_resource("rum-config", resource))
dest.post.assert_not_awaited()


def test_create_resource_delegates_to_update_when_destination_exists():
cfg = RUMConfig(MagicMock())
dest = AsyncMock()
dest.get = AsyncMock(return_value={"data": _cfg(False)})
dest.patch = AsyncMock(return_value={"data": _cfg(True)})
dest.post = AsyncMock()
cfg.config.destination_client = dest
cfg.config.state = MagicMock()
cfg.config.state.destination = defaultdict(dict)

resource = _cfg(True)
_id, data = _run(cfg.create_resource("rum-config", resource))

assert _id == "rum-config"
dest.post.assert_not_awaited()
dest.patch.assert_awaited_once()
# state.destination hydrated with the existing dest config so update can proceed
assert cfg.config.state.destination["rum_config"]["rum-config"]["attributes"]["enforced_application_tags"] is False


def test_update_resource_patches_enforced_application_tags_only():
cfg = RUMConfig(MagicMock())
dest = AsyncMock()
dest.patch = AsyncMock(return_value={"data": _cfg(True)})
cfg.config.destination_client = dest

resource = _cfg(True)
_id, data = _run(cfg.update_resource("rum-config", resource))

assert _id == "rum-config"
dest.patch.assert_awaited_once()
patch_url, patch_payload = dest.patch.await_args.args
assert patch_url == "/api/v2/rum/config"
assert patch_payload == {"data": {"type": "rum_config", "attributes": {"enforced_application_tags": True}}}


def test_delete_resource_is_noop():
cfg = RUMConfig(MagicMock())
cfg.config.destination_client = AsyncMock()
cfg.config.logger = MagicMock()
_run(cfg.delete_resource("rum-config"))
cfg.config.destination_client.delete.assert_not_awaited()
Loading