diff --git a/README.md b/README.md index 9c4d8f9..775b77e 100644 --- a/README.md +++ b/README.md @@ -259,18 +259,18 @@ asyncio.run(main()) ActiveGates - Auto-update jobs | :heavy_check_mark: | `dt.activegates_autoupdate_jobs` | ActiveGates - Remote configuration | :heavy_check_mark: | `dt.activegates_remote_configuration` | Audit Logs | :heavy_check_mark: | `dt.audit_logs` | - Events | :warning: | `dt.events_v2` | + Events | :heavy_check_mark: | `dt.events_v2` | Extensions 2.0 | :heavy_check_mark: | `dt.extensions_v2` | - Logs | :warning: | `dt.logs` | + Logs | :heavy_check_mark: | `dt.logs` | Metrics | :heavy_check_mark: | `dt.metrics` | - Monitored entities | :warning: | `dt.entities` | + Monitored entities | :heavy_check_mark: | `dt.entities` | Monitored entities - Custom tags | :heavy_check_mark: | `dt.custom_tags` | - Network zones | :warning: | `dt.network_zones` | + Network zones | :heavy_check_mark: | `dt.network_zones` | OneAgents - Remote configuration | :heavy_check_mark: | `dt.oneagents_remote_configuration` | Problems | :heavy_check_mark: | `dt.problems` | Security problems | :heavy_check_mark: | `dt.security_problems` | Service-level objectives | :heavy_check_mark: | `dt.slos` | - Settings | :warning: | `dt.settings` | + Settings | :heavy_check_mark: | `dt.settings` | Settings - Management zones | :heavy_check_mark: | `dt.management_zones_v2` | Synthetic - Monitors | :heavy_check_mark: | `dt.synthetic_monitors_v2` | Credential vault | :heavy_check_mark: | `dt.credentials` | diff --git a/dynatrace/environment_v2/events.py b/dynatrace/environment_v2/events.py index 4f6f6ac..46e2a73 100644 --- a/dynatrace/environment_v2/events.py +++ b/dynatrace/environment_v2/events.py @@ -34,6 +34,7 @@ class EventServiceV2: ENDPOINT_EVENTS = "/api/v2/events" ENDPOINT_INGEST = "/api/v2/events/ingest" + ENDPOINT_PROPERTIES = "/api/v2/eventProperties" ENDPOINT_TYPES = "/api/v2/eventTypes" def __init__(self, http_client: HttpClient): @@ -90,6 +91,38 @@ async def get(self, event_id: str) -> "Event": ) return Event(raw_element=response.json(), http_client=self.__http_client) + async def list_properties( + self, page_size: int | None = None + ) -> "PaginatedList[EventPropertyDetails]": + """Lists all event properties. + + :param page_size: The amount of event properties in a single response payload. The maximal allowed page size is 500. If not set, 100 is used. + + :returns PaginatedList[EventPropertyDetails]: the list of event properties + """ + params = {"pageSize": page_size} + return await PaginatedList( + target_class=EventPropertyDetails, + http_client=self.__http_client, + target_url=self.ENDPOINT_PROPERTIES, + list_item="eventProperties", + target_params=params, + ).initialize() + + async def get_property(self, property_key: str) -> "EventPropertyDetails": + """Gets the details of an event property. + + :param property_key: The event property key you're inquiring. + + :returns EventPropertyDetails: the event property requested + """ + response = await self.__http_client.make_request( + path=f"{self.ENDPOINT_PROPERTIES}/{property_key}" + ) + return EventPropertyDetails( + raw_element=response.json(), http_client=self.__http_client + ) + async def list_types( self, page_size: int | None = None ) -> "PaginatedList[EventType]": @@ -234,6 +267,24 @@ def to_json(self) -> dict[str, Any]: return {"key": self.key, "value": self.value} +class EventPropertyDetails(DynatraceObject): + def _create_from_raw_data(self, raw_element: dict[str, Any]): + self.key: str = raw_element["key"] + self.display_name: str | None = raw_element.get("displayName") + self.filterable: bool | None = raw_element.get("filterable") + self.description: str | None = raw_element.get("description") + self.writable: bool | None = raw_element.get("writable") + + def to_json(self) -> dict[str, Any]: + return { + "key": self.key, + "displayName": self.display_name, + "filterable": self.filterable, + "description": self.description, + "writable": self.writable, + } + + class EventType(DynatraceObject): def _create_from_raw_data(self, raw_element: dict[str, Any]): self.type: str = raw_element["type"] diff --git a/dynatrace/environment_v2/logs.py b/dynatrace/environment_v2/logs.py index 1a3656e..0303f93 100644 --- a/dynatrace/environment_v2/logs.py +++ b/dynatrace/environment_v2/logs.py @@ -27,8 +27,10 @@ class LogService: - # TODO - Add search and aggregate ENDPOINT = "/api/v2/logs" + ENDPOINT_EXPORT = "/api/v2/logs/export" + ENDPOINT_SEARCH = "/api/v2/logs/search" + ENDPOINT_AGGREGATE = "/api/v2/logs/aggregate" def __init__(self, http_client: HttpClient): self.__http_client = http_client @@ -60,11 +62,76 @@ async def export( return await PaginatedList( LogRecord, self.__http_client, - "/api/v2/logs/export", + self.ENDPOINT_EXPORT, params, list_item="results", ).initialize() + async def search( + self, + query: str | None = None, + time_from: datetime | str | None = None, + time_to: datetime | str | None = None, + sort: str | None = None, + limit: int | None = None, + ) -> list["LogRecord"]: + """Reads log records using the deprecated search endpoint and fetches all slices.""" + params = { + "from": timestamp_to_string(time_from), + "to": timestamp_to_string(time_to), + "query": query, + "sort": sort, + "limit": limit, + } + records: list[LogRecord] = [] + request_params = { + key: value for key, value in params.items() if value is not None + } + + while True: + response = await self.__http_client.make_request( + self.ENDPOINT_SEARCH, params=request_params + ) + payload = response.json() + records.extend( + LogRecord(raw_element=record, http_client=self.__http_client) + for record in payload.get("results", []) + ) + next_slice_key = payload.get("nextSliceKey") + if not next_slice_key: + break + request_params = {"nextSliceKey": next_slice_key} + + return records + + async def aggregate( + self, + query: str | None = None, + time_from: datetime | str | None = None, + time_to: datetime | str | None = None, + time_buckets: int | None = None, + max_group_values: int | None = None, + group_by: list[str] | None = None, + ) -> "AggregatedLog": + """Gets aggregated log records from the deprecated aggregate endpoint.""" + params = { + "from": timestamp_to_string(time_from), + "to": timestamp_to_string(time_to), + "query": query, + "timeBuckets": time_buckets, + "maxGroupValues": max_group_values, + "groupBy": group_by, + } + request_params = { + key: value for key, value in params.items() if value is not None + } + response = await self.__http_client.make_request( + self.ENDPOINT_AGGREGATE, params=request_params + ) + return AggregatedLog( + raw_element=response.json(), http_client=self.__http_client + ) + async def ingest(self, payload: dict[str, Any] | list[dict[str, Any]]) -> Response: """ Ingests logs into the Dynatrace log store. @@ -89,6 +156,20 @@ def _create_from_raw_data(self, raw_element: dict[str, Any]): self.status: LogRecordStatus = LogRecordStatus(raw_element.get("status")) +class AggregatedLog(DynatraceObject): + def _create_from_raw_data(self, raw_element: dict[str, Any]): + self.aggregation_result: dict[str, Any] | None = raw_element.get( + "aggregationResult" + ) + self.warnings: str | None = raw_element.get("warnings") + + def to_json(self) -> dict[str, Any]: + return { + "aggregationResult": self.aggregation_result, + "warnings": self.warnings, + } + + class EventType(Enum): K8S = "K8S" LOG = "LOG" diff --git a/dynatrace/environment_v2/monitored_entities.py b/dynatrace/environment_v2/monitored_entities.py index fc8b97a..e8d70b8 100644 --- a/dynatrace/environment_v2/monitored_entities.py +++ b/dynatrace/environment_v2/monitored_entities.py @@ -32,6 +32,7 @@ class EntityService: ENDPOINT_ENTITIES = "/api/v2/entities" ENDPOINT_TYPES = "/api/v2/entityTypes" + ENDPOINT_SECURITY_CONTEXT = "/api/v2/entities/securityContext" def __init__(self, http_client: HttpClient): self.__http_client = http_client @@ -119,6 +120,50 @@ async def post_custom_device(self, device: "CustomDeviceCreation") -> "Response" """ return await device.post() + async def set_security_context( + self, + entity_selector: str, + security_context: builtins.list[str], + time_from: datetime | str | None = None, + time_to: datetime | str | None = None, + ) -> "SecurityContextResult": + """Sets the given security context for all entities matching the entity selector.""" + params = { + "entitySelector": entity_selector, + "from": timestamp_to_string(time_from), + "to": timestamp_to_string(time_to), + } + response = await self.__http_client.make_request( + self.ENDPOINT_SECURITY_CONTEXT, + method="POST", + params={"securityContext": security_context}, + query_params=params, + ) + return SecurityContextResult( + raw_element=response.json(), http_client=self.__http_client + ) + + async def delete_security_context( + self, + entity_selector: str, + time_from: datetime | str | None = None, + time_to: datetime | str | None = None, + ) -> "SecurityContextResult": + """Deletes the security context for all entities matching the entity selector.""" + params = { + "entitySelector": entity_selector, + "from": timestamp_to_string(time_from), + "to": timestamp_to_string(time_to), + } + response = await self.__http_client.make_request( + self.ENDPOINT_SECURITY_CONTEXT, + method="DELETE", + params=params, + ) + return SecurityContextResult( + raw_element=response.json(), http_client=self.__http_client + ) + def create_custom_device( self, custom_device_id: str, @@ -312,11 +357,12 @@ def to_json(self): body["messageType"] = str(self.message_type) return body - async def post(self) -> "Response": + async def post(self, ui_based: bool | None = None) -> "Response": return await self._http_client.make_request( path=f"{EntityService.ENDPOINT_ENTITIES}/custom", method="POST", params=self.to_json(), + query_params={"uiBased": ui_based}, ) @@ -341,6 +387,20 @@ def _create_from_raw_data(self, raw_element: dict): self.tags: str | None = raw_element.get("tags") +class SecurityContextResult(DynatraceObject): + def _create_from_raw_data(self, raw_element: dict[str, Any]): + self.entity_ids: list[str] | None = raw_element.get("entityIds", []) + self.management_zone_ids: list[int] | None = raw_element.get( + "managementZoneIds" + ) + + def to_json(self) -> dict[str, Any]: + return { + "entityIds": self.entity_ids, + "managementZoneIds": self.management_zone_ids, + } + + class EntityTypePropertyDto(DynatraceObject): def _create_from_raw_data(self, raw_element: dict[str, Any]): self.id: str = raw_element["id"] diff --git a/dynatrace/environment_v2/networkzones.py b/dynatrace/environment_v2/networkzones.py index dfe5194..0620aa6 100644 --- a/dynatrace/environment_v2/networkzones.py +++ b/dynatrace/environment_v2/networkzones.py @@ -25,6 +25,7 @@ class NetworkZoneService: ENDPOINT = "/api/v2/networkZones" ENDPOINT_GLOBALSETTINGS = "/api/v2/networkZoneSettings" + ENDPOINT_HOST_CONNECTION_STATISTICS = "/api/v2/networkZones" def __init__(self, http_client: HttpClient): self.__http_client = http_client @@ -52,6 +53,19 @@ async def get(self, networkzone_id: str): ).json() return NetworkZone(raw_element=response) + async def get_host_statistics( + self, networkzone_id: str, filter: str | None = None + ) -> "NetworkZoneConnectionStatistics": + """Gets the statistics about hosts using the network zone.""" + params = {"filter": filter} + response = ( + await self.__http_client.make_request( + f"{self.ENDPOINT}/{networkzone_id}/hostConnectionStatistics", + params=params, + ) + ).json() + return NetworkZoneConnectionStatistics(raw_element=response) + async def update( self, networkzone_id: str, @@ -80,7 +94,7 @@ async def delete(self, networkzone_id: str): path=f"{self.ENDPOINT}/{networkzone_id}", method="DELETE" ) - async def getGlobalConfig(self): + async def get_global_config(self) -> "NetworkZoneSettings": """Gets the global configuration of network zones. No params :return: Network Zone Global Settings object """ @@ -89,7 +103,7 @@ async def getGlobalConfig(self): ).json() return NetworkZoneSettings(raw_element=response) - async def updateGlobalConfig(self, configuration: bool): + async def update_global_config(self, configuration: bool): """Updates the global configuration of network zones. :param configuration: boolean setting to enable/disable NZs @@ -100,6 +114,12 @@ async def updateGlobalConfig(self, configuration: bool): path=self.ENDPOINT_GLOBALSETTINGS, method="PUT", params=params ) + async def getGlobalConfig(self): + return await self.get_global_config() + + async def updateGlobalConfig(self, configuration: bool): + return await self.update_global_config(configuration) + class NetworkZone(DynatraceObject): def _create_from_raw_data(self, raw_element: dict[str, Any]): @@ -119,3 +139,27 @@ def _create_from_raw_data(self, raw_element: dict[str, Any]): class NetworkZoneSettings(DynatraceObject): def _create_from_raw_data(self, raw_element: dict[str, bool]): self.network_zones_enabled: bool = raw_element.get("networkZonesEnabled") + + +class NetworkZoneConnectionStatistics(DynatraceObject): + def _create_from_raw_data(self, raw_element: dict[str, Any]): + self.hosts_configured_but_not_connected: list[str] = raw_element.get( + "hostsConfiguredButNotConnected", [] + ) + self.hosts_connected_as_alternative: list[str] = raw_element.get( + "hostsConnectedAsAlternative", [] + ) + self.hosts_connected_as_failover: list[str] = raw_element.get( + "hostsConnectedAsFailover", [] + ) + self.hosts_connected_as_failover_without_active_gates: list[str] = ( + raw_element.get("hostsConnectedAsFailoverWithoutActiveGates", []) + ) + + def to_json(self) -> dict[str, Any]: + return { + "hostsConfiguredButNotConnected": self.hosts_configured_but_not_connected, + "hostsConnectedAsAlternative": self.hosts_connected_as_alternative, + "hostsConnectedAsFailover": self.hosts_connected_as_failover, + "hostsConnectedAsFailoverWithoutActiveGates": self.hosts_connected_as_failover_without_active_gates, + } diff --git a/dynatrace/environment_v2/settings.py b/dynatrace/environment_v2/settings.py index 55e2856..3e5dc74 100644 --- a/dynatrace/environment_v2/settings.py +++ b/dynatrace/environment_v2/settings.py @@ -12,11 +12,14 @@ class SettingService: OBJECTS_ENDPOINT = "/api/v2/settings/objects" SCHEMAS_ENDPOINT = "/api/v2/settings/schemas" + EFFECTIVE_VALUES_ENDPOINT = "/api/v2/settings/effectiveValues" def __init__(self, http_client: HttpClient): self.__http_client = http_client - async def list_schemas(self) -> PaginatedList[SchemaStub]: + async def list_schemas( + self, fields: str | None = None + ) -> PaginatedList[SchemaStub]: """Lists all settings schemas available in your environment""" return await PaginatedList( @@ -24,6 +27,7 @@ async def list_schemas(self) -> PaginatedList[SchemaStub]: self.__http_client, target_url=self.SCHEMAS_ENDPOINT, list_item="items", + target_params={"fields": fields}, ).initialize() async def list_objects( @@ -35,6 +39,7 @@ async def list_objects( filter: str | None = None, sort: str | None = None, page_size: str | None = None, + admin_access: bool | None = None, ) -> PaginatedList[SettingsObject]: """Lists settings @@ -48,6 +53,7 @@ async def list_objects( "filter": filter, "sort": sort, "pageSize": page_size, + "adminAccess": admin_access, } return await PaginatedList( SettingsObject, @@ -57,10 +63,36 @@ async def list_objects( target_params=params, ).initialize() + async def list_effective_values( + self, + scope: str, + schema_ids: str | None = None, + fields: str | None = None, + page_size: int | None = None, + admin_access: bool | None = None, + ) -> PaginatedList[EffectiveSettingsValue]: + """Lists effective settings values for selected schemas at a selected scope""" + + params = { + "schemaIds": schema_ids, + "scope": scope, + "fields": fields, + "pageSize": page_size, + "adminAccess": admin_access, + } + return await PaginatedList( + EffectiveSettingsValue, + self.__http_client, + target_url=self.EFFECTIVE_VALUES_ENDPOINT, + list_item="items", + target_params=params, + ).initialize() + async def create_object( self, validate_only: bool | None = False, body: list[SettingsObjectCreate] | SettingsObjectCreate | None = None, + admin_access: bool | None = None, ): """ Creates a new settings object or validates the provided settigns object @@ -68,7 +100,7 @@ async def create_object( :param validate_only: If true, the request runs only validation of the submitted settings objects, without saving them :param body: The JSON body of the request. Contains the settings objects """ - query_params = {"validateOnly": validate_only} + query_params = {"validateOnly": validate_only, "adminAccess": admin_access} if isinstance(body, SettingsObjectCreate): body = [body] @@ -85,39 +117,53 @@ async def create_object( ).json() return response - async def get_object(self, object_id: str): + async def get_object(self, object_id: str, admin_access: bool | None = None): """Gets parameters of specified settings object :param object_id: the ID of the object :return: a Settings object """ + query_params = {"adminAccess": admin_access} response = ( await self.__http_client.make_request( - f"{self.OBJECTS_ENDPOINT}/{object_id}" + f"{self.OBJECTS_ENDPOINT}/{object_id}", query_params=query_params ) ).json() return SettingsObject(raw_element=response) async def update_object( - self, object_id: str, body: SettingsObjectUpdate | None = None + self, + object_id: str, + body: SettingsObjectUpdate | None = None, + validate_only: bool | None = None, + admin_access: bool | None = None, ): """Updates an existing settings object :param object_id: the ID of the object :param value: the JSON body of the request. Contains updated parameters of the settings object. """ + query_params = {"validateOnly": validate_only, "adminAccess": admin_access} return await self.__http_client.make_request( - f"{self.OBJECTS_ENDPOINT}/{object_id}", params=body.json(), method="PUT" + f"{self.OBJECTS_ENDPOINT}/{object_id}", + params=body.json(), + method="PUT", + query_params=query_params, ) - async def delete_object(self, object_id: str, update_token: str | None = None): + async def delete_object( + self, + object_id: str, + update_token: str | None = None, + admin_access: bool | None = None, + ): """Deletes the specified object :param object_id: the ID of the object :param update_token: The update token of the object. You can use it to detect simultaneous modifications by different users :return: HTTP response """ - query_params = {"updateToken": update_token} + query_params = {"updateToken": update_token, "adminAccess": admin_access} return await self.__http_client.make_request( f"{self.OBJECTS_ENDPOINT}/{object_id}", method="DELETE", @@ -235,3 +281,27 @@ def _create_from_raw_data(self, raw_element: dict[str, Any]): self.display_name = raw_element["displayName"] self.latest_schema_version = raw_element["latestSchemaVersion"] self.schema_id = raw_element["schemaId"] + + +class EffectiveSettingsValue(DynatraceObject): + def _create_from_raw_data(self, raw_element: dict[str, Any]): + self.author: str | None = raw_element.get("author") + self.created: datetime | None = ( + int64_to_datetime(int(raw_element.get("created"))) + if raw_element.get("created") + else None + ) + self.created_by: str | None = raw_element.get("createdBy") + self.external_id: str | None = raw_element.get("externalId") + self.modified: datetime | None = ( + int64_to_datetime(int(raw_element.get("modified"))) + if raw_element.get("modified") + else None + ) + self.modified_by: str | None = raw_element.get("modifiedBy") + self.origin: str | None = raw_element.get("origin") + self.schema_id: str | None = raw_element.get("schemaId") + self.schema_version: str | None = raw_element.get("schemaVersion") + self.search_summary: str | None = raw_element.get("searchSummary") + self.summary: str | None = raw_element.get("summary") + self.value: Any | None = raw_element.get("value") diff --git a/pyproject.toml b/pyproject.toml index 55d9810..2b0ee19 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "dt-async" -version = "1.0.11" +version = "1.0.12" description = "Dynatrace async API Python client" readme = "README.md" authors = ["David Lopes "] diff --git a/test/environment_v2/test_entities.py b/test/environment_v2/test_entities.py index d3130f6..cd4945f 100644 --- a/test/environment_v2/test_entities.py +++ b/test/environment_v2/test_entities.py @@ -10,12 +10,13 @@ EntityTypePropertyDto, FromPosition, MessageType, + SecurityContextResult, ToPosition, ) from dynatrace.environment_v2.schemas import ManagementZone from dynatrace.pagination import PaginatedList from dynatrace.utils import int64_to_datetime -from test.async_utils import collect +from test.async_utils import MockResponse, collect async def test_list(dt: DynatraceAsync): @@ -175,3 +176,51 @@ async def test_create_custom_device(dt: DynatraceAsync): assert device.dns_names[0] == "testdevice.testnet.net" assert device.properties["this"] == "that" assert device.message_type == MessageType.CUSTOM_DEVICE + + +async def test_security_context(dt: DynatraceAsync, monkeypatch): + calls = [] + + async def fake_make_request( + path, + params=None, + headers=None, + method="GET", + data=None, + files=None, + query_params=None, + **kwargs, + ): + calls.append( + { + "path": path, + "params": params, + "method": method, + "query_params": query_params, + } + ) + if method == "POST": + return MockResponse({"entityIds": ["HOST-1"], "managementZoneIds": [7]}) + return MockResponse({"entityIds": ["HOST-2"], "managementZoneIds": []}) + + monkeypatch.setattr( + dt._DynatraceAsync__http_client, "make_request", fake_make_request + ) + + set_result = await dt.entities.set_security_context( + 'type("HOST")', ["Production"], time_from="now-1h" + ) + delete_result = await dt.entities.delete_security_context('type("HOST")') + + assert isinstance(set_result, SecurityContextResult) + assert set_result.entity_ids == ["HOST-1"] + assert set_result.management_zone_ids == [7] + assert isinstance(delete_result, SecurityContextResult) + assert delete_result.entity_ids == ["HOST-2"] + + assert calls[0]["path"] == "/api/v2/entities/securityContext" + assert calls[0]["method"] == "POST" + assert calls[0]["params"] == {"securityContext": ["Production"]} + assert calls[0]["query_params"]["entitySelector"] == 'type("HOST")' + assert calls[1]["path"] == "/api/v2/entities/securityContext" + assert calls[1]["method"] == "DELETE" diff --git a/test/environment_v2/test_events_v2.py b/test/environment_v2/test_events_v2.py index acd8503..a7efdd5 100644 --- a/test/environment_v2/test_events_v2.py +++ b/test/environment_v2/test_events_v2.py @@ -5,6 +5,7 @@ from dynatrace.environment_v2.events import ( Event, EventProperty, + EventPropertyDetails, EventSeverity, EventStatus, EventType, @@ -72,6 +73,39 @@ async def test_get(dt: DynatraceAsync): assert not event.frequent_event +async def test_list_properties(dt: DynatraceAsync): + event_properties = await dt.events_v2.list_properties() + + # type checks + assert isinstance(event_properties, PaginatedList) + property_list = await collect(event_properties) + assert all( + isinstance(event_property, EventPropertyDetails) + for event_property in property_list + ) + + # value checks + assert len(property_list) == 2 + assert property_list[0].key == "dt.event.description" + + +async def test_get_property(dt: DynatraceAsync): + property_details = await dt.events_v2.get_property("dt.event.description") + + # type checks + assert isinstance(property_details, EventPropertyDetails) + assert isinstance(property_details.key, str) + assert isinstance(property_details.display_name, str) + assert isinstance(property_details.filterable, bool) + assert isinstance(property_details.writable, bool) + + # value checks + assert property_details.key == "dt.event.description" + assert property_details.display_name == "Custom description" + assert property_details.filterable is True + assert property_details.writable is True + + async def test_list_types(dt: DynatraceAsync): event_types = await dt.events_v2.list_types() diff --git a/test/environment_v2/test_networkzones.py b/test/environment_v2/test_networkzones.py index 969898a..f2eb782 100644 --- a/test/environment_v2/test_networkzones.py +++ b/test/environment_v2/test_networkzones.py @@ -1,7 +1,8 @@ import dynatrace.environment_v2.networkzones as nz from dynatrace import DynatraceAsync +from dynatrace.http_client import HttpClient from dynatrace.pagination import PaginatedList -from test.async_utils import collect +from test.async_utils import MockResponse, collect NETWORKZONE_ID = "default" @@ -33,3 +34,61 @@ async def test_get(dt: DynatraceAsync): assert network_zone.num_oneagents_configured == 141 assert network_zone.num_oneagents_using == 141 assert network_zone.num_oneagents_from_other_zones == 0 + + +async def test_get_global_config(dt: DynatraceAsync, monkeypatch): + async def make_request( + self, + path: str, + params: dict | None = None, + headers: dict | None = None, + method="GET", + data=None, + query_params=None, + **kwargs, + ): + assert path == "/api/v2/networkZoneSettings" + return MockResponse({"networkZonesEnabled": True}) + + monkeypatch.setattr(HttpClient, "make_request", make_request) + + settings = await dt.network_zones.get_global_config() + + assert isinstance(settings, nz.NetworkZoneSettings) + assert settings.network_zones_enabled is True + + +async def test_get_host_statistics(dt: DynatraceAsync, monkeypatch): + async def make_request( + self, + path: str, + params: dict | None = None, + headers: dict | None = None, + method="GET", + data=None, + query_params=None, + **kwargs, + ): + assert path == "/api/v2/networkZones/default/hostConnectionStatistics" + assert params == {"filter": "all"} + return MockResponse( + { + "hostsConfiguredButNotConnected": ["host-1"], + "hostsConnectedAsAlternative": ["host-2"], + "hostsConnectedAsFailover": ["host-3"], + "hostsConnectedAsFailoverWithoutActiveGates": ["host-4"], + } + ) + + monkeypatch.setattr(HttpClient, "make_request", make_request) + + statistics = await dt.network_zones.get_host_statistics( + networkzone_id=NETWORKZONE_ID, + filter="all", + ) + + assert isinstance(statistics, nz.NetworkZoneConnectionStatistics) + assert statistics.hosts_configured_but_not_connected == ["host-1"] + assert statistics.hosts_connected_as_alternative == ["host-2"] + assert statistics.hosts_connected_as_failover == ["host-3"] + assert statistics.hosts_connected_as_failover_without_active_gates == ["host-4"] diff --git a/test/environment_v2/test_settings.py b/test/environment_v2/test_settings.py index c2b426a..15c2a2f 100644 --- a/test/environment_v2/test_settings.py +++ b/test/environment_v2/test_settings.py @@ -1,11 +1,13 @@ from dynatrace import DynatraceAsync from dynatrace.environment_v2.settings import ( + EffectiveSettingsValue, SchemaStub, SettingsObject, SettingsObjectCreate, ) +from dynatrace.http_client import HttpClient from dynatrace.pagination import PaginatedList -from test.async_utils import collect +from test.async_utils import MockResponse, collect settings_dict = { "enabled": True, @@ -62,6 +64,54 @@ async def test_list_objects(dt: DynatraceAsync): assert all(isinstance(s, SettingsObject) for s in settings_list) +async def test_list_effective_values(dt: DynatraceAsync, monkeypatch): + async def make_request( + self, + path: str, + params: dict | None = None, + headers: dict | None = None, + method="GET", + data=None, + query_params=None, + **kwargs, + ): + assert path == "/api/v2/settings/effectiveValues" + return MockResponse( + { + "items": [ + { + "author": "Alice", + "created": 1710000000000, + "createdBy": "user-1", + "externalId": "external-1", + "modified": 1710001000000, + "modifiedBy": "user-2", + "origin": "USER", + "schemaId": "builtin:anomaly-detection.metric-events", + "schemaVersion": "1.0.0", + "searchSummary": "summary", + "summary": "effective value", + "value": {"enabled": True}, + } + ], + "totalCount": 1, + } + ) + + monkeypatch.setattr(HttpClient, "make_request", make_request) + + effective_values = await dt.settings.list_effective_values(scope="environment") + + assert isinstance(effective_values, PaginatedList) + effective_values_list = await collect(effective_values) + assert len(effective_values_list) == 1 + assert all(isinstance(v, EffectiveSettingsValue) for v in effective_values_list) + assert ( + effective_values_list[0].schema_id == "builtin:anomaly-detection.metric-events" + ) + assert effective_values_list[0].value == {"enabled": True} + + async def test_get_object(dt: DynatraceAsync): setting = await dt.settings.get_object(object_id=test_object_id) assert isinstance(setting, SettingsObject) diff --git a/test/mock_data/GET_api_v2_eventProperties.json b/test/mock_data/GET_api_v2_eventProperties.json new file mode 100644 index 0000000..009ef22 --- /dev/null +++ b/test/mock_data/GET_api_v2_eventProperties.json @@ -0,0 +1,19 @@ +{ + "totalCount": 2, + "eventProperties": [ + { + "key": "dt.event.description", + "displayName": "Custom description", + "filterable": true, + "description": "A short description of the event property.", + "writable": true + }, + { + "key": "dt.event.source", + "displayName": "Custom source", + "filterable": true, + "description": "The source of the event.", + "writable": true + } + ] +} diff --git a/test/mock_data/GET_api_v2_eventProperties_dt_event_description.json b/test/mock_data/GET_api_v2_eventProperties_dt_event_description.json new file mode 100644 index 0000000..2c5e48b --- /dev/null +++ b/test/mock_data/GET_api_v2_eventProperties_dt_event_description.json @@ -0,0 +1,7 @@ +{ + "key": "dt.event.description", + "displayName": "Custom description", + "filterable": true, + "description": "A short description of the event property.", + "writable": true +} diff --git a/test/test_logs.py b/test/test_logs.py index 6076bcb..13ed12c 100644 --- a/test/test_logs.py +++ b/test/test_logs.py @@ -1,9 +1,14 @@ from datetime import datetime, timezone from dynatrace import DynatraceAsync -from dynatrace.environment_v2.logs import EventType, LogRecord, LogRecordStatus +from dynatrace.environment_v2.logs import ( + AggregatedLog, + EventType, + LogRecord, + LogRecordStatus, +) from dynatrace.pagination import PaginatedList -from test.async_utils import collect +from test.async_utils import MockResponse, collect async def test_export(dt: DynatraceAsync): @@ -20,3 +25,95 @@ async def test_export(dt: DynatraceAsync): assert first.event_type == EventType.SFM assert first.status == LogRecordStatus.ERROR assert first.timestamp == datetime.fromtimestamp(1683574915193 / 1000, timezone.utc) + + +async def test_search(dt: DynatraceAsync, monkeypatch): + calls = [] + responses = iter( + [ + MockResponse( + { + "results": [ + { + "timestamp": 1683574915193, + "content": "first slice", + "eventType": "SFM", + "status": "ERROR", + "additionalColumns": {}, + } + ], + "nextSliceKey": "slice-2", + } + ), + MockResponse( + { + "results": [ + { + "timestamp": 1683574915194, + "content": "second slice", + "eventType": "LOG", + "status": "INFO", + "additionalColumns": {}, + } + ] + } + ), + ] + ) + + async def fake_make_request( + path, + params=None, + headers=None, + method="GET", + data=None, + files=None, + query_params=None, + **kwargs, + ): + calls.append({"path": path, "params": params, "method": method}) + return next(responses) + + monkeypatch.setattr( + dt._DynatraceAsync__http_client, "make_request", fake_make_request + ) + + records = await dt.logs.search(query='content:"slice"', limit=1) + + assert len(records) == 2 + assert isinstance(records[0], LogRecord) + assert records[0].content == "first slice" + assert records[1].content == "second slice" + assert calls[0]["path"] == "/api/v2/logs/search" + assert calls[0]["params"]["limit"] == 1 + assert calls[1]["params"] == {"nextSliceKey": "slice-2"} + + +async def test_aggregate(dt: DynatraceAsync, monkeypatch): + async def fake_make_request( + path, + params=None, + headers=None, + method="GET", + data=None, + files=None, + query_params=None, + **kwargs, + ): + return MockResponse( + { + "aggregationResult": { + "logLevel": {"1683574915": {"ERROR": 3, "INFO": 1}} + }, + "warnings": "", + } + ) + + monkeypatch.setattr( + dt._DynatraceAsync__http_client, "make_request", fake_make_request + ) + + aggregate = await dt.logs.aggregate(query='content:"slice"', time_buckets=1) + + assert isinstance(aggregate, AggregatedLog) + assert aggregate.aggregation_result["logLevel"]["1683574915"]["ERROR"] == 3