From 5e120f64fd4f9dde829551df2ce3e829fee2a301 Mon Sep 17 00:00:00 2001 From: sjer-akamai Date: Tue, 26 May 2026 18:56:54 +0200 Subject: [PATCH 1/4] Aclp logs support (#686) * Add destination API models for monitor objects * Add destination API support in monitor group * Destination.history client call fix * `access_key_secret` parameter name fixes * Add unit tests * Rename Destination to LogsDestination * Add integration tests * Typo fix * Integration tests tweaks * Documentation tweaks * Add negative integration test cases * Fix assertion * Formatting tweaks * ACLP Logs stream - add model and group * ACLP Logs Stream - unit tests * ACLP Logs stream - add integration tests * ACLP Logs Stream - ensure update reverts on failed assertions * ACLP Logs Stream - Formatting tweaks * ACLP Logs Stream - copilot review tweaks * ACLP Logs Stream - review tweaks - pt. 2 * ACLP Logs Stream - review tweaks - pt. 3 * ACLP Logs Stream - remove redundant comma * ACLP Logs Stream - Merge save() method integration tests, add skip guard if stream already exists * Update test/unit/objects/monitor_test.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * ACLP Logs Stream - refactor invalid destination test to use session-scoped fixture for deterministic execution * ACLP Logs - update assertion - stream status not tracked by version history * ACLP Logs - update e2e tests workflow * Update .github/workflows/e2e-test.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update linode_api4/objects/monitor.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * ACLP Logs - Clarify LogsDestinationType supporting only one value * ACLP Logs - Fix formatting * ACLP Logs - Extend destination API with new updates * ACLP Logs - Extend Stream API with lke_audit_logs_type, reorder tests * ACLP Logs - fix __all__ statement * ACLP Logs - Stabilize stream tests * ACLP Logs - Reduce integration tests runtime - remove unnecessary updates * ACLP Logs - Remove integration tests capability check * ACLP Logs - Stabilize integration tests - loosen status update assertions * ACLP Logs - Increase timeout for destinations and buckets cleanup * ACLP Logs - Increase timeout for destinations and buckets cleanup * ACLP Logs - stabilize integration tests - fix teardown * ACLP Logs - rename fixture --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/e2e-test-pr.yml | 10 +- .github/workflows/e2e-test.yml | 10 +- linode_api4/groups/monitor.py | 213 +++++++ linode_api4/objects/monitor.py | 389 +++++++++++- test/fixtures/monitor_streams.json | 31 + test/fixtures/monitor_streams_1_history.json | 31 + test/fixtures/monitor_streams_2.json | 43 ++ test/fixtures/monitor_streams_3.json | 29 + .../monitor_streams_destinations.json | 24 + ...onitor_streams_destinations_1_history.json | 24 + .../monitor_streams_destinations_2.json | 36 ++ .../models/monitor/test_monitor_logs.py | 521 ++++++++++++++++ test/unit/objects/monitor_test.py | 588 +++++++++++++++++- 13 files changed, 1945 insertions(+), 4 deletions(-) create mode 100644 test/fixtures/monitor_streams.json create mode 100644 test/fixtures/monitor_streams_1_history.json create mode 100644 test/fixtures/monitor_streams_2.json create mode 100644 test/fixtures/monitor_streams_3.json create mode 100644 test/fixtures/monitor_streams_destinations.json create mode 100644 test/fixtures/monitor_streams_destinations_1_history.json create mode 100644 test/fixtures/monitor_streams_destinations_2.json create mode 100644 test/integration/models/monitor/test_monitor_logs.py diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml index de95ac92b..373a19f42 100644 --- a/.github/workflows/e2e-test-pr.yml +++ b/.github/workflows/e2e-test-pr.yml @@ -2,6 +2,14 @@ on: pull_request: workflow_dispatch: inputs: + run_aclp_logs_stream_tests: + description: 'Set this parameter to "true" to run ACLP logs stream related test cases' + required: false + default: 'false' + type: choice + options: + - 'true' + - 'false' run_db_fork_tests: description: 'Set this parameter to "true" to run fork database related test cases' required: false @@ -104,7 +112,7 @@ jobs: run: | timestamp=$(date +'%Y%m%d%H%M') report_filename="${timestamp}_sdk_test_report.xml" - make test-int RUN_DB_FORK_TESTS=${{ github.event.inputs.run_db_fork_tests }} RUN_DB_TESTS=${{ github.event.inputs.run_db_tests }} TEST_ARGS="--junitxml=${report_filename}" TEST_SUITE="${{ github.event.inputs.test_suite }}" + make test-int RUN_DB_FORK_TESTS=${{ github.event.inputs.run_db_fork_tests }} RUN_DB_TESTS=${{ github.event.inputs.run_db_tests }} RUN_ACLP_LOGS_STREAM_TESTS=${{ github.event.inputs.run_aclp_logs_stream_tests }} TEST_ARGS="--junitxml=${report_filename}" TEST_SUITE="${{ github.event.inputs.test_suite }}" env: LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 8a02599cc..a0350f2c3 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -3,6 +3,14 @@ name: Integration Tests on: workflow_dispatch: inputs: + run_aclp_logs_stream_tests: + description: 'Set this parameter to "true" to run ACLP logs stream related test cases' + required: false + default: 'false' + type: choice + options: + - 'true' + - 'false' run_db_fork_tests: description: 'Set this parameter to "true" to run fork database related test cases' required: false @@ -99,7 +107,7 @@ jobs: run: | timestamp=$(date +'%Y%m%d%H%M') report_filename="${timestamp}_sdk_test_report.xml" - make test-int RUN_DB_FORK_TESTS=${{ github.event.inputs.run_db_fork_tests }} RUN_DB_TESTS=${{ github.event.inputs.run_db_tests }} TEST_SUITE="${{ github.event.inputs.test_suite }}" TEST_ARGS="--junitxml=${report_filename}" + make test-int RUN_DB_FORK_TESTS=${{ github.event.inputs.run_db_fork_tests }} RUN_DB_TESTS=${{ github.event.inputs.run_db_tests }} RUN_ACLP_LOGS_STREAM_TESTS=${{ github.event.inputs.run_aclp_logs_stream_tests }} TEST_SUITE="${{ github.event.inputs.test_suite }}" TEST_ARGS="--junitxml=${report_filename}" env: LINODE_TOKEN: ${{ env.LINODE_TOKEN }} diff --git a/linode_api4/groups/monitor.py b/linode_api4/groups/monitor.py index 0d7f19ce8..08170c8d7 100644 --- a/linode_api4/groups/monitor.py +++ b/linode_api4/groups/monitor.py @@ -8,11 +8,21 @@ AlertDefinition, AlertDefinitionEntity, AlertScope, + LogsDestination, + LogsDestinationType, + LogsStream, + LogsStreamStatus, + LogsStreamType, MonitorDashboard, MonitorMetricsDefinition, MonitorService, MonitorServiceToken, ) +from linode_api4.objects.monitor import ( + AkamaiObjectStorageLogsDestinationDetails, + CustomHTTPSLogsDestinationDetails, + LogsStreamDetails, +) __all__ = [ "MonitorGroup", @@ -332,3 +342,206 @@ def alert_definition_entities( *filters, endpoint=endpoint, ) + + def destinations(self, *filters) -> PaginatedList: + """ + List available logs destinations. + + Returns a paginated collection of :class:`LogsDestination` objects which + describe logs destinations. By default, this method returns all available + destinations; you can supply optional filter expressions to restrict + the results, for example:: + + # Get destinations created by username and with id 111 + destinations = client.monitor.destinations(LogsDestination.created_by == "username", + LogsDestination.id == 111) + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-destinations + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of :class:`LogsDestination` objects matching the query. + :rtype: PaginatedList of LogsDestination + """ + + return self.client._get_and_filter(LogsDestination, *filters) + + def destination_create( + self, + label: str, + type: Union[LogsDestinationType, str], + details: Union[ + AkamaiObjectStorageLogsDestinationDetails, + CustomHTTPSLogsDestinationDetails, + ], + ) -> LogsDestination: + """ + Creates a new :any:`LogsDestination` for logs on this account. + + For an ``akamai_object_storage`` destination:: + + client = LinodeClient(TOKEN) + + new_destination = client.monitor.destination_create( + label="OBJ_logs_destination", + type="akamai_object_storage", + details=AkamaiObjectStorageLogsDestinationDetails( + access_key_id="1ABCD23EFG4HIJKLMNO5", + access_key_secret="1aB2CD3e4fgHi5JK6lmnop7qR8STU9VxYzabcdefHh", + bucket_name="primary-bucket", + host="primary-bucket-1.us-east-12.linodeobjects.com", + path="audit-logs", + ) + ) + + For a ``custom_https`` destination:: + + new_destination = client.monitor.destination_create( + label="custom_logs_destination", + type="custom_https", + details=CustomHTTPSLogsDestinationDetails( + endpoint_url="https://my-site.com/log-storage/basicAuth", + authentication=DestinationAuthentication( + type="basic", + details=BasicAuthenticationDetails( + basic_authentication_user="user", + basic_authentication_password="pass", + ), + ), + data_compression="gzip", + content_type="application/json", + ) + ) + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-destination + + :param label: The name for this logs destination. + :type label: str + :param type: The type of destination — ``akamai_object_storage`` or ``custom_https``. + :type type: str or LogsDestinationType + :param details: A typed details object matching the destination type. + Use :class:`AkamaiObjectStorageLogsDestinationDetails` for + ``akamai_object_storage`` or :class:`CustomHTTPSLogsDestinationDetails` + for ``custom_https``. + :type details: AkamaiObjectStorageLogsDestinationDetails or CustomHTTPSLogsDestinationDetails + + :returns: The newly created logs destination. + :rtype: LogsDestination + """ + + params = { + "label": label, + "type": type, + "details": details.dict, + } + + result = self.client.post("/monitor/streams/destinations", data=params) + + if "id" not in result: + raise UnexpectedResponseError( + "Unexpected response when creating destination!", + json=result, + ) + + return LogsDestination(self.client, result["id"], result) + + def streams(self, *filters) -> PaginatedList: + """ + List available logs streams. + + Returns a paginated collection of :class:`LogsStream` objects which + describe logs streams. By default, this method returns all available + streams; you can supply optional filter expressions to restrict + the results, for example:: + + # Get all streams with status ``provisioning`` + provisioning_streams = client.monitor.streams(LogsStream.status == "provisioning") + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-streams + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + :returns: A list of :class:`LogsStream` objects matching the query. + :rtype: PaginatedList of LogsStream + """ + + return self.client._get_and_filter(LogsStream, *filters) + + def stream_create( + self, + destinations: list[int], + label: str, + type: Union[LogsStreamType, str], + status: Optional[Union[LogsStreamStatus, str]] = None, + details: Optional[LogsStreamDetails] = None, + ) -> LogsStream: + """ + Creates a new :any:`LogsStream` for logs on this account. For example:: + + client = LinodeClient(TOKEN) + + # audit_logs stream (no details required) + new_stream = client.monitor.stream_create( + destinations=[1234], + label="Linode_services", + status="active", + type="audit_logs" + ) + + # lke_audit_logs stream with specific clusters + lke_stream = client.monitor.stream_create( + destinations=[1234], + label="LKE_audit_stream", + type="lke_audit_logs", + details=LogsStreamDetails( + cluster_ids=[1111, 2222], + is_auto_add_all_clusters_enabled=False, + ) + ) + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-stream + + :param destinations: The unique identifier for the sync point that will receive logs data. + Run the List destinations operation and store the id values for each applicable destination. + At the moment only single destination is supported. + :type destinations: list[int] + :param label: The name of the stream. This is used for display purposes in Akamai Cloud Manager. + :type label: str + :param type: The type of stream — ``audit_logs`` for Linode control plane logs, + or ``lke_audit_logs`` for LKE enterprise cluster audit logs. + :type type: str or LogsStreamType + :param status: (Optional) The availability status of the stream. Possible values are: ``active``, ``inactive``. + Defaults to ``active``. + :type status: str + :param details: (Optional) Additional stream details. Only applicable for + ``lke_audit_logs`` streams. Omit for ``audit_logs`` streams. + :type details: LogsStreamDetails + + :returns: The newly created logs stream. + :rtype: LogsStream + """ + + params = { + "label": label, + "type": type, + "destinations": destinations, + } + + if status is not None: + params["status"] = status + + if details is not None: + params["details"] = details.dict + + result = self.client.post("/monitor/streams", data=params) + + if "id" not in result: + raise UnexpectedResponseError( + "Unexpected response when creating logs stream!", + json=result, + ) + + return LogsStream(self.client, result["id"], result) diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index 7e0f4ae4d..c5f751fde 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, field -from typing import List, Optional, Union +from typing import Any, Dict, List, Optional, Union from linode_api4.objects import DerivedBase from linode_api4.objects.base import Base, Property @@ -20,6 +20,26 @@ "MonitorServiceToken", "RuleCriteria", "TriggerConditions", + "AkamaiObjectStorageLogsDestinationDetails", + "AuthenticationType", + "BasicAuthenticationDetails", + "ClientCertificateDetails", + "ContentType", + "CustomHeader", + "CustomHTTPSLogsDestinationDetails", + "DataCompressionType", + "DestinationAuthentication", + "LogsDestinationDetailsBase", + "LogsDestination", + "LogsDestinationHistory", + "LogsDestinationStatus", + "LogsDestinationType", + "LogsStream", + "LogsStreamHistory", + "LogsStreamType", + "LogsStreamStatus", + "LogsStreamDetails", + "LogsStreamDestination", ] @@ -131,6 +151,35 @@ class AlertStatus(StrEnum): AlertDefinitionStatusFailed = "failed" +class LogsDestinationType(StrEnum): + """ + The type of destination for logs data sync. + """ + + akamai_object_storage = "akamai_object_storage" + custom_https = "custom_https" + + +class AuthenticationType(StrEnum): + none = "none" + basic = "basic" + + +class DataCompressionType(StrEnum): + gzip = "gzip" + none = "none" + + +class ContentType(StrEnum): + json = "application/json" + json_utf8 = "application/json; charset=utf-8" + + +class LogsDestinationStatus(StrEnum): + active = "active" + inactive = "inactive" + + @dataclass class Filter(JSONObject): """ @@ -515,3 +564,341 @@ class AlertChannel(Base): "created_by": Property(), "updated_by": Property(), } + + +@dataclass +class BasicAuthenticationDetails(JSONObject): + """ + Includes additional parameters necessary to define basic authentication. + """ + + basic_authentication_user: Optional[str] = None + basic_authentication_password: Optional[str] = None + + +@dataclass +class DestinationAuthentication(JSONObject): + """ + Authentication details required to access the endpoint_url. + """ + + type: Optional[AuthenticationType] = None + details: Optional[BasicAuthenticationDetails] = None + + +@dataclass +class CustomHeader(JSONObject): + """ + Pairs of parameters used to optionally include custom headers in the request. + """ + + name: str = "" + value: str = "" + + +@dataclass +class ClientCertificateDetails(JSONObject): + """ + Contains TLS client certificate information to additionally secure the connection. + """ + + client_ca_certificate: Optional[str] = None + client_certificate: Optional[str] = None + client_private_key: Optional[str] = None + tls_hostname: Optional[str] = None + + +@dataclass +class LogsDestinationDetailsBase(JSONObject): + """ + Base class for Logs Destination details. + Use the factory method to instantiate the correct subclass based on destination type. + """ + + @classmethod + def load_by_type( + cls, dest_type: str, json_dict: dict + ) -> Optional["LogsDestinationDetailsBase"]: + """ + Factory method that instantiates the correct details subclass + based on the destination type string. + + :param dest_type: The destination type (e.g. "akamai_object_storage", "custom_https"). + :param json_dict: The raw JSON dict for the details block. + :returns: A populated subclass instance, or None if json_dict is empty/None. + """ + if not json_dict: + return None + + if dest_type == LogsDestinationType.akamai_object_storage: + return AkamaiObjectStorageLogsDestinationDetails.from_json( + json_dict + ) + elif dest_type == LogsDestinationType.custom_https: + return CustomHTTPSLogsDestinationDetails.from_json(json_dict) + + return None + + +@dataclass +class CustomHTTPSLogsDestinationDetails(LogsDestinationDetailsBase): + """ + Represents the details block for custom_https LogsDestination type. + """ + + endpoint_url: str = "" + authentication: Optional[DestinationAuthentication] = None + data_compression: Optional[DataCompressionType] = None + content_type: Optional[ContentType] = None + custom_headers: Optional[List[CustomHeader]] = None + client_certificate_details: Optional[ClientCertificateDetails] = None + + +@dataclass +class AkamaiObjectStorageLogsDestinationDetails(LogsDestinationDetailsBase): + """ + Represents the details block for Akamai Object Storage LogsDestination type. + Fields: + - access_key_id: str - The unique identifier assigned to the Object Storage key required for authentication to the bucket. + - bucket_name: str - The name of the Object Storage bucket. + - host: str - The hostname where the Object Storage bucket can be accessed. + - path: Optional[str] - The specific path in an Object Storage bucket where audit logs files are uploaded. May be absent or None in API responses. + """ + + access_key_id: str = "" + access_key_secret: Optional[str] = None + bucket_name: str = "" + host: str = "" + path: Optional[str] = None + + +class LogsDestinationHistory(Base): + """ + Represents a read-only historical snapshot of a Logs Destination. + + API documentation: https://techdocs.akamai.com/linode-api/reference/get-destination-history + """ + + properties = { + "created": Property(is_datetime=True), + "created_by": Property(), + "details": Property(), + "id": Property(identifier=True), + "label": Property(), + "status": Property(), + "type": Property(), + "updated": Property(is_datetime=True), + "updated_by": Property(), + "version": Property(), + } + + def _populate(self, json): + super()._populate(json) + + if json and "details" in json and "type" in json: + self._set( + "details", + LogsDestinationDetailsBase.load_by_type( + json["type"], json["details"] + ), + ) + + +class LogsDestination(Base): + """ + Represents a logs destination object. + + API documentation: https://techdocs.akamai.com/linode-api/reference/get-destination + """ + + api_endpoint = "/monitor/streams/destinations/{id}" + + properties = { + "created": Property(is_datetime=True), + "created_by": Property(), + "details": Property(mutable=True), + "id": Property(identifier=True), + "label": Property(mutable=True), + "status": Property(), + "type": Property(), + "updated": Property(is_datetime=True), + "updated_by": Property(), + "version": Property(), + } + + def _populate(self, json): + super()._populate(json) + + if json and "details" in json and "type" in json: + self._set( + "details", + LogsDestinationDetailsBase.load_by_type( + json["type"], json["details"] + ), + ) + + @property + def history(self): + """ + Retrieves the version history for this LogsDestination. + + API documentation: https://techdocs.akamai.com/linode-api/reference/get-destination-history + """ + + return self._client._get_objects( + "{}/history".format( + LogsDestination.api_endpoint.format(id=self.id) + ), + LogsDestinationHistory, + ) + + +class LogsStreamStatus(StrEnum): + active = "active" + inactive = "inactive" + provisioning = "provisioning" + deactivating = "deactivating" + + +class LogsStreamType(StrEnum): + audit_logs = "audit_logs" + lke_audit_logs = "lke_audit_logs" + + +@dataclass +class LogsStreamDetails(JSONObject): + """ + Additional details for a logs stream. + + This object only applies to streams with a ``type`` of ``lke_audit_logs``. + Leave it out of requests that use a ``type`` of ``audit_logs``. + + .. note:: + When updating a stream, any existing settings need to be included to + maintain them. For example, if you're adding new ``cluster_ids`` to the + stream, you also need to include any existing ones to maintain them. + Run the Get a stream operation to review the existing ``details`` + settings for a stream before submitting an update. + + Fields: + - cluster_ids: List of LKE enterprise cluster IDs to include in the stream. + Cannot be used when ``is_auto_add_all_clusters_enabled`` is ``True``. + - is_auto_add_all_clusters_enabled: When ``True``, newly added LKE enterprise + clusters on the account are automatically + included in the stream. + """ + + cluster_ids: Optional[List[int]] = None + is_auto_add_all_clusters_enabled: bool = False + + +@dataclass +class LogsStreamDestination(JSONObject): + """ + Represents a destination attached to a LogsStream. + """ + + id: int = 0 + label: str = "" + type: Optional[LogsDestinationType] = None + details: Optional[LogsDestinationDetailsBase] = None + + @classmethod + def from_json( + cls, json: Dict[str, Any] + ) -> Optional["LogsStreamDestination"]: + if json is None: + return None + + obj = super().from_json(json) + + if obj and json.get("type"): + obj.details = LogsDestinationDetailsBase.load_by_type( + json["type"], json.get("details") + ) + + return obj + + +class LogsStreamHistory(Base): + """ + Represents a read-only historical snapshot of a logs stream. + + API documentation: https://techdocs.akamai.com/linode-api/reference/get-stream-history + """ + + properties = { + "created": Property(is_datetime=True), + "created_by": Property(), + "destinations": Property(json_object=LogsStreamDestination), + "details": Property(json_object=LogsStreamDetails), + "id": Property(identifier=True), + "label": Property(), + "status": Property(), + "type": Property(), + "updated": Property(is_datetime=True), + "updated_by": Property(), + "version": Property(), + } + + +class LogsStream(Base): + """ + Represents a logs stream object. + + API documentation: https://techdocs.akamai.com/linode-api/reference/get-stream + """ + + api_endpoint = "/monitor/streams/{id}" + + properties = { + "created": Property(is_datetime=True), + "created_by": Property(), + "destinations": Property(json_object=LogsStreamDestination), + "details": Property(mutable=True, json_object=LogsStreamDetails), + "id": Property(identifier=True), + "label": Property(mutable=True), + "status": Property(mutable=True), + "type": Property(), + "updated": Property(is_datetime=True), + "updated_by": Property(), + "version": Property(), + } + + def update_destinations(self, destinations: List[int]): + """ + Updates the sync points that receive logs data for this stream. + Replaces existing destinations with the provided list. + + :param destinations: A list of destination IDs. + At the moment only single destination per stream is supported. + Passing more than one element in the list will result in an error from the API. + :type destinations: list[int] + + :returns: True if the update was successful. + :rtype: bool + """ + if not destinations: + raise ValueError("A destination id must be provided.") + payload = {"destinations": destinations} + + # The Linode API PUT request expects the flat list of IDs + result = self._client.put( + self.api_endpoint.format(id=self.id), data=payload + ) + self._populate(result) + + return True + + @property + def history(self): + """ + Retrieves the version history for this LogsStream. + + API documentation: https://techdocs.akamai.com/linode-api/reference/get-stream-history + """ + + return self._client._get_objects( + "{}/history".format(LogsStream.api_endpoint.format(id=self.id)), + LogsStreamHistory, + ) diff --git a/test/fixtures/monitor_streams.json b/test/fixtures/monitor_streams.json new file mode 100644 index 000000000..def47b365 --- /dev/null +++ b/test/fixtures/monitor_streams.json @@ -0,0 +1,31 @@ +{ + "data": [ + { + "id": 1, + "label": "my-logs-stream", + "type": "audit_logs", + "status": "active", + "destinations": [ + { + "id": 1, + "label": "my-logs-destination", + "type": "akamai_object_storage", + "details": { + "access_key_id": "1ABCD23EFG4HIJKLMNO5", + "bucket_name": "primary-bucket", + "host": "primary-bucket.us-east-1.linodeobjects.com", + "path": "audit-logs" + } + } + ], + "created": "2024-06-01T12:00:00", + "updated": "2024-06-01T12:00:00", + "created_by": "tester", + "updated_by": "tester", + "version": 1 + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/monitor_streams_1_history.json b/test/fixtures/monitor_streams_1_history.json new file mode 100644 index 000000000..8f536303e --- /dev/null +++ b/test/fixtures/monitor_streams_1_history.json @@ -0,0 +1,31 @@ +{ + "data": [ + { + "id": 1, + "label": "my-logs-stream", + "type": "audit_logs", + "status": "active", + "destinations": [ + { + "id": 1, + "label": "my-logs-destination", + "type": "akamai_object_storage", + "details": { + "access_key_id": "1ABCD23EFG4HIJKLMNO5", + "bucket_name": "primary-bucket", + "host": "primary-bucket.us-east-1.linodeobjects.com", + "path": "audit-logs" + } + } + ], + "created": "2024-06-01T12:00:00", + "updated": "2024-06-02T09:00:00", + "created_by": "tester", + "updated_by": "tester", + "version": 2 + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/monitor_streams_2.json b/test/fixtures/monitor_streams_2.json new file mode 100644 index 000000000..aa0a2b5cd --- /dev/null +++ b/test/fixtures/monitor_streams_2.json @@ -0,0 +1,43 @@ +{ + "id": 2, + "label": "my-custom-https-stream", + "type": "audit_logs", + "status": "active", + "destinations": [ + { + "id": 2, + "label": "my-custom-https-destination", + "type": "custom_https", + "details": { + "endpoint_url": "https://my-site.com/log-storage/basicAuth", + "authentication": { + "type": "basic", + "details": { + "basic_authentication_user": "John_Q", + "basic_authentication_password": "p@$$w0Rd" + } + }, + "data_compression": "gzip", + "content_type": "application/json", + "custom_headers": [ + { + "name": "Cache-Control", + "value": "max-age=0" + } + ], + "client_certificate_details": { + "client_ca_certificate": "-----BEGIN CERTIFICATE-----\nMIIBIjANBgkq...\n-----END CERTIFICATE-----", + "client_certificate": "-----BEGIN CERTIFICATE-----\nMIIBIjANBgkq...\n-----END CERTIFICATE-----", + "client_private_key": "-----BEGIN PRIVATE KEY-----\nMIIBIjANBgkq...\n-----END PRIVATE KEY-----", + "tls_hostname": "my-site.com" + } + } + } + ], + "created": "2024-08-01T12:00:00", + "updated": "2024-08-01T12:00:00", + "created_by": "tester", + "updated_by": "tester", + "version": 1 +} + diff --git a/test/fixtures/monitor_streams_3.json b/test/fixtures/monitor_streams_3.json new file mode 100644 index 000000000..a584dde45 --- /dev/null +++ b/test/fixtures/monitor_streams_3.json @@ -0,0 +1,29 @@ +{ + "id": 3, + "label": "my-lke-audit-logs-stream", + "type": "lke_audit_logs", + "status": "active", + "destinations": [ + { + "id": 1, + "label": "my-logs-destination", + "type": "akamai_object_storage", + "details": { + "access_key_id": "1ABCD23EFG4HIJKLMNO5", + "bucket_name": "primary-bucket", + "host": "primary-bucket.us-east-1.linodeobjects.com", + "path": "audit-logs" + } + } + ], + "details": { + "cluster_ids": [1234, 5678], + "is_auto_add_all_clusters_enabled": false + }, + "created": "2024-09-01T12:00:00", + "updated": "2024-09-01T12:00:00", + "created_by": "tester", + "updated_by": "tester", + "version": 1 +} + diff --git a/test/fixtures/monitor_streams_destinations.json b/test/fixtures/monitor_streams_destinations.json new file mode 100644 index 000000000..0e1365e26 --- /dev/null +++ b/test/fixtures/monitor_streams_destinations.json @@ -0,0 +1,24 @@ +{ + "data": [ + { + "id": 1, + "label": "my-logs-destination", + "type": "akamai_object_storage", + "status": "active", + "details": { + "access_key_id": "1ABCD23EFG4HIJKLMNO5", + "bucket_name": "primary-bucket", + "host": "primary-bucket.us-east-1.linodeobjects.com", + "path": "audit-logs" + }, + "created": "2024-06-01T12:00:00", + "updated": "2024-06-01T12:00:00", + "created_by": "tester", + "updated_by": "tester", + "version": 1 + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/monitor_streams_destinations_1_history.json b/test/fixtures/monitor_streams_destinations_1_history.json new file mode 100644 index 000000000..11f262c81 --- /dev/null +++ b/test/fixtures/monitor_streams_destinations_1_history.json @@ -0,0 +1,24 @@ +{ + "data": [ + { + "id": 1, + "label": "my-logs-destination", + "type": "akamai_object_storage", + "status": "active", + "details": { + "access_key_id": "1ABCD23EFG4HIJKLMNO5", + "bucket_name": "primary-bucket", + "host": "primary-bucket.us-east-1.linodeobjects.com", + "path": "audit-logs" + }, + "created": "2024-06-01T12:00:00", + "updated": "2024-06-02T09:00:00", + "created_by": "tester", + "updated_by": "tester", + "version": 2 + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/monitor_streams_destinations_2.json b/test/fixtures/monitor_streams_destinations_2.json new file mode 100644 index 000000000..215b90297 --- /dev/null +++ b/test/fixtures/monitor_streams_destinations_2.json @@ -0,0 +1,36 @@ +{ + "id": 2, + "label": "my-custom-https-destination", + "type": "custom_https", + "status": "active", + "details": { + "endpoint_url": "https://my-site.com/log-storage/basicAuth", + "authentication": { + "type": "basic", + "details": { + "basic_authentication_user": "John_Q", + "basic_authentication_password": "p@$$w0Rd" + } + }, + "data_compression": "gzip", + "content_type": "application/json", + "custom_headers": [ + { + "name": "Cache-Control", + "value": "max-age=0" + } + ], + "client_certificate_details": { + "client_ca_certificate": "-----BEGIN CERTIFICATE-----\nMIIBIjANBgkq...\n-----END CERTIFICATE-----", + "client_certificate": "-----BEGIN CERTIFICATE-----\nMIIBIjANBgkq...\n-----END CERTIFICATE-----", + "client_private_key": "-----BEGIN PRIVATE KEY-----\nMIIBIjANBgkq...\n-----END PRIVATE KEY-----", + "tls_hostname": "my-site.com" + } + }, + "created": "2024-08-01T12:00:00", + "updated": "2024-08-01T12:00:00", + "created_by": "tester", + "updated_by": "tester", + "version": 1 +} + diff --git a/test/integration/models/monitor/test_monitor_logs.py b/test/integration/models/monitor/test_monitor_logs.py new file mode 100644 index 000000000..9d4148c17 --- /dev/null +++ b/test/integration/models/monitor/test_monitor_logs.py @@ -0,0 +1,521 @@ +import os +import urllib.request +from test.integration.conftest import get_region +from test.integration.helpers import ( + get_test_label, + send_request_when_resource_available, + wait_for_condition, +) + +import pytest + +from linode_api4 import LinodeClient, LogsStreamType, PaginatedList, Region +from linode_api4.objects import ( + ObjectStorageACL, + ObjectStorageBucket, + ObjectStorageKeys, +) +from linode_api4.objects.monitor import ( + AkamaiObjectStorageLogsDestinationDetails, + LogsDestination, + LogsStream, + LogsStreamStatus, +) + +_RUN_ACLP_LOGS_STREAM_TESTS = "RUN_ACLP_LOGS_STREAM_TESTS" +_SKIP_STREAM_TESTS = pytest.mark.skipif( + os.getenv(_RUN_ACLP_LOGS_STREAM_TESTS, "").strip().lower() + not in {"yes", "true"}, + reason=f"{_RUN_ACLP_LOGS_STREAM_TESTS} environment variable must be set to 'yes' or 'true'", +) +_STREAM_FIXTURE_CLEANUP_WAIT = 2700 +_STREAM_FIXTURE_PROVISIONING_WAIT = 3600 + + +@pytest.fixture(scope="session") +def region(test_linode_client: LinodeClient): + region = get_region(test_linode_client, {"Object Storage"}) + yield region + + +@pytest.fixture(scope="session") +def create_object_storage_key(test_linode_client: LinodeClient, region: Region): + key = test_linode_client.object_storage.keys_create( + label=get_test_label(), regions=[region.id] + ) + yield key + key.delete() + + +@pytest.fixture(scope="session") +def create_destination( + test_linode_client: LinodeClient, + create_object_storage_key: ObjectStorageKeys, + region: Region, +): + dest, bucket = _create_destination_with_bucket( + test_linode_client, create_object_storage_key, region + ) + yield dest + _delete_destination_with_bucket(test_linode_client, dest, bucket) + + +def _create_destination_with_bucket( + client: LinodeClient, key: ObjectStorageKeys, region: Region +): + """Helper that creates an OBJ bucket and a logs destination backed by it.""" + bucket = client.object_storage.bucket_create( + cluster_or_region=region.id, + label=get_test_label(), + acl=ObjectStorageACL.PRIVATE, + cors_enabled=False, + ) + dest = client.monitor.destination_create( + label=get_test_label(), + type="akamai_object_storage", + details=AkamaiObjectStorageLogsDestinationDetails( + access_key_id=key.access_key, + access_key_secret=key.secret_key, + bucket_name=bucket.label, + host=bucket.hostname, + ), + ) + return dest, bucket + + +def _delete_destination_with_bucket( + client: LinodeClient, dest: LogsDestination, bucket: ObjectStorageBucket +): + """Helper that deletes a logs destination and its backing OBJ bucket.""" + + def no_stream_attached(): + streams = client.monitor.streams() + return all( + all(d.id != dest.id for d in s.destinations) for s in streams + ) + + wait_for_condition(30, _STREAM_FIXTURE_CLEANUP_WAIT, no_stream_attached) + send_request_when_resource_available(timeout=100, func=dest.delete) + _empty_bucket(client, bucket) + send_request_when_resource_available(timeout=100, func=bucket.delete) + + +def _skip_if_streams_exist(client: LinodeClient): + """Skip the current test if any streams already exist on the account. + Only one stream can be present per account at a time.""" + existing_streams = client.monitor.streams() + if len(existing_streams) > 0: + stream_labels = [s.label for s in existing_streams] + pytest.skip( + f"Skipping: existing stream(s) found on this account " + f"(labels: {stream_labels}). Only one stream can be present per account." + ) + + +def _empty_bucket(client: LinodeClient, bucket: ObjectStorageBucket): + """ + Helper function clearing objects in the test bucket so it can be deleted. + """ + for obj in bucket.contents(): + signed = client.object_storage.object_url_create( + cluster_or_region_id=bucket.region, + bucket=bucket.label, + method="DELETE", + name=obj.name, + ) + urllib.request.urlopen( + urllib.request.Request(signed.url, method="DELETE") + ) + + +def test_list_destinations( + test_linode_client: LinodeClient, create_destination: LogsDestination +): + """ + Test that listing destinations returns a PaginatedList containing the previously created destination. + """ + destinations = test_linode_client.monitor.destinations() + + assert isinstance(destinations, PaginatedList) + assert len(destinations) > 0 + assert all(isinstance(d, LogsDestination) for d in destinations) + + ids = [d.id for d in destinations] + assert create_destination.id in ids + + +def test_get_destination_by_id( + test_linode_client: LinodeClient, create_destination: LogsDestination +): + """ + Test that fetching destination with id filter returns correct destination. + """ + destination_by_id = test_linode_client.load( + LogsDestination, create_destination.id + ) + + assert isinstance(destination_by_id, LogsDestination) + assert destination_by_id.id == create_destination.id + assert destination_by_id.label == create_destination.label + assert destination_by_id.type == create_destination.type + + +def test_update_destination_label_and_version_history( + test_linode_client: LinodeClient, + create_destination: LogsDestination, + create_object_storage_key: ObjectStorageKeys, +): + """ + Test that a LogsDestination label can be updated via save(), + and that history reflects both states. + """ + new_label = create_destination.label + "-upd" + new_path = "updated/logs/path/" + + dest = test_linode_client.load(LogsDestination, create_destination.id) + original_version = dest.version + dest.label = new_label + dest.details.path = new_path + dest.details.access_key_secret = create_object_storage_key.secret_key + dest.save() + + updated = test_linode_client.load(LogsDestination, create_destination.id) + assert updated.label == new_label + assert updated.details.path == new_path + + history = updated.history + assert history is not None + assert len(history) >= 2 + + snapshot_original = next( + snap for snap in history if snap.version == original_version + ) + snapshot_updated = next( + snap for snap in history if snap.version == updated.version + ) + + assert snapshot_updated.label == new_label + assert snapshot_updated.details.path == new_path + assert snapshot_updated.id == create_destination.id + + assert snapshot_original.label == create_destination.label + assert snapshot_original.details.path is None + assert snapshot_original.id == create_destination.id + + +def test_fails_to_create_destination_invalid_secret( + test_linode_client: LinodeClient, +): + """ + Test that a destination create request with invalid access key results in a 400 ApiError. + """ + from linode_api4.errors import ApiError + + with pytest.raises(ApiError) as excinfo: + test_linode_client.monitor.destination_create( + label=get_test_label(), + type="akamai_object_storage", + details=AkamaiObjectStorageLogsDestinationDetails( + access_key_id="1", + access_key_secret="1", + bucket_name="some-bucket", + host="some-bucket.us-southeast-1.linodeobjects.com", + ), + ) + assert excinfo.value.status == 400 + assert excinfo.value.errors == ["Invalid access key id or secret key"] + + +def test_fails_to_create_destination_invalid_type( + test_linode_client: LinodeClient, +): + """ + Test that a destination create request with an unsupported type + results in a 400 ApiError. + """ + from linode_api4.errors import ApiError + + with pytest.raises(ApiError) as excinfo: + test_linode_client.monitor.destination_create( + label=get_test_label(), + type="invalid_type", + details=AkamaiObjectStorageLogsDestinationDetails( + access_key_id="SOMEACCESSKEY", + access_key_secret="SOMESECRETKEY", + bucket_name="some-bucket", + host="some-bucket.us-southeast-1.linodeobjects.com", + ), + ) + assert excinfo.value.status == 400 + assert excinfo.value.errors == [ + "Must be one of akamai_object_storage, custom_https" + ] + + +def test_fails_to_create_destination_empty_required_fields( + test_linode_client: LinodeClient, +): + """ + Test that a destination create request with missing required fields + results in a 400 ApiError. + """ + from linode_api4.errors import ApiError + + with pytest.raises(ApiError) as excinfo: + test_linode_client.monitor.destination_create( + label=get_test_label(), + type="akamai_object_storage", + details=AkamaiObjectStorageLogsDestinationDetails( + access_key_id="", + access_key_secret="", + bucket_name="", + host="", + ), + ) + assert excinfo.value.status == 400 + assert len(excinfo.value.errors) == 4 + assert all( + error == "Length must be 1-255 characters" + for error in excinfo.value.errors + ) + + +@pytest.fixture(scope="session") +def invalid_destination_error(test_linode_client: LinodeClient): + """ + Session-scoped fixture to attempt invalid stream creation deterministically + before any valid streams are created. Yields the resulting exception so + assertions can be handled safely within the test case. + """ + from linode_api4.errors import ApiError + + _skip_if_streams_exist(test_linode_client) + + try: + test_linode_client.monitor.stream_create( + label=get_test_label(), + type=LogsStreamType.audit_logs, + destinations=[999999999], + ) + yield None + except ApiError as excinfo: + yield excinfo + + +@_SKIP_STREAM_TESTS +def test_fails_to_create_stream_invalid_destination(invalid_destination_error): + """ + Test that creating a stream with a non-existent destination ID results in a 400 ApiError. + Requires no other streams to be present on account. + """ + assert ( + invalid_destination_error is not None + ), "Expected an ApiError but none was raised" + + assert invalid_destination_error.status == 400 + assert invalid_destination_error.errors == ["Destination not found"] + + +@pytest.fixture(scope="session") +def create_secondary_destination( + test_linode_client: LinodeClient, + create_object_storage_key: ObjectStorageKeys, + region: Region, +): + dest, bucket = _create_destination_with_bucket( + test_linode_client, create_object_storage_key, region + ) + yield dest + _delete_destination_with_bucket(test_linode_client, dest, bucket) + + +@pytest.fixture(scope="session") +def create_stream( + test_linode_client: LinodeClient, + create_destination: LogsDestination, + invalid_destination_error, # This ensures run order to keep negative test case deterministic + create_secondary_destination: LogsDestination, # This ensures teardown order - stream must be deleted before its destinations can be deleted +): + _skip_if_streams_exist(test_linode_client) + + stream = test_linode_client.monitor.stream_create( + label=get_test_label(), + destinations=[create_destination.id], + type=LogsStreamType.audit_logs, + ) + assert stream.id is not None + assert stream.status == LogsStreamStatus.provisioning + yield stream + _stream_teardown(test_linode_client, stream) + + +def _wait_for_stream_updatable(client: LinodeClient, stream_id: int): + """ + Blocks until the stream with the given id reaches active or inactive status. + Updating destinations or other attributes puts the stream + back into a transitional state, and attempting to delete or modify it while + transitioning results in [400] errors. + """ + + def is_stream_updatable(): + stream = client.load(LogsStream, stream_id) + return stream.status in ( + LogsStreamStatus.active, + LogsStreamStatus.inactive, + ) + + wait_for_condition( + 30, _STREAM_FIXTURE_PROVISIONING_WAIT, is_stream_updatable + ) + + +def _stream_teardown(test_linode_client: LinodeClient, stream: LogsStream): + _wait_for_stream_updatable(test_linode_client, stream.id) + send_request_when_resource_available(timeout=100, func=stream.delete) + + # The delete request returns 200 but stream deletion is async on the backend. + # Wait until the stream is fully gone before teardown continues, so that + # dependent fixtures (e.g. create_secondary_destination) can proceed with teardown. + def is_stream_deleted(): + existing = test_linode_client.monitor.streams() + return all(s.id != stream.id for s in existing) + + wait_for_condition(30, _STREAM_FIXTURE_CLEANUP_WAIT, is_stream_deleted) + + +@pytest.fixture(scope="session") +def provisioned_stream( + test_linode_client: LinodeClient, create_stream: LogsStream +): + """ + Waits until the stream transitions out of provisioning state. + NOTE: Stream provisioning can take up to 60 minutes to finish. + """ + + def is_stream_provisioned(): + stream = test_linode_client.load(LogsStream, create_stream.id) + return stream.status in ( + LogsStreamStatus.active, + LogsStreamStatus.inactive, + ) + + wait_for_condition( + 60, _STREAM_FIXTURE_PROVISIONING_WAIT, is_stream_provisioned + ) + + yield test_linode_client.load(LogsStream, create_stream.id) + + +@pytest.fixture(scope="function") +def wait_for_updatable_status( + test_linode_client: LinodeClient, provisioned_stream: LogsStream +): + """ + Waits for the stream to be in an active or inactive state before a test runs. + Streams can switch to `provisioning` state between updates. This makes sure the previous update is fully finished. + """ + _wait_for_stream_updatable(test_linode_client, provisioned_stream.id) + + +@_SKIP_STREAM_TESTS +def test_list_streams( + test_linode_client: LinodeClient, provisioned_stream: LogsStream +): + """ + Test that listing streams returns a PaginatedList containing the previously created stream. + """ + streams = test_linode_client.monitor.streams() + + assert isinstance(streams, PaginatedList) + assert len(streams) > 0 + assert all(isinstance(s, LogsStream) for s in streams) + + ids = [s.id for s in streams] + assert provisioned_stream.id in ids + + +@_SKIP_STREAM_TESTS +def test_get_stream_by_id( + test_linode_client: LinodeClient, provisioned_stream: LogsStream +): + """ + Test that loading a stream by ID returns the correct stream with expected fields. + """ + stream = test_linode_client.load(LogsStream, provisioned_stream.id) + + assert isinstance(stream, LogsStream) + assert stream.id == provisioned_stream.id + assert provisioned_stream.label in stream.label + assert stream.status in (LogsStreamStatus.active, LogsStreamStatus.inactive) + assert len(stream.destinations) == 1 + + +@_SKIP_STREAM_TESTS +@pytest.mark.usefixtures("wait_for_updatable_status") +def test_update_stream_label_and_status( + test_linode_client: LinodeClient, provisioned_stream: LogsStream +): + """ + Test that a LogsStream label and status can both be updated via save(), and that + the version history reflects label changes across versions. + """ + stream = test_linode_client.load(LogsStream, provisioned_stream.id) + original_label = stream.label + original_status = stream.status + version_before = stream.version + + new_label = original_label + "-upd" + new_status = ( + [LogsStreamStatus.inactive, LogsStreamStatus.deactivating] + if original_status == LogsStreamStatus.active + else [LogsStreamStatus.active, LogsStreamStatus.provisioning] + ) + + stream.label = new_label + stream.status = new_status[0] + result = stream.save() + assert result is True + + updated = test_linode_client.load(LogsStream, provisioned_stream.id) + assert updated.label == new_label + assert updated.status in new_status + + history = updated.history + snapshot_original = next(h for h in history if h.version == version_before) + snapshot_updated = next(h for h in history if h.version == updated.version) + + assert snapshot_original.label == original_label + assert snapshot_updated.label == new_label + assert snapshot_updated.id == provisioned_stream.id + + +@_SKIP_STREAM_TESTS +@pytest.mark.usefixtures("wait_for_updatable_status") +def test_update_stream_destinations( + test_linode_client: LinodeClient, + provisioned_stream: LogsStream, + create_secondary_destination: LogsDestination, +): + """ + Test that a stream destination can be replaced via update_destinations(), + and that history reflects the change. The API allows exactly one destination per stream. + """ + stream = test_linode_client.load(LogsStream, provisioned_stream.id) + original_destinations = [stream.destinations[0].id] + version_before = stream.version + + result = stream.update_destinations([create_secondary_destination.id]) + assert result is True + + updated = test_linode_client.load(LogsStream, provisioned_stream.id) + assert len(updated.destinations) == 1 + assert updated.destinations[0].id == create_secondary_destination.id + + history = updated.history + snapshot_original = next(h for h in history if h.version == version_before) + snapshot_updated = next(h for h in history if h.version == updated.version) + + assert snapshot_original.destinations[0].id == original_destinations[0] + assert ( + snapshot_updated.destinations[0].id == create_secondary_destination.id + ) diff --git a/test/unit/objects/monitor_test.py b/test/unit/objects/monitor_test.py index 5913b3b28..43985a172 100644 --- a/test/unit/objects/monitor_test.py +++ b/test/unit/objects/monitor_test.py @@ -1,7 +1,23 @@ import datetime from test.unit.base import ClientBaseCase -from linode_api4.objects import AlertChannel, MonitorDashboard, MonitorService +from linode_api4.objects import ( + AlertChannel, + LogsDestination, + LogsDestinationHistory, + LogsStream, + LogsStreamDestination, + MonitorDashboard, + MonitorService, +) +from linode_api4.objects.monitor import ( + AkamaiObjectStorageLogsDestinationDetails, + CustomHTTPSLogsDestinationDetails, + DestinationAuthentication, + LogsDestinationDetailsBase, + LogsStreamDetails, + LogsStreamType, +) class MonitorTest(ClientBaseCase): @@ -169,3 +185,573 @@ def test_alert_channels(self): "/monitor/alert-channels/123/alerts", ) self.assertEqual(channels[0].alerts.alert_count, 0) + + +class LogsDestinationTest(ClientBaseCase): + """ + Tests methods for LogsDestination class + """ + + def test_list_destinations(self): + """ + Test that listing destinations returns LogsDestination objects with all fields populated. + """ + destinations = self.client.monitor.destinations() + + self.assertEqual(len(destinations), 1) + dest = destinations[0] + self.assertIsInstance(dest, LogsDestination) + self.assertEqual(dest.id, 1) + self.assertEqual(dest.label, "my-logs-destination") + self.assertEqual(dest.type, "akamai_object_storage") + self.assertEqual(dest.status, "active") + self.assertEqual(dest.version, 1) + self.assertEqual(dest.created, datetime.datetime(2024, 6, 1, 12, 0, 0)) + self.assertEqual(dest.updated, datetime.datetime(2024, 6, 1, 12, 0, 0)) + self.assertEqual(dest.created_by, "tester") + self.assertEqual(dest.updated_by, "tester") + + def test_list_destinations_details(self): + """ + Test that the nested LogsDestinationDetails are deserialized correctly. + """ + dest = self.client.load(LogsDestination, 1) + + self.assertIsNotNone(dest.details) + self.assertEqual(dest.details.access_key_id, "1ABCD23EFG4HIJKLMNO5") + self.assertEqual(dest.details.bucket_name, "primary-bucket") + self.assertEqual( + dest.details.host, "primary-bucket.us-east-1.linodeobjects.com" + ) + self.assertEqual(dest.details.path, "audit-logs") + + self.assertIsNone(dest.details.access_key_secret) + + def test_destination_history(self): + """ + Test that the history property returns LogsDestinationHistory objects. + """ + dest = self.client.load(LogsDestination, 1) + history = dest.history + + self.assertEqual(len(history), 1) + snapshot = history[0] + self.assertIsInstance(snapshot, LogsDestinationHistory) + self.assertEqual(snapshot.id, 1) + self.assertEqual(snapshot.label, "my-logs-destination") + self.assertEqual(snapshot.type, "akamai_object_storage") + self.assertEqual(snapshot.status, "active") + self.assertEqual(snapshot.version, 2) + self.assertEqual( + snapshot.updated, datetime.datetime(2024, 6, 2, 9, 0, 0) + ) + self.assertIsNotNone(snapshot.details) + self.assertEqual(snapshot.details.bucket_name, "primary-bucket") + + def test_create_destination_akamai_object_storage(self): + """ + Test that destination_create with type=akamai_object_storage sends the right + payload and returns a LogsDestination object. + """ + create_response = { + "id": 2, + "label": "new-dest", + "type": "akamai_object_storage", + "status": "active", + "details": { + "access_key_id": "KEYID999", + "bucket_name": "new-bucket", + "host": "new-bucket.us-east-1.linodeobjects.com", + "path": "logs/audit", + }, + "created": "2024-07-01T00:00:00", + "updated": "2024-07-01T00:00:00", + "created_by": "tester", + "updated_by": "tester", + "version": 1, + } + + with self.mock_post(create_response) as m: + result = self.client.monitor.destination_create( + label="new-dest", + type="akamai_object_storage", + details=AkamaiObjectStorageLogsDestinationDetails( + access_key_id="KEYID999", + access_key_secret="SUPERSECRET", + bucket_name="new-bucket", + host="new-bucket.us-east-1.linodeobjects.com", + path="logs/audit", + ), + ) + + self.assertEqual(m.call_url, "/monitor/streams/destinations") + self.assertEqual(m.call_data["label"], "new-dest") + self.assertEqual(m.call_data["type"], "akamai_object_storage") + self.assertEqual(m.call_data["details"]["access_key_id"], "KEYID999") + self.assertEqual( + m.call_data["details"]["access_key_secret"], "SUPERSECRET" + ) + self.assertEqual(m.call_data["details"]["bucket_name"], "new-bucket") + self.assertEqual( + m.call_data["details"]["host"], + "new-bucket.us-east-1.linodeobjects.com", + ) + self.assertEqual(m.call_data["details"]["path"], "logs/audit") + + self.assertIsInstance(result, LogsDestination) + self.assertEqual(result.id, 2) + self.assertEqual(result.label, "new-dest") + + def test_update_destination(self): + """ + Test that mutating a LogsDestination's mutable fields and calling save() + sends a PUT to the correct endpoint with the updated values. + """ + dest = self.client.load(LogsDestination, 1) + + updated_response = { + "id": 1, + "label": "renamed-destination", + "type": "akamai_object_storage", + "status": "active", + "details": { + "access_key_id": "1ABCD23EFG4HIJKLMNO5", + "bucket_name": "primary-bucket", + "host": "primary-bucket.us-east-1.linodeobjects.com", + "path": "audit-logs", + }, + "created": "2024-06-01T12:00:00", + "updated": "2024-06-03T08:00:00", + "created_by": "tester", + "updated_by": "tester", + "version": 2, + } + + with self.mock_put(updated_response) as m: + dest.label = "renamed-destination" + dest.save() + + self.assertEqual(m.call_url, "/monitor/streams/destinations/1") + self.assertEqual(m.call_data["label"], "renamed-destination") + + def test_delete_destination(self): + """ + Test that deleting a LogsDestination issues a DELETE to the correct URL. + """ + dest = self.client.load(LogsDestination, 1) + + with self.mock_delete() as m: + dest.delete() + + self.assertEqual(m.call_url, "/monitor/streams/destinations/1") + + +class CustomHTTPSLogsDestinationTest(ClientBaseCase): + """ + Tests for custom_https type LogsDestination and the load_by_type factory. + """ + + def test_load_by_type_factory(self): + """load_by_type dispatches to the correct details class based on type.""" + akamai = LogsDestinationDetailsBase.load_by_type( + "akamai_object_storage", + {"access_key_id": "K", "bucket_name": "b", "host": "h.com"}, + ) + self.assertIsInstance(akamai, AkamaiObjectStorageLogsDestinationDetails) + self.assertEqual(akamai.access_key_id, "K") + + custom = LogsDestinationDetailsBase.load_by_type( + "custom_https", + { + "endpoint_url": "https://x.com", + "authentication": {"type": "none"}, + "data_compression": "gzip", + "content_type": "application/json", + }, + ) + self.assertIsInstance(custom, CustomHTTPSLogsDestinationDetails) + self.assertEqual(custom.endpoint_url, "https://x.com") + + self.assertIsNone( + LogsDestinationDetailsBase.load_by_type("custom_https", None) + ) + self.assertIsNone( + LogsDestinationDetailsBase.load_by_type("custom_https", {}) + ) + self.assertIsNone( + LogsDestinationDetailsBase.load_by_type("unknown", {"x": 1}) + ) + + def test_load_custom_https_destination(self): + """ + Loading a custom_https destination deserializes all nested fields correctly. + """ + dest = self.client.load(LogsDestination, 2) + + self.assertIsInstance(dest.details, CustomHTTPSLogsDestinationDetails) + self.assertEqual( + dest.details.endpoint_url, + "https://my-site.com/log-storage/basicAuth", + ) + self.assertEqual(dest.details.data_compression, "gzip") + self.assertEqual(dest.details.content_type, "application/json") + self.assertEqual(dest.details.authentication.type, "basic") + self.assertEqual( + dest.details.authentication.details.basic_authentication_user, + "John_Q", + ) + self.assertEqual(dest.details.custom_headers[0].name, "Cache-Control") + self.assertEqual( + dest.details.client_certificate_details.tls_hostname, "my-site.com" + ) + + def test_stream_with_custom_https_destination(self): + """ + A LogsStreamDestination with type custom_https is deserialized correctly. + """ + stream = self.client.load(LogsStream, 2) + details = stream.destinations[0].details + + self.assertIsInstance(details, CustomHTTPSLogsDestinationDetails) + self.assertEqual( + details.endpoint_url, "https://my-site.com/log-storage/basicAuth" + ) + self.assertEqual(details.authentication.type, "basic") + self.assertEqual(details.custom_headers[0].name, "Cache-Control") + + def test_create_custom_https_destination(self): + """ + destination_create with type=custom_https sends the correct payload. + """ + create_response = { + "id": 3, + "label": "new-custom-dest", + "type": "custom_https", + "status": "active", + "details": { + "endpoint_url": "https://example.com/logs", + "authentication": {"type": "none"}, + "data_compression": "none", + "content_type": "application/json", + }, + "created": "2024-09-01T00:00:00", + "updated": "2024-09-01T00:00:00", + "created_by": "tester", + "updated_by": "tester", + "version": 1, + } + + with self.mock_post(create_response) as m: + result = self.client.monitor.destination_create( + label="new-custom-dest", + type="custom_https", + details=CustomHTTPSLogsDestinationDetails( + endpoint_url="https://example.com/logs", + authentication=DestinationAuthentication(type="none"), + data_compression="none", + content_type="application/json", + ), + ) + + self.assertEqual(m.call_url, "/monitor/streams/destinations") + self.assertEqual(m.call_data["type"], "custom_https") + self.assertEqual( + m.call_data["details"]["endpoint_url"], "https://example.com/logs" + ) + self.assertIsInstance(result, LogsDestination) + self.assertEqual(result.id, 3) + self.assertIsInstance(result.details, CustomHTTPSLogsDestinationDetails) + + +class LogsStreamTest(ClientBaseCase): + """ + Tests methods for LogsStream class. + """ + + def test_list_streams(self): + """ + Test that listing streams returns LogsStream objects with all fields populated. + """ + streams = self.client.monitor.streams() + + self.assertEqual(len(streams), 1) + stream = streams[0] + self.assertIsInstance(stream, LogsStream) + self.assertEqual(stream.id, 1) + self.assertEqual(stream.label, "my-logs-stream") + self.assertEqual(stream.type, "audit_logs") + self.assertEqual(stream.status, "active") + self.assertEqual(stream.version, 1) + self.assertEqual( + stream.created, datetime.datetime(2024, 6, 1, 12, 0, 0) + ) + self.assertEqual( + stream.updated, datetime.datetime(2024, 6, 1, 12, 0, 0) + ) + self.assertEqual(stream.created_by, "tester") + self.assertEqual(stream.updated_by, "tester") + + def test_list_streams_destinations(self): + """ + Test that the nested destinations are deserialized as LogsStreamDestination objects. + """ + stream = self.client.load(LogsStream, 1) + + self.assertIsNotNone(stream.destinations) + self.assertEqual(len(stream.destinations), 1) + dest = stream.destinations[0] + self.assertIsInstance(dest, LogsStreamDestination) + self.assertEqual(dest.id, 1) + self.assertEqual(dest.label, "my-logs-destination") + self.assertEqual(dest.type, "akamai_object_storage") + self.assertIsNotNone(dest.details) + self.assertEqual(dest.details.bucket_name, "primary-bucket") + self.assertEqual(dest.details.access_key_id, "1ABCD23EFG4HIJKLMNO5") + self.assertEqual( + dest.details.host, "primary-bucket.us-east-1.linodeobjects.com" + ) + self.assertEqual(dest.details.path, "audit-logs") + + def test_stream_history(self): + """ + Test that the history property returns LogsStreamHistory objects. + """ + stream = self.client.load(LogsStream, 1) + history = stream.history + + self.assertEqual(len(history), 1) + snapshot = history[0] + self.assertEqual(snapshot.id, 1) + self.assertEqual(snapshot.label, "my-logs-stream") + self.assertEqual(snapshot.type, "audit_logs") + self.assertEqual(snapshot.status, "active") + self.assertEqual(snapshot.version, 2) + self.assertEqual( + snapshot.updated, datetime.datetime(2024, 6, 2, 9, 0, 0) + ) + self.assertIsNotNone(snapshot.destinations) + + def test_create_stream(self): + """ + Test that stream_create sends the correct payload and returns a LogsStream object. + """ + create_response = { + "id": 2, + "label": "new-stream", + "type": "audit_logs", + "status": "active", + "destinations": [ + { + "id": 1, + "label": "my-logs-destination", + "type": "akamai_object_storage", + "details": {}, + } + ], + "created": "2024-07-01T00:00:00", + "updated": "2024-07-01T00:00:00", + "created_by": "tester", + "updated_by": "tester", + "version": 1, + } + + with self.mock_post(create_response) as m: + result = self.client.monitor.stream_create( + destinations=[1], + label="new-stream", + status="active", + type="audit_logs", + ) + + self.assertEqual(m.call_url, "/monitor/streams") + self.assertEqual(m.call_data["label"], "new-stream") + self.assertEqual(m.call_data["type"], "audit_logs") + self.assertEqual(m.call_data["status"], "active") + self.assertEqual(m.call_data["destinations"], [1]) + + self.assertIsInstance(result, LogsStream) + self.assertEqual(result.id, 2) + self.assertEqual(result.label, "new-stream") + + def test_update_stream_save(self): + """ + Test that mutating a LogsStream's mutable fields and calling save() + sends a PUT with correct payload. + """ + stream = self.client.load(LogsStream, 1) + + updated_response = { + "id": 1, + "label": "renamed-stream", + "type": "audit_logs", + "status": "inactive", + "destinations": [ + { + "id": 1, + "label": "my-logs-destination", + "type": "akamai_object_storage", + "details": {}, + } + ], + "created": "2024-06-01T12:00:00", + "updated": "2024-06-03T08:00:00", + "created_by": "tester", + "updated_by": "tester", + "version": 2, + } + + with self.mock_put(updated_response) as m: + stream.label = "renamed-stream" + stream.status = "inactive" + stream.save() + + self.assertEqual(m.call_url, "/monitor/streams/1") + self.assertEqual(m.call_data["label"], "renamed-stream") + self.assertEqual(m.call_data["status"], "inactive") + + def test_update_stream_destinations(self): + """ + Test that update_destinations sends PUT request with flat destination ids list. + """ + stream = self.client.load(LogsStream, 1) + + with self.mock_put({}) as m: + result = stream.update_destinations([1]) + + self.assertEqual(m.call_url, "/monitor/streams/1") + self.assertEqual(m.call_data["destinations"], [1]) + self.assertTrue(result) + + def test_fail_update_stream_destinations_when_no_destination_ids_passed( + self, + ): + """ + Test that update_destinations raises exception and doesn't send PUT request when id list is empty. + """ + stream = self.client.load(LogsStream, 1) + with self.mock_put({}) as m: + with self.assertRaises(ValueError) as context: + stream.update_destinations([]) + + self.assertFalse(m.called) + self.assertIn( + "A destination id must be provided.", str(context.exception) + ) + + def test_delete_stream(self): + """ + Test that deleting a LogsStream issues a DELETE to the correct URL. + """ + stream = self.client.load(LogsStream, 1) + + with self.mock_delete() as m: + stream.delete() + + self.assertEqual(m.call_url, "/monitor/streams/1") + + +class LkeAuditLogsStreamTest(ClientBaseCase): + """ + Tests for lke_audit_logs stream type and LogsStreamDetails model. + """ + + def test_logs_stream_type_enum(self): + """LogsStreamType exposes both audit_logs and lke_audit_logs values.""" + self.assertEqual(LogsStreamType.audit_logs, "audit_logs") + self.assertEqual(LogsStreamType.lke_audit_logs, "lke_audit_logs") + + def test_load_lke_audit_logs_stream(self): + """ + Loading an lke_audit_logs stream deserializes type and details correctly. + """ + stream = self.client.load(LogsStream, 3) + + self.assertEqual(stream.id, 3) + self.assertEqual(stream.type, "lke_audit_logs") + self.assertIsInstance(stream.details, LogsStreamDetails) + self.assertEqual(stream.details.cluster_ids, [1234, 5678]) + self.assertFalse(stream.details.is_auto_add_all_clusters_enabled) + + def test_audit_logs_stream_details_is_none(self): + """An audit_logs stream has no details block.""" + stream = self.client.load(LogsStream, 1) + self.assertIsNone(stream.details) + + def test_create_lke_audit_logs_stream(self): + """ + stream_create with lke_audit_logs sends details in the payload. + """ + create_response = { + "id": 4, + "label": "new-lke-stream", + "type": "lke_audit_logs", + "status": "active", + "destinations": [ + { + "id": 1, + "label": "d", + "type": "akamai_object_storage", + "details": {}, + } + ], + "details": { + "cluster_ids": [1111, 2222], + "is_auto_add_all_clusters_enabled": False, + }, + "created": "2024-10-01T12:00:00", + "updated": "2024-10-01T12:00:00", + "created_by": "tester", + "updated_by": "tester", + "version": 1, + } + + with self.mock_post(create_response) as m: + result = self.client.monitor.stream_create( + destinations=[1], + label="new-lke-stream", + type=LogsStreamType.lke_audit_logs, + details=LogsStreamDetails( + cluster_ids=[1111, 2222], + is_auto_add_all_clusters_enabled=False, + ), + ) + + self.assertEqual(m.call_data["type"], "lke_audit_logs") + self.assertEqual(m.call_data["details"]["cluster_ids"], [1111, 2222]) + self.assertFalse( + m.call_data["details"]["is_auto_add_all_clusters_enabled"] + ) + self.assertIsInstance(result.details, LogsStreamDetails) + + def test_create_audit_logs_stream_omits_details(self): + """ + stream_create without details does not include a details key in the payload. + """ + create_response = { + "id": 5, + "label": "new-audit-stream", + "type": "audit_logs", + "status": "active", + "destinations": [ + { + "id": 1, + "label": "d", + "type": "akamai_object_storage", + "details": {}, + } + ], + "created": "2024-10-01T12:00:00", + "updated": "2024-10-01T12:00:00", + "created_by": "tester", + "updated_by": "tester", + "version": 1, + } + + with self.mock_post(create_response) as m: + self.client.monitor.stream_create( + destinations=[1], + label="new-audit-stream", + type=LogsStreamType.audit_logs, + ) + + self.assertNotIn("details", m.call_data) From 1b1eaff3258feac896d40c8067add0b7e2982eb0 Mon Sep 17 00:00:00 2001 From: mawasthy-lgtm Date: Thu, 4 Jun 2026 20:32:52 +0530 Subject: [PATCH 2/4] Updating python sdk to include group_by at dashboard level (#703) * updating group_by at dashboard level * reformatting files --------- Co-authored-by: Maciej Wilk --- linode_api4/objects/monitor.py | 1 + test/fixtures/monitor_dashboards.json | 435 ++++++++++++++++-- test/fixtures/monitor_dashboards_1.json | 107 +++-- .../monitor_services_dbaas_dashboards.json | 124 +++-- test/unit/objects/monitor_test.py | 50 +- 5 files changed, 588 insertions(+), 129 deletions(-) diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index c5f751fde..0ce4e6d05 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -278,6 +278,7 @@ class MonitorDashboard(Base): "label": Property(), "service_type": Property(ServiceType), "type": Property(DashboardType), + "group_by": Property(), "widgets": Property(json_object=DashboardWidget), "updated": Property(is_datetime=True), } diff --git a/test/fixtures/monitor_dashboards.json b/test/fixtures/monitor_dashboards.json index 5e56923a1..996a3d400 100644 --- a/test/fixtures/monitor_dashboards.json +++ b/test/fixtures/monitor_dashboards.json @@ -1,41 +1,404 @@ { "data": [ - { - "created": "2024-10-10T05:01:58", - "id": 1, - "label": "Resource Usage", - "service_type": "dbaas", - "type": "standard", - "updated": "2024-10-10T05:01:58", - "widgets": [ - { - "aggregate_function": "sum", - "chart_type": "area", - "color": "default", - "label": "CPU Usage", - "metric": "cpu_usage", - "size": 12, - "unit": "%", - "y_label": "cpu_usage", - "group_by": ["entity_id"], - "filters": null - }, - { - "aggregate_function": "sum", - "chart_type": "area", - "color": "default", - "label": "Disk I/O Write", - "metric": "write_iops", - "size": 6, - "unit": "IOPS", - "y_label": "write_iops", - "group_by": ["entity_id"], - "filters": null - } - ] - } + { + "id": 1, + "type": "standard", + "service_type": "dbaas", + "label": "Resource Usage", + "group_by": [ + "entity_id" + ], + "created": "2025-02-27T07:59:40", + "updated": "2025-10-07T01:16:40", + "widgets": [ + { + "metric": "cpu_usage", + "unit": "%", + "label": "CPU Usage", + "color": "default", + "size": 12, + "chart_type": "area", + "y_label": "cpu_usage", + "aggregate_function": "avg" + }, + { + "metric": "memory_usage", + "unit": "%", + "label": "Memory Usage", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "memory_usage", + "aggregate_function": "avg" + }, + { + "metric": "available_memory", + "unit": "GB", + "label": "Available Memory", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "available_memory", + "aggregate_function": "avg" + }, + { + "metric": "disk_usage", + "unit": "%", + "label": "Disk Space Usage", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "disk_usage", + "aggregate_function": "avg" + }, + { + "metric": "available_disk", + "unit": "GB", + "label": "Available Disk Space", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "available_disk", + "aggregate_function": "avg" + }, + { + "metric": "read_iops", + "unit": "IOPS", + "label": "Disk I/O Read", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "read_iops", + "aggregate_function": "avg" + }, + { + "metric": "write_iops", + "unit": "IOPS", + "label": "Disk I/O Write", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "write_iops", + "aggregate_function": "avg" + } + ] + }, + { + "id": 2, + "type": "standard", + "service_type": "linode", + "label": "Overview", + "group_by": [ + "entity_id" + ], + "created": "2025-05-15T07:05:56", + "updated": "2026-04-08T04:48:43", + "widgets": [ + { + "metric": "vm_cpu_time_total", + "unit": "%", + "label": "CPU Usage by Instance", + "color": "default", + "size": 12, + "chart_type": "area", + "y_label": "vm_cpu_time_total", + "aggregate_function": "avg" + }, + { + "metric": "vm_local_disk_iops_total", + "unit": "IOPS", + "label": "Local Disk I/O by Instance", + "color": "default", + "size": 12, + "chart_type": "area", + "y_label": "vm_local_disk_iops_total", + "aggregate_function": "avg" + }, + { + "metric": "vm_network_bytes_total", + "unit": "Kbps", + "label": "Network Traffic In by Instance", + "color": "default", + "size": 12, + "chart_type": "area", + "y_label": "vm_network_bytes_total", + "aggregate_function": "avg", + "filters": [ + { + "dimension_label": "pattern", + "operator": "in", + "value": "publicin,privatein" + } + ] + }, + { + "metric": "vm_network_bytes_total", + "unit": "Kbps", + "label": "Network Traffic Out by Instance", + "color": "default", + "size": 12, + "chart_type": "area", + "y_label": "vm_network_bytes_total", + "aggregate_function": "avg", + "filters": [ + { + "dimension_label": "pattern", + "operator": "in", + "value": "publicout,privateout" + } + ] + } + ] + }, + { + "id": 3, + "type": "standard", + "service_type": "nodebalancer", + "label": "Traffic Overview", + "group_by": [ + "entity_id" + ], + "created": "2025-06-05T05:02:14", + "updated": "2026-04-08T04:48:43", + "widgets": [ + { + "metric": "nb_ingress_traffic_rate", + "unit": "Bps", + "label": "Ingress Traffic Rate", + "color": "default", + "size": 12, + "chart_type": "line", + "y_label": "nb_ingress_traffic_rate", + "aggregate_function": "sum" + }, + { + "metric": "nb_egress_traffic_rate", + "unit": "Bps", + "label": "Egress Traffic Rate", + "color": "default", + "size": 12, + "chart_type": "line", + "y_label": "nb_egress_traffic_rate", + "aggregate_function": "sum" + } + ] + }, + { + "id": 4, + "type": "standard", + "service_type": "firewall", + "label": "Connection Metrics", + "group_by": [ + "entity_id", + "linode_id", + "interface_id" + ], + "created": "2025-06-25T01:24:14", + "updated": "2026-04-08T04:48:43", + "widgets": [ + { + "metric": "fw_active_connections", + "unit": "Count", + "label": "Current Connections", + "color": "default", + "size": 12, + "chart_type": "line", + "y_label": "fw_active_connections", + "aggregate_function": "avg" + } + ] + }, + { + "id": 5, + "type": "standard", + "service_type": "netloadbalancer", + "label": "Traffic Overview", + "group_by": [ + "entity_id" + ], + "created": "2025-06-25T01:25:37", + "updated": "2026-04-08T04:48:43", + "widgets": [ + { + "metric": "nlb_ingress_traffic", + "unit": "Bps", + "label": "Ingress Traffic Rate", + "color": "default", + "size": 12, + "chart_type": "line", + "y_label": "nlb_ingress_traffic", + "aggregate_function": "sum" + }, + { + "metric": "nlb_backend_ingress_traffic", + "unit": "Bps", + "label": "Ingress Traffic Rate Per Backend Node", + "color": "default", + "size": 12, + "chart_type": "line", + "y_label": "nlb_backend_ingress_traffic", + "group_by": [ + "node_id" + ], + "aggregate_function": "sum" + } + ] + }, + { + "id": 6, + "type": "standard", + "service_type": "objectstorage", + "label": "Bucket Activity", + "group_by": [ + "entity_id" + ], + "created": "2025-09-08T06:54:54", + "updated": "2026-04-08T06:01:02", + "widgets": [ + { + "metric": "obj_bucket_size", + "unit": "Bytes", + "label": "Content Stored", + "color": "default", + "size": 6, + "chart_type": "line", + "y_label": "obj_bucket_size", + "aggregate_function": "sum" + }, + { + "metric": "obj_requests_rps", + "unit": "Count", + "label": "Requests per second", + "color": "default", + "size": 6, + "chart_type": "line", + "y_label": "obj_requests_rps", + "group_by": [ + "request_type" + ], + "aggregate_function": "sum" + } + ] + }, + { + "id": 7, + "type": "standard", + "service_type": "blockstorage", + "label": "Storage Performance", + "group_by": [ + "entity_id", + "linode_id" + ], + "created": "2025-09-30T03:34:57", + "updated": "2026-04-08T04:48:43", + "widgets": [ + { + "metric": "volume_read_ops", + "unit": "Count", + "label": "Volume Read Operations", + "color": "default", + "size": 6, + "chart_type": "line", + "y_label": "volume_read_ops", + "aggregate_function": "sum" + } + ] + }, + { + "id": 8, + "type": "standard", + "service_type": "firewall", + "label": "Ingress Activity", + "group_by": [ + "entity_id", + "nodebalancer_id" + ], + "created": "2025-10-07T01:11:59", + "updated": "2026-04-08T04:48:43", + "widgets": [ + { + "metric": "nb_ingress_bytes_accepted", + "unit": "Bps", + "label": "Accepted Bytes", + "color": "default", + "size": 12, + "chart_type": "line", + "y_label": "nb_ingress_bytes_accepted", + "aggregate_function": "sum" + } + ] + }, + { + "id": 9, + "type": "standard", + "service_type": "lke", + "label": "Cluster status", + "group_by": [ + "entity_id" + ], + "created": "2025-11-07T08:51:48", + "updated": "2025-11-07T08:51:48", + "widgets": [ + { + "metric": "lke_e_ready_worker_nodes", + "unit": "Count", + "label": "Ready Worker Nodes", + "color": "default", + "size": 6, + "chart_type": "line", + "y_label": "lke_e_ready_worker_nodes", + "aggregate_function": "max" + } + ] + }, + { + "id": 10, + "type": "standard", + "service_type": "objectstorage", + "label": "Endpoint Activity", + "group_by": [ + "endpoint" + ], + "created": "2025-11-19T06:15:28", + "updated": "2026-04-08T06:01:03", + "widgets": [ + { + "metric": "obj_bucket_size", + "unit": "Bytes", + "label": "Content Stored", + "color": "default", + "size": 6, + "chart_type": "line", + "y_label": "obj_bucket_size", + "aggregate_function": "sum" + } + ] + }, + { + "id": 11, + "type": "standard", + "service_type": "logs", + "label": "Log Delivery Status", + "group_by": [ + "entity_id" + ], + "created": "2026-03-12T04:24:34", + "updated": "2026-04-07T05:54:21", + "widgets": [ + { + "metric": "success_upload_count", + "unit": "Count", + "label": "Successful Uploads", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "success_upload_count", + "aggregate_function": "sum" + } + ] + } ], "page": 1, "pages": 1, - "results": 1 - } \ No newline at end of file + "results": 11 +} diff --git a/test/fixtures/monitor_dashboards_1.json b/test/fixtures/monitor_dashboards_1.json index afb5d71ee..32e20fe1f 100644 --- a/test/fixtures/monitor_dashboards_1.json +++ b/test/fixtures/monitor_dashboards_1.json @@ -1,34 +1,83 @@ { - "created": "2024-10-10T05:01:58", "id": 1, - "label": "Resource Usage", - "service_type": "dbaas", "type": "standard", - "updated": "2024-10-10T05:01:58", + "service_type": "dbaas", + "label": "Resource Usage", + "group_by": [ + "entity_id" + ], + "created": "2025-02-27T07:59:40", + "updated": "2025-10-07T01:16:40", "widgets": [ - { - "aggregate_function": "sum", - "chart_type": "area", - "color": "default", - "label": "CPU Usage", - "metric": "cpu_usage", - "size": 12, - "unit": "%", - "y_label": "cpu_usage", - "group_by": ["entity_id"], - "filters": null - }, - { - "aggregate_function": "sum", - "chart_type": "area", - "color": "default", - "label": "Available Memory", - "metric": "available_memory", - "size": 6, - "unit": "GB", - "y_label": "available_memory", - "group_by": ["entity_id"], - "filters": null - } + { + "metric": "cpu_usage", + "unit": "%", + "label": "CPU Usage", + "color": "default", + "size": 12, + "chart_type": "area", + "y_label": "cpu_usage", + "aggregate_function": "avg" + }, + { + "metric": "memory_usage", + "unit": "%", + "label": "Memory Usage", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "memory_usage", + "aggregate_function": "avg" + }, + { + "metric": "available_memory", + "unit": "GB", + "label": "Available Memory", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "available_memory", + "aggregate_function": "avg" + }, + { + "metric": "disk_usage", + "unit": "%", + "label": "Disk Space Usage", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "disk_usage", + "aggregate_function": "avg" + }, + { + "metric": "available_disk", + "unit": "GB", + "label": "Available Disk Space", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "available_disk", + "aggregate_function": "avg" + }, + { + "metric": "read_iops", + "unit": "IOPS", + "label": "Disk I/O Read", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "read_iops", + "aggregate_function": "avg" + }, + { + "metric": "write_iops", + "unit": "IOPS", + "label": "Disk I/O Write", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "write_iops", + "aggregate_function": "avg" + } ] - } \ No newline at end of file +} diff --git a/test/fixtures/monitor_services_dbaas_dashboards.json b/test/fixtures/monitor_services_dbaas_dashboards.json index e39a231b2..1620c4453 100644 --- a/test/fixtures/monitor_services_dbaas_dashboards.json +++ b/test/fixtures/monitor_services_dbaas_dashboards.json @@ -1,48 +1,90 @@ { "data": [ - { - "created": "2024-10-10T05:01:58", - "id": 1, - "label": "Resource Usage", - "service_type": "dbaas", - "type": "standard", - "updated": "2024-10-10T05:01:58", - "widgets": [ - { - "aggregate_function": "sum", - "chart_type": "area", - "color": "default", - "label": "CPU Usage", - "metric": "cpu_usage", - "size": 12, - "unit": "%", - "y_label": "cpu_usage", - "group_by": ["entity_id"], - "filters": null - }, - { - "aggregate_function": "sum", - "chart_type": "area", - "color": "default", - "label": "Memory Usage", - "metric": "memory_usage", - "size": 6, - "unit": "%", - "y_label": "memory_usage", - "group_by": ["entity_id"], - "filters": [ - { - "dimension_label": "pattern", - "operator": "in", - "value": "publicout,privateout" - } + { + "id": 1, + "type": "standard", + "service_type": "dbaas", + "label": "Resource Usage", + "group_by": [ + "entity_id" + ], + "created": "2025-02-27T07:59:40", + "updated": "2025-10-07T01:16:40", + "widgets": [ + { + "metric": "cpu_usage", + "unit": "%", + "label": "CPU Usage", + "color": "default", + "size": 12, + "chart_type": "area", + "y_label": "cpu_usage", + "aggregate_function": "avg" + }, + { + "metric": "memory_usage", + "unit": "%", + "label": "Memory Usage", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "memory_usage", + "aggregate_function": "avg" + }, + { + "metric": "available_memory", + "unit": "GB", + "label": "Available Memory", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "available_memory", + "aggregate_function": "avg" + }, + { + "metric": "disk_usage", + "unit": "%", + "label": "Disk Space Usage", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "disk_usage", + "aggregate_function": "avg" + }, + { + "metric": "available_disk", + "unit": "GB", + "label": "Available Disk Space", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "available_disk", + "aggregate_function": "avg" + }, + { + "metric": "read_iops", + "unit": "IOPS", + "label": "Disk I/O Read", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "read_iops", + "aggregate_function": "avg" + }, + { + "metric": "write_iops", + "unit": "IOPS", + "label": "Disk I/O Write", + "color": "default", + "size": 6, + "chart_type": "area", + "y_label": "write_iops", + "aggregate_function": "avg" + } ] - - } - ] - } + } ], "page": 1, "pages": 1, "results": 1 - } \ No newline at end of file +} diff --git a/test/unit/objects/monitor_test.py b/test/unit/objects/monitor_test.py index 43985a172..c0999e485 100644 --- a/test/unit/objects/monitor_test.py +++ b/test/unit/objects/monitor_test.py @@ -41,15 +41,17 @@ def test_dashboard_by_ID(self): dashboard = self.client.load(MonitorDashboard, 1) self.assertEqual(dashboard.type, "standard") self.assertEqual( - dashboard.created, datetime.datetime(2024, 10, 10, 5, 1, 58) + dashboard.created, datetime.datetime(2025, 2, 27, 7, 59, 40) ) self.assertEqual(dashboard.id, 1) self.assertEqual(dashboard.label, "Resource Usage") self.assertEqual(dashboard.service_type, "dbaas") self.assertEqual( - dashboard.updated, datetime.datetime(2024, 10, 10, 5, 1, 58) + dashboard.updated, datetime.datetime(2025, 10, 7, 1, 16, 40) ) - self.assertEqual(dashboard.widgets[0].aggregate_function, "sum") + self.assertEqual(dashboard.group_by, ["entity_id"]) + self.assertEqual(len(dashboard.widgets), 7) + self.assertEqual(dashboard.widgets[0].aggregate_function, "avg") self.assertEqual(dashboard.widgets[0].chart_type, "area") self.assertEqual(dashboard.widgets[0].color, "default") self.assertEqual(dashboard.widgets[0].label, "CPU Usage") @@ -57,22 +59,24 @@ def test_dashboard_by_ID(self): self.assertEqual(dashboard.widgets[0].size, 12) self.assertEqual(dashboard.widgets[0].unit, "%") self.assertEqual(dashboard.widgets[0].y_label, "cpu_usage") - self.assertEqual(dashboard.widgets[0].group_by, ["entity_id"]) + self.assertIsNone(dashboard.widgets[0].group_by) self.assertIsNone(dashboard.widgets[0].filters) def test_dashboard_by_service_type(self): dashboards = self.client.monitor.dashboards(service_type="dbaas") self.assertEqual(dashboards[0].type, "standard") self.assertEqual( - dashboards[0].created, datetime.datetime(2024, 10, 10, 5, 1, 58) + dashboards[0].created, datetime.datetime(2025, 2, 27, 7, 59, 40) ) self.assertEqual(dashboards[0].id, 1) self.assertEqual(dashboards[0].label, "Resource Usage") self.assertEqual(dashboards[0].service_type, "dbaas") self.assertEqual( - dashboards[0].updated, datetime.datetime(2024, 10, 10, 5, 1, 58) + dashboards[0].updated, datetime.datetime(2025, 10, 7, 1, 16, 40) ) - self.assertEqual(dashboards[0].widgets[0].aggregate_function, "sum") + self.assertEqual(dashboards[0].group_by, ["entity_id"]) + self.assertEqual(len(dashboards[0].widgets), 7) + self.assertEqual(dashboards[0].widgets[0].aggregate_function, "avg") self.assertEqual(dashboards[0].widgets[0].chart_type, "area") self.assertEqual(dashboards[0].widgets[0].color, "default") self.assertEqual(dashboards[0].widgets[0].label, "CPU Usage") @@ -80,35 +84,30 @@ def test_dashboard_by_service_type(self): self.assertEqual(dashboards[0].widgets[0].size, 12) self.assertEqual(dashboards[0].widgets[0].unit, "%") self.assertEqual(dashboards[0].widgets[0].y_label, "cpu_usage") - self.assertEqual(dashboards[0].widgets[0].group_by, ["entity_id"]) + self.assertIsNone(dashboards[0].widgets[0].group_by) self.assertIsNone(dashboards[0].widgets[0].filters) - # Test the second widget which has filters + # Test the second widget (memory_usage, no filters) self.assertEqual(dashboards[0].widgets[1].label, "Memory Usage") - self.assertEqual(dashboards[0].widgets[1].group_by, ["entity_id"]) - self.assertIsNotNone(dashboards[0].widgets[1].filters) - self.assertEqual(len(dashboards[0].widgets[1].filters), 1) - self.assertEqual( - dashboards[0].widgets[1].filters[0].dimension_label, "pattern" - ) - self.assertEqual(dashboards[0].widgets[1].filters[0].operator, "in") - self.assertEqual( - dashboards[0].widgets[1].filters[0].value, "publicout,privateout" - ) + self.assertEqual(dashboards[0].widgets[1].aggregate_function, "avg") + self.assertIsNone(dashboards[0].widgets[1].group_by) + self.assertIsNone(dashboards[0].widgets[1].filters) def test_get_all_dashboards(self): dashboards = self.client.monitor.dashboards() + self.assertEqual(len(dashboards), 11) self.assertEqual(dashboards[0].type, "standard") self.assertEqual( - dashboards[0].created, datetime.datetime(2024, 10, 10, 5, 1, 58) + dashboards[0].created, datetime.datetime(2025, 2, 27, 7, 59, 40) ) self.assertEqual(dashboards[0].id, 1) self.assertEqual(dashboards[0].label, "Resource Usage") self.assertEqual(dashboards[0].service_type, "dbaas") self.assertEqual( - dashboards[0].updated, datetime.datetime(2024, 10, 10, 5, 1, 58) + dashboards[0].updated, datetime.datetime(2025, 10, 7, 1, 16, 40) ) - self.assertEqual(dashboards[0].widgets[0].aggregate_function, "sum") + self.assertEqual(dashboards[0].group_by, ["entity_id"]) + self.assertEqual(dashboards[0].widgets[0].aggregate_function, "avg") self.assertEqual(dashboards[0].widgets[0].chart_type, "area") self.assertEqual(dashboards[0].widgets[0].color, "default") self.assertEqual(dashboards[0].widgets[0].label, "CPU Usage") @@ -116,8 +115,13 @@ def test_get_all_dashboards(self): self.assertEqual(dashboards[0].widgets[0].size, 12) self.assertEqual(dashboards[0].widgets[0].unit, "%") self.assertEqual(dashboards[0].widgets[0].y_label, "cpu_usage") - self.assertEqual(dashboards[0].widgets[0].group_by, ["entity_id"]) + self.assertIsNone(dashboards[0].widgets[0].group_by) self.assertIsNone(dashboards[0].widgets[0].filters) + # Verify a dashboard with multiple group_by values (id=4, firewall) + self.assertEqual(dashboards[3].id, 4) + self.assertEqual( + dashboards[3].group_by, ["entity_id", "linode_id", "interface_id"] + ) def test_specific_service_details(self): data = self.client.load(MonitorService, "dbaas") From cff502a4e1bc31e56034f12ab1794a652734384e Mon Sep 17 00:00:00 2001 From: shkaruna Date: Mon, 8 Jun 2026 20:27:41 +0530 Subject: [PATCH 3/4] ref: update monitor API docstring (#702) --- linode_api4/groups/monitor.py | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/linode_api4/groups/monitor.py b/linode_api4/groups/monitor.py index 08170c8d7..375225f80 100644 --- a/linode_api4/groups/monitor.py +++ b/linode_api4/groups/monitor.py @@ -47,8 +47,6 @@ def dashboards( dashboard = client.load(MonitorDashboard, 1) dashboards_by_service = client.monitor.dashboards(service_type="dbaas") - .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. - API Documentation: - All Dashboards: https://techdocs.akamai.com/linode-api/reference/get-dashboards-all - Dashboards by Service: https://techdocs.akamai.com/linode-api/reference/get-dashboards @@ -83,8 +81,6 @@ def services( supported_services = client.monitor.services() service_details = client.monitor.load(MonitorService, "dbaas") - .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. - API Documentation: https://techdocs.akamai.com/linode-api/reference/get-monitor-services API Documentation: https://techdocs.akamai.com/linode-api/reference/get-monitor-services-for-service-type @@ -110,7 +106,6 @@ def metric_definitions( Returns metrics for a specific service type. metrics = client.monitor.list_metric_definitions(service_type="dbaas") - .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. API Documentation: https://techdocs.akamai.com/linode-api/reference/get-monitor-information @@ -136,8 +131,6 @@ def create_token( Returns a JWE Token for a specific service type. token = client.monitor.create_token(service_type="dbaas", entity_ids=[1234]) - .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. - API Documentation: https://techdocs.akamai.com/linode-api/reference/post-get-token :param service_type: The service type to create token for. @@ -175,7 +168,6 @@ def alert_definitions( alerts = client.monitor.alert_definitions() alerts_by_service = client.monitor.alert_definitions(service_type="dbaas") - .. note:: This endpoint is in beta and requires using the v4beta base URL. API Documentation: https://techdocs.akamai.com/linode-api/reference/get-alert-definitions @@ -212,8 +204,6 @@ def alert_channels(self, *filters) -> PaginatedList: Examples: channels = client.monitor.alert_channels() - .. note:: This endpoint is in beta and requires using the v4beta base URL. - API Documentation: https://techdocs.akamai.com/linode-api/reference/get-notification-channels :param filters: Optional filter expressions to apply to the collection. @@ -242,8 +232,6 @@ def create_alert_definition( The alert definition configures when alerts are fired and which channels are notified. - .. note:: This endpoint is in beta and requires using the v4beta base URL. - API Documentation: https://techdocs.akamai.com/linode-api/reference/post-alert-definition-for-service-type :param service_type: Service type for which to create the alert definition @@ -319,9 +307,7 @@ def alert_definition_entities( This endpoint supports pagination fields (`page`, `page_size`) in the API. - .. note:: This endpoint is in beta and requires using the v4beta base URL. - - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-alert-definition-entities :param service_type: Service type for the alert definition (e.g. `dbaas`). :type service_type: str From e12230d4619c3de0ae03a255c0dfa9a8d6057009 Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Mon, 8 Jun 2026 17:51:13 +0200 Subject: [PATCH 4/4] TPT-4443: Fix flaky tests (#698) * Fix flaky integration tests * Update test_domain and test_volume with waits * Update test_linode_client and test_account with waits * Add linode.invalidate() into get_linode_status * Add two more Capabilities to be aligned with the latest apinext object * Add missing API doc links for Object Storage global quotas --- linode_api4/groups/object_storage.py | 2 +- linode_api4/objects/object_storage.py | 2 +- linode_api4/objects/region.py | 2 + test/integration/conftest.py | 12 ++++ .../linode_client/test_linode_client.py | 70 +++++++++---------- .../models/account/test_account.py | 3 +- test/integration/models/domain/test_domain.py | 3 +- test/integration/models/linode/test_linode.py | 2 +- .../models/placement/test_placement.py | 2 +- 9 files changed, 55 insertions(+), 43 deletions(-) diff --git a/linode_api4/groups/object_storage.py b/linode_api4/groups/object_storage.py index d36690111..d54e1cb5a 100644 --- a/linode_api4/groups/object_storage.py +++ b/linode_api4/groups/object_storage.py @@ -539,7 +539,7 @@ def global_quotas(self, *filters): """ Lists the active account-level Object Storage quotas applied to your account. - API Documentation: TBD + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-object-storage-global-quotas :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` diff --git a/linode_api4/objects/object_storage.py b/linode_api4/objects/object_storage.py index fdb91e180..e4b027c4f 100644 --- a/linode_api4/objects/object_storage.py +++ b/linode_api4/objects/object_storage.py @@ -622,7 +622,7 @@ class ObjectStorageGlobalQuota(Base): """ An account-level Object Storage quota. - API documentation: TBD + API documentation: https://techdocs.akamai.com/linode-api/reference/get-object-storage-global-quota """ api_endpoint = "/object-storage/global-quotas/{quota_id}" diff --git a/linode_api4/objects/region.py b/linode_api4/objects/region.py index 9a77dc485..ce4ea3894 100644 --- a/linode_api4/objects/region.py +++ b/linode_api4/objects/region.py @@ -39,6 +39,7 @@ class Capability(StrEnum): lke_control_plane_acl = "LKE Network Access Control List (IP ACL)" aclb = "Akamai Cloud Load Balancer" support_ticket_severity = "Support Ticket Severity" + support_live_chat = "Support Live Chat" backups = "Backups" placement_group = "Placement Group" disk_encryption = "Disk Encryption" @@ -58,6 +59,7 @@ class Capability(StrEnum): maintenance_policy = "Maintenance Policy" vpc_dual_stack = "VPC Dual Stack" vpc_ipv6_stack = "VPC IPv6 Stack" + vpc_custom_ipv4_ranges = "Custom VPC IPv4 Ranges" nlb = "Network LoadBalancer" natgateway = "NAT Gateway" lke_e_byovpc = "Kubernetes Enterprise BYO VPC" diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 74c7a8fd5..85c86aa90 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -310,6 +310,12 @@ def test_domain(test_linode_client): domain=domain_addr, soa_email=soa_email, tags=["test-tag"] ) + def get_domain_status(): + domain.invalidate() + return domain.status == "active" + + wait_for_condition(3, 30, get_domain_status) + # Create a SRV record domain.record_create( "SRV", @@ -333,6 +339,12 @@ def test_volume(test_linode_client): volume = client.volume_create(label=label, region=region) + def get_volume_status(): + volume.invalidate() + return volume.status == "active" + + wait_for_condition(5, 45, get_volume_status) + yield volume send_request_when_resource_available(timeout=100, func=volume.delete) diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index 762462220..0cacdf437 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -1,7 +1,7 @@ import re import time from test.integration.conftest import get_region -from test.integration.helpers import get_test_label +from test.integration.helpers import get_test_label, wait_for_condition import pytest @@ -9,6 +9,13 @@ from linode_api4.objects import ConfigInterface, ObjectStorageKeys, Region +def is_tag_created(client, tag_label): + tags = client.tags() + tag_label_list = [i.label for i in tags] + + return tag_label in tag_label_list + + @pytest.fixture(scope="session") def setup_client_and_linode(test_linode_client, e2e_test_firewall): client = test_linode_client @@ -55,10 +62,11 @@ def test_fails_to_create_domain_without_soa_email(setup_client_and_linode): timestamp = str(time.time_ns()) domain_addr = timestamp + "example.com" - try: - domain = client.domain_create(domain=domain_addr) - except ApiError as e: - assert e.status == 400 + + with pytest.raises(ApiError) as exc_info: + client.domain_create(domain=domain_addr) + + assert exc_info.value.status == 400 @pytest.mark.smoke @@ -90,11 +98,16 @@ def test_get_regions(test_linode_client): def test_image_create(setup_client_and_linode): client = setup_client_and_linode[0] linode = setup_client_and_linode[1] - label = get_test_label() description = "Test description" tags = ["test"] - usable_disk = [v for v in linode.disks if v.filesystem != "swap"] + + def linode_disks_are_ready(linode_instance): + linode_instance.invalidate() + disks = [d for d in linode_instance.disks if d.filesystem != "swap"] + return disks if disks else None + + usable_disk = wait_for_condition(5, 120, linode_disks_are_ready, linode) image = client.image_create( disk=usable_disk[0].id, label=label, description=description, tags=tags @@ -116,10 +129,11 @@ def test_fails_to_create_image_with_non_existing_disk_id( description = "Test description" disk_id = 111111 - try: + with pytest.raises(ApiError) as exc_info: client.image_create(disk=disk_id, label=label, description=description) - except ApiError as e: - assert 400 <= e.status < 500 + + # TODO: Specific status code may be used when defect is solved: ARB-7797 + assert 400 <= exc_info.value.status < 500 def test_fails_to_delete_predefined_images(setup_client_and_linode): @@ -127,12 +141,11 @@ def test_fails_to_delete_predefined_images(setup_client_and_linode): images = client.images() - try: + with pytest.raises(ApiError, match="Unauthorized") as exc_info: # new images go on top of the list thus choose last image images.last().delete() - except ApiError as e: - assert "Unauthorized" in str(e.json) - assert e.status == 403 + + assert exc_info.value.status == 403 def test_get_volume(test_linode_client, test_volume): @@ -150,11 +163,7 @@ def test_get_tag(test_linode_client, test_tag): client = test_linode_client label = test_tag.label - tags = client.tags() - - tag_label_list = [i.label for i in tags] - - assert label in tag_label_list + assert wait_for_condition(3, 30, is_tag_created, client, label) def test_create_tag_with_id( @@ -176,15 +185,10 @@ def test_create_tag_with_id( volumes=[volume.id, volume], ) - # Get tags after creation - tags = client.tags() - - tag_label_list = [i.label for i in tags] + assert wait_for_condition(3, 30, is_tag_created, client, label) tag.delete() - assert label in tag_label_list - @pytest.mark.smoke def test_create_tag_with_entities( @@ -202,15 +206,10 @@ def test_create_tag_with_entities( label, entities=[linode, domain, nodebalancer, volume] ) - # Get tags after creation - tags = client.tags() - - tag_label_list = [i.label for i in tags] + assert wait_for_condition(3, 30, is_tag_created, client, label) tag.delete() - assert label in tag_label_list - # AccountGroupTests def test_get_account_settings(test_linode_client): @@ -345,16 +344,15 @@ def test_fails_to_create_cluster_with_invalid_version(test_linode_client): client = test_linode_client region = get_region(client, {"Kubernetes"}).id - try: - cluster = client.lke.cluster_create( + with pytest.raises(ApiError, match="not valid") as exc_info: + client.lke.cluster_create( region, "example-cluster", invalid_version, {"type": "g6-standard-1", "count": 3}, ) - except ApiError as e: - assert "not valid" in str(e.json) - assert e.status == 400 + + assert exc_info.value.status == 400 # ObjectStorageGroupTests diff --git a/test/integration/models/account/test_account.py b/test/integration/models/account/test_account.py index 2bb3c48f0..286e9de06 100644 --- a/test/integration/models/account/test_account.py +++ b/test/integration/models/account/test_account.py @@ -108,10 +108,11 @@ def test_latest_get_event(test_linode_client, e2e_test_firewall): ) def get_linode_status(): + linode.invalidate() return linode.status == "running" # To ensure the Linode is running and the 'event' key has been populated - wait_for_condition(3, 100, get_linode_status) + wait_for_condition(5, 150, get_linode_status) events = client.load(Event, "") latest_events = events._raw_json.get("data")[:15] diff --git a/test/integration/models/domain/test_domain.py b/test/integration/models/domain/test_domain.py index d7956d421..0ca674f1a 100644 --- a/test/integration/models/domain/test_domain.py +++ b/test/integration/models/domain/test_domain.py @@ -43,10 +43,9 @@ def test_clone(test_linode_client, test_domain): dom = "example.clone-" + timestamp + "-inttestsdk.org" domain.clone(dom) - time.sleep(1) + time.sleep(3) ds = test_linode_client.domains() - domains = [i.domain for i in ds] assert dom in domains diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index f73fbfc0a..0e677b67a 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -304,7 +304,7 @@ def test_linode_rebuild(test_linode_client): root_pass="aComplex@Password123", ) - wait_for_condition(10, 100, get_status, linode, "running") + wait_for_condition(10, 150, get_status, linode, "running") retry_sending_request( 3, diff --git a/test/integration/models/placement/test_placement.py b/test/integration/models/placement/test_placement.py index 21c6519f5..1a7e38e27 100644 --- a/test/integration/models/placement/test_placement.py +++ b/test/integration/models/placement/test_placement.py @@ -92,7 +92,7 @@ def test_pg_migration( # Says it could take up to ~6 hrs for migration to fully complete send_request_when_resource_available( - 300, + 400, linode.initiate_migration, placement_group=pg_inbound.id, migration_type=MigrationType.COLD,