From 63bb91baa967f23c939128272d0e8434d6498d45 Mon Sep 17 00:00:00 2001 From: sjerecze Date: Mon, 13 Apr 2026 13:41:56 +0200 Subject: [PATCH 01/36] Add destination API models for monitor objects --- linode_api4/objects/monitor.py | 83 ++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index 1a83b59d6..cd169d43c 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -20,6 +20,11 @@ "MonitorServiceToken", "RuleCriteria", "TriggerConditions", + "Destination", + "DestinationDetails", + "DestinationHistory", + "DestinationStatus", + "DestinationType" ] @@ -131,6 +136,15 @@ class AlertStatus(StrEnum): AlertDefinitionStatusFailed = "failed" +class DestinationType(StrEnum): + akamai_object_storage = "akamai_object_storage" + + +class DestinationStatus(StrEnum): + active = "active" + inactive = "inactive" + + @dataclass class Filter(JSONObject): """ @@ -515,3 +529,72 @@ class AlertChannel(Base): "created_by": Property(), "updated_by": Property(), } + +@dataclass +class DestinationDetails(JSONObject): + """ + Represents the details block for Destination. + 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: str - The specific path in an Object Storage bucket where audit logs files are uploaded. + """ + access_key_id: str = "" + secret_access_key: Optional[str] = None + bucket_name: str = "" + host: str = "" + path: str = "" + +class DestinationHistory(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(json_object=DestinationDetails), + "id": Property(identifier=True), + "label": Property(), + "status": Property(), + "type": Property(), + "updated": Property(is_datetime=True), + "updated_by": Property(), + "version": Property(), + } + +class Destination(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, json_object=DestinationDetails), + "id": Property(identifier=True), + "label": Property(mutable=True), + "status": Property(), + "type": Property(mutable=True), + "updated": Property(is_datetime=True), + "updated_by": Property(), + "version": Property(), + } + + @property + def history(self): + """ + Retrieves the version history for this Destination. + + API documentation: https://techdocs.akamai.com/linode-api/reference/get-destination-history + """ + return self.client._get_objects( + "{}/history".format(Destination.api_endpoint.format(id=self.id)), + DestinationHistory + ) From be7d021835b833c71a8e8f006adf1baf0c282329 Mon Sep 17 00:00:00 2001 From: sjerecze Date: Mon, 13 Apr 2026 15:22:54 +0200 Subject: [PATCH 02/36] Add destination API support in monitor group --- linode_api4/groups/monitor.py | 91 ++++++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) diff --git a/linode_api4/groups/monitor.py b/linode_api4/groups/monitor.py index 0d7f19ce8..d5f935ae7 100644 --- a/linode_api4/groups/monitor.py +++ b/linode_api4/groups/monitor.py @@ -1,6 +1,6 @@ from typing import Any, Optional, Union -from linode_api4 import PaginatedList +from linode_api4 import PaginatedList, Destination from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group from linode_api4.objects import ( @@ -332,3 +332,92 @@ def alert_definition_entities( *filters, endpoint=endpoint, ) + + def destinations(self, *filters) -> PaginatedList: + """ + List available logs destinations. + + Returns a paginated collection of :class:`Destination` 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 all active destinations + active_dests = client.monitor.destinations(Destination.status == "active") + + 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:`Destination` objects matching the query. + :rtype: PaginatedList of Destination + """ + return self.client._get_and_filter(Destination, *filters) + + def destination_create( + self, + label: str, + type: Union["DestinationType", str], + access_key_id: str, + secret_access_key: str, + bucket_name: str, + host: str, + path: Optional[str] = None, + ) -> Destination: + """ + Creates a new :any:`Destination` for logs on this account with + the given label, type, and object storage details. For example:: + + client = LinodeClient(TOKEN) + + new_destination = client.monitor.destination_create( + label="OBJ_logs_destination", + type="akamai_object_storage", + access_key_id="1ABCD23EFG4HIJKLMNO5", + secret_access_key="1aB2CD3e4fgHi5JK6lmnop7qR8STU9VxYzabcdefHh", + bucket_name="primary-bucket", + host="primary-bucket-1.us-east-12.linodeobjects.com", + path="audit-logs" + ) + + 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 for logs data sync. Currently, only akamai_object_storage is supported for use. + :type type: str or DestinationType + :param access_key_id: The unique identifier assigned to the Object Storage key required for authentication to the bucket. + :type access_key_id: str + :param secret_access_key: The Object Storage key's secret key. + :type secret_access_key: str + :param bucket_name: The name of the Object Storage bucket + :type bucket_name: str + :param host: The hostname where the Object Storage bucket can be accessed + :type host: str + :param path: (Optional Custom path for audit log storage in your Object Storage bucket. + :type path: Optional[str] + """ + params = { + "label": label, + "type": type, + "details": { + "access_key_id": access_key_id, + "secret_access_key": secret_access_key, + "bucket_name": bucket_name, + "host": host, + } + } + + if path is not None: + params["details"]["path"] = path + + result = self.client.post("/monitor/streams/destinations", data=params) + + if "id" not in result: + raise UnexpectedResponseError( + "Unexpected response when creating destination!", + json=result, + ) + + return Destination(self.client, result["id"], result) \ No newline at end of file From 66aa4679e47ab5af5838a7f756f5b5aca687d5f0 Mon Sep 17 00:00:00 2001 From: sjerecze Date: Tue, 14 Apr 2026 09:40:05 +0200 Subject: [PATCH 03/36] Destination.history client call fix --- linode_api4/objects/monitor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index cd169d43c..dd08e554b 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -594,7 +594,7 @@ def history(self): API documentation: https://techdocs.akamai.com/linode-api/reference/get-destination-history """ - return self.client._get_objects( + return self._client._get_objects( "{}/history".format(Destination.api_endpoint.format(id=self.id)), DestinationHistory ) From c6dbb16ceecf253fdba78f5aa701e0b32da815dc Mon Sep 17 00:00:00 2001 From: sjerecze Date: Tue, 14 Apr 2026 09:40:19 +0200 Subject: [PATCH 04/36] `access_key_secret` parameter name fixes --- linode_api4/groups/monitor.py | 10 +++++----- linode_api4/objects/monitor.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/linode_api4/groups/monitor.py b/linode_api4/groups/monitor.py index d5f935ae7..46f5ddb45 100644 --- a/linode_api4/groups/monitor.py +++ b/linode_api4/groups/monitor.py @@ -360,7 +360,7 @@ def destination_create( label: str, type: Union["DestinationType", str], access_key_id: str, - secret_access_key: str, + access_key_secret: str, bucket_name: str, host: str, path: Optional[str] = None, @@ -375,7 +375,7 @@ def destination_create( label="OBJ_logs_destination", type="akamai_object_storage", access_key_id="1ABCD23EFG4HIJKLMNO5", - secret_access_key="1aB2CD3e4fgHi5JK6lmnop7qR8STU9VxYzabcdefHh", + access_key_secret="1aB2CD3e4fgHi5JK6lmnop7qR8STU9VxYzabcdefHh", bucket_name="primary-bucket", host="primary-bucket-1.us-east-12.linodeobjects.com", path="audit-logs" @@ -389,8 +389,8 @@ def destination_create( :type type: str or DestinationType :param access_key_id: The unique identifier assigned to the Object Storage key required for authentication to the bucket. :type access_key_id: str - :param secret_access_key: The Object Storage key's secret key. - :type secret_access_key: str + :param access_key_secret: The Object Storage key's secret key. + :type access_key_secret: str :param bucket_name: The name of the Object Storage bucket :type bucket_name: str :param host: The hostname where the Object Storage bucket can be accessed @@ -403,7 +403,7 @@ def destination_create( "type": type, "details": { "access_key_id": access_key_id, - "secret_access_key": secret_access_key, + "access_key_secret": access_key_secret, "bucket_name": bucket_name, "host": host, } diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index dd08e554b..c3ec23cde 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -541,7 +541,7 @@ class DestinationDetails(JSONObject): - path: str - The specific path in an Object Storage bucket where audit logs files are uploaded. """ access_key_id: str = "" - secret_access_key: Optional[str] = None + access_key_secret: Optional[str] = None bucket_name: str = "" host: str = "" path: str = "" From 297a0801eeb34d134aa5eb94251b7add9c226fc8 Mon Sep 17 00:00:00 2001 From: sjerecze Date: Tue, 14 Apr 2026 10:02:29 +0200 Subject: [PATCH 05/36] Add unit tests --- .../monitor_streams_destinations.json | 24 +++ ...onitor_streams_destinations_1_history.json | 24 +++ test/unit/objects/monitor_test.py | 163 +++++++++++++++++- 3 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/monitor_streams_destinations.json create mode 100644 test/fixtures/monitor_streams_destinations_1_history.json 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/unit/objects/monitor_test.py b/test/unit/objects/monitor_test.py index 5913b3b28..94989cc98 100644 --- a/test/unit/objects/monitor_test.py +++ b/test/unit/objects/monitor_test.py @@ -1,7 +1,7 @@ import datetime from test.unit.base import ClientBaseCase -from linode_api4.objects import AlertChannel, MonitorDashboard, MonitorService +from linode_api4.objects import AlertChannel, MonitorDashboard, MonitorService, Destination class MonitorTest(ClientBaseCase): @@ -169,3 +169,164 @@ def test_alert_channels(self): "/monitor/alert-channels/123/alerts", ) self.assertEqual(channels[0].alerts.alert_count, 0) + +class DestinationTest(ClientBaseCase): + """ + Tests methods of the Destination class + """ + + def test_list_destinations(self): + """ + Test that listing destinations returns Destination objects with all fields populated. + """ + destinations = self.client.monitor.destinations() + + self.assertEqual(len(destinations), 1) + dest = destinations[0] + self.assertIsInstance(dest, Destination) + 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 DestinationDetails are deserialized correctly. + """ + dest = self.client.load(Destination, 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 DestinationHistory objects. + """ + dest = self.client.load(Destination, 1) + history = dest.history + + self.assertEqual(len(history), 1) + snapshot = history[0] + 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(self): + """ + Test that destination_create sends the right payload and returns + a Destination 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", + 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, Destination) + self.assertEqual(result.id, 2) + self.assertEqual(result.label, "new-dest") + + def test_update_destination(self): + """ + Test that mutating a Destination's mutable fields and calling save() + sends a PUT to the correct endpoint with the updated values. + """ + dest = self.client.load(Destination, 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 Destination issues a DELETE to the correct URL. + """ + dest = self.client.load(Destination, 1) + + with self.mock_delete() as m: + dest.delete() + + self.assertEqual( + m.call_url, "/monitor/streams/destinations/1" + ) From 0f58fd6aac5952753ae1568a793f8c6ca100769a Mon Sep 17 00:00:00 2001 From: sjerecze Date: Wed, 15 Apr 2026 09:41:45 +0200 Subject: [PATCH 06/36] Rename Destination to LogsDestination --- linode_api4/groups/monitor.py | 24 ++++++++++++---------- linode_api4/objects/monitor.py | 34 +++++++++++++++---------------- test/unit/objects/monitor_test.py | 30 +++++++++++++-------------- 3 files changed, 45 insertions(+), 43 deletions(-) diff --git a/linode_api4/groups/monitor.py b/linode_api4/groups/monitor.py index 46f5ddb45..f0cd54895 100644 --- a/linode_api4/groups/monitor.py +++ b/linode_api4/groups/monitor.py @@ -1,6 +1,6 @@ from typing import Any, Optional, Union -from linode_api4 import PaginatedList, Destination +from linode_api4 import PaginatedList from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group from linode_api4.objects import ( @@ -12,6 +12,8 @@ MonitorMetricsDefinition, MonitorService, MonitorServiceToken, + LogsDestination, + LogsDestinationType ) __all__ = [ @@ -337,36 +339,36 @@ def destinations(self, *filters) -> PaginatedList: """ List available logs destinations. - Returns a paginated collection of :class:`Destination` objects which + 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 all active destinations - active_dests = client.monitor.destinations(Destination.status == "active") + active_dests = client.monitor.destinations(LogsDestination.status == "active") 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:`Destination` objects matching the query. - :rtype: PaginatedList of Destination + :returns: A list of :class:`LogsDestination` objects matching the query. + :rtype: PaginatedList of LogsDestination """ - return self.client._get_and_filter(Destination, *filters) + return self.client._get_and_filter(LogsDestination, *filters) def destination_create( self, label: str, - type: Union["DestinationType", str], + type: Union[LogsDestinationType, str], access_key_id: str, access_key_secret: str, bucket_name: str, host: str, path: Optional[str] = None, - ) -> Destination: + ) -> LogsDestination: """ - Creates a new :any:`Destination` for logs on this account with + Creates a new :any:`LogsDestination` for logs on this account with the given label, type, and object storage details. For example:: client = LinodeClient(TOKEN) @@ -386,7 +388,7 @@ def destination_create( :param label: The name for this logs destination :type label: str :param type: The type of destination for logs data sync. Currently, only akamai_object_storage is supported for use. - :type type: str or DestinationType + :type type: str or LogsDestinationType :param access_key_id: The unique identifier assigned to the Object Storage key required for authentication to the bucket. :type access_key_id: str :param access_key_secret: The Object Storage key's secret key. @@ -420,4 +422,4 @@ def destination_create( json=result, ) - return Destination(self.client, result["id"], result) \ No newline at end of file + return LogsDestination(self.client, result["id"], result) \ No newline at end of file diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index c3ec23cde..2c6ae365c 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -20,11 +20,11 @@ "MonitorServiceToken", "RuleCriteria", "TriggerConditions", - "Destination", - "DestinationDetails", - "DestinationHistory", - "DestinationStatus", - "DestinationType" + "LogsDestination", + "LogsDestinationDetails", + "LogsDestinationHistory", + "LogsDestinationStatus", + "LogsDestinationType" ] @@ -136,11 +136,11 @@ class AlertStatus(StrEnum): AlertDefinitionStatusFailed = "failed" -class DestinationType(StrEnum): +class LogsDestinationType(StrEnum): akamai_object_storage = "akamai_object_storage" -class DestinationStatus(StrEnum): +class LogsDestinationStatus(StrEnum): active = "active" inactive = "inactive" @@ -531,9 +531,9 @@ class AlertChannel(Base): } @dataclass -class DestinationDetails(JSONObject): +class LogsDestinationDetails(JSONObject): """ - Represents the details block for Destination. + Represents the details block for LogsDestination. 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. @@ -546,7 +546,7 @@ class DestinationDetails(JSONObject): host: str = "" path: str = "" -class DestinationHistory(Base): +class LogsDestinationHistory(Base): """ Represents a read-only historical snapshot of a Logs Destination. @@ -555,7 +555,7 @@ class DestinationHistory(Base): properties = { "created": Property(is_datetime=True), "created_by": Property(), - "details": Property(json_object=DestinationDetails), + "details": Property(json_object=LogsDestinationDetails), "id": Property(identifier=True), "label": Property(), "status": Property(), @@ -565,7 +565,7 @@ class DestinationHistory(Base): "version": Property(), } -class Destination(Base): +class LogsDestination(Base): """ Represents a logs destination object. @@ -577,11 +577,11 @@ class Destination(Base): properties = { "created": Property(is_datetime=True), "created_by": Property(), - "details": Property(mutable=True, json_object=DestinationDetails), + "details": Property(mutable=True, json_object=LogsDestinationDetails), "id": Property(identifier=True), "label": Property(mutable=True), "status": Property(), - "type": Property(mutable=True), + "type": Property(), "updated": Property(is_datetime=True), "updated_by": Property(), "version": Property(), @@ -590,11 +590,11 @@ class Destination(Base): @property def history(self): """ - Retrieves the version history for this Destination. + 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(Destination.api_endpoint.format(id=self.id)), - DestinationHistory + "{}/history".format(LogsDestination.api_endpoint.format(id=self.id)), + LogsDestinationHistory ) diff --git a/test/unit/objects/monitor_test.py b/test/unit/objects/monitor_test.py index 94989cc98..5cba5f330 100644 --- a/test/unit/objects/monitor_test.py +++ b/test/unit/objects/monitor_test.py @@ -1,7 +1,7 @@ import datetime from test.unit.base import ClientBaseCase -from linode_api4.objects import AlertChannel, MonitorDashboard, MonitorService, Destination +from linode_api4.objects import AlertChannel, MonitorDashboard, MonitorService, LogsDestination class MonitorTest(ClientBaseCase): @@ -170,20 +170,20 @@ def test_alert_channels(self): ) self.assertEqual(channels[0].alerts.alert_count, 0) -class DestinationTest(ClientBaseCase): +class LogsDestinationTest(ClientBaseCase): """ - Tests methods of the Destination class + Tests methods for LogsDestination class """ def test_list_destinations(self): """ - Test that listing destinations returns Destination objects with all fields populated. + 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, Destination) + self.assertIsInstance(dest, LogsDestination) self.assertEqual(dest.id, 1) self.assertEqual(dest.label, "my-logs-destination") self.assertEqual(dest.type, "akamai_object_storage") @@ -200,9 +200,9 @@ def test_list_destinations(self): def test_list_destinations_details(self): """ - Test that the nested DestinationDetails are deserialized correctly. + Test that the nested LogsDestinationDetails are deserialized correctly. """ - dest = self.client.load(Destination, 1) + dest = self.client.load(LogsDestination, 1) self.assertIsNotNone(dest.details) self.assertEqual(dest.details.access_key_id, "1ABCD23EFG4HIJKLMNO5") @@ -216,9 +216,9 @@ def test_list_destinations_details(self): def test_destination_history(self): """ - Test that the history property returns DestinationHistory objects. + Test that the history property returns LogsDestinationHistory objects. """ - dest = self.client.load(Destination, 1) + dest = self.client.load(LogsDestination, 1) history = dest.history self.assertEqual(len(history), 1) @@ -237,7 +237,7 @@ def test_destination_history(self): def test_create_destination(self): """ Test that destination_create sends the right payload and returns - a Destination object. + a LogsDestination object. """ create_response = { "id": 2, @@ -282,16 +282,16 @@ def test_create_destination(self): ) self.assertEqual(m.call_data["details"]["path"], "logs/audit") - self.assertIsInstance(result, Destination) + self.assertIsInstance(result, LogsDestination) self.assertEqual(result.id, 2) self.assertEqual(result.label, "new-dest") def test_update_destination(self): """ - Test that mutating a Destination's mutable fields and calling save() + 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(Destination, 1) + dest = self.client.load(LogsDestination, 1) updated_response = { "id": 1, @@ -320,9 +320,9 @@ def test_update_destination(self): def test_delete_destination(self): """ - Test that deleting a Destination issues a DELETE to the correct URL. + Test that deleting a LogsDestination issues a DELETE to the correct URL. """ - dest = self.client.load(Destination, 1) + dest = self.client.load(LogsDestination, 1) with self.mock_delete() as m: dest.delete() From 5a05955296f831a0274abd3ca3acd141defa40ec Mon Sep 17 00:00:00 2001 From: sjerecze Date: Wed, 15 Apr 2026 09:42:00 +0200 Subject: [PATCH 07/36] Add integration tests --- .../monitor/test_monitor_logs_destination.py | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 test/integration/models/monitor/test_monitor_logs_destination.py diff --git a/test/integration/models/monitor/test_monitor_logs_destination.py b/test/integration/models/monitor/test_monitor_logs_destination.py new file mode 100644 index 000000000..3b5d17d16 --- /dev/null +++ b/test/integration/models/monitor/test_monitor_logs_destination.py @@ -0,0 +1,139 @@ +import urllib.request + +import pytest +from linode_api4 import LinodeClient, PaginatedList +from linode_api4.objects import (ObjectStorageACL, + ObjectStorageKeys, + ObjectStorageBucket) +from linode_api4.objects.monitor import ( + LogsDestination, +) +from test.integration.helpers import ( + get_test_label, + send_request_when_resource_available, + wait_for_condition, +) + + +@pytest.fixture(scope="session") +def test_object_storage_key(test_linode_client: LinodeClient): + key = test_linode_client.object_storage.keys_create( + label=get_test_label(), + ) + yield key + key.delete() + + +@pytest.fixture(scope="session") +def test_destination( + test_linode_client: LinodeClient, + test_object_storage_key: ObjectStorageKeys, +): + bucket = test_linode_client.object_storage.bucket_create( + cluster_or_region="us-southeast", + label=get_test_label(), + acl=ObjectStorageACL.PRIVATE, + cors_enabled=False, + ) + + dest = test_linode_client.monitor.destination_create( + label=get_test_label(), + type="akamai_object_storage", + access_key_id=test_object_storage_key.access_key, + access_key_secret=test_object_storage_key.secret_key, + bucket_name=bucket.label, + host=f"{bucket.label}.us-southeast-1.linodeobjects.com", + ) + + yield dest + + send_request_when_resource_available(timeout=100, func=dest.delete) + _empty_bucket(test_linode_client, bucket) + send_request_when_resource_available(timeout=100, func=bucket.delete) + + +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, test_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 test_destination.id in ids + + +def test_get_destination_by_id(test_linode_client: LinodeClient, test_destination: LogsDestination): + """ + Test that fetching destination with id filter returns correct destination. + """ + destination_by_id = test_linode_client.load(LogsDestination, test_destination.id) + + assert isinstance(destination_by_id, LogsDestination) + assert destination_by_id.id == test_destination.id + assert destination_by_id.label == test_destination.label + assert destination_by_id.type == test_destination.type + + +def test_update_destination_label( + test_linode_client: LinodeClient, + test_destination: LogsDestination, + test_object_storage_key: ObjectStorageKeys, +): + """ + Test that a LogsDestination label can be updated via save(). + """ + new_label = test_destination.label + "-upd" + new_path = "updated/logs/path/" + + dest = test_linode_client.load(LogsDestination, test_destination.id) + dest.label = new_label + dest.details.path = new_path + dest.details.access_key_secret = test_object_storage_key.secret_key + dest.save() + + updated = test_linode_client.load(LogsDestination, test_destination.id) + assert updated.label == new_label + assert updated.details.path == new_path + + +def test_destination_history(test_linode_client: LinodeClient, test_destination: LogsDestination): + """ + Test that LogsDestination.history returns version snapshots reflecting + the state before and after the label/path update performed in test_update_mutable_fields. + """ + dest = test_linode_client.load(LogsDestination, test_destination.id) + history = dest.history + + assert history is not None + assert len(history) >= 2 + + snapshot_original = next(snap for snap in history if snap.version == 1) + snapshot_updated = next(snap for snap in history if snap.version == 2) + + assert snapshot_updated.label == test_destination.label + "-upd" + assert snapshot_updated.details.path == "updated/logs/path/" + assert snapshot_updated.id == test_destination.id + + assert snapshot_original.label == test_destination.label + assert snapshot_original.details.path == None + assert snapshot_original.id == test_destination.id From f20535a476ba64bf44c0204244708af2728e0582 Mon Sep 17 00:00:00 2001 From: sjerecze Date: Thu, 16 Apr 2026 15:22:50 +0200 Subject: [PATCH 08/36] Typo fix --- linode_api4/groups/monitor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linode_api4/groups/monitor.py b/linode_api4/groups/monitor.py index f0cd54895..b0b6cf137 100644 --- a/linode_api4/groups/monitor.py +++ b/linode_api4/groups/monitor.py @@ -397,7 +397,7 @@ def destination_create( :type bucket_name: str :param host: The hostname where the Object Storage bucket can be accessed :type host: str - :param path: (Optional Custom path for audit log storage in your Object Storage bucket. + :param path: (Optional) Custom path for audit log storage in your Object Storage bucket. :type path: Optional[str] """ params = { From e2208228399d8b97eed59d50db1c8922cd8aecec Mon Sep 17 00:00:00 2001 From: sjerecze Date: Thu, 16 Apr 2026 15:23:25 +0200 Subject: [PATCH 09/36] Integration tests tweaks --- ...gs_destination.py => test_monitor_logs.py} | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) rename test/integration/models/monitor/{test_monitor_logs_destination.py => test_monitor_logs.py} (84%) diff --git a/test/integration/models/monitor/test_monitor_logs_destination.py b/test/integration/models/monitor/test_monitor_logs.py similarity index 84% rename from test/integration/models/monitor/test_monitor_logs_destination.py rename to test/integration/models/monitor/test_monitor_logs.py index 3b5d17d16..906db9ca0 100644 --- a/test/integration/models/monitor/test_monitor_logs_destination.py +++ b/test/integration/models/monitor/test_monitor_logs.py @@ -4,7 +4,8 @@ from linode_api4 import LinodeClient, PaginatedList from linode_api4.objects import (ObjectStorageACL, ObjectStorageKeys, - ObjectStorageBucket) + ObjectStorageBucket, + Capability) from linode_api4.objects.monitor import ( LogsDestination, ) @@ -15,6 +16,14 @@ ) +@pytest.fixture(scope="session", autouse=True) +def require_aclp_logs(test_linode_client: LinodeClient): + """Skip all tests in this module if the aclp_logs feature is not enabled for the account.""" + account = test_linode_client.account() + if Capability.aclp_logs not in account.capabilities: + pytest.skip("aclp_logs feature is not enabled for this account") + + @pytest.fixture(scope="session") def test_object_storage_key(test_linode_client: LinodeClient): key = test_linode_client.object_storage.keys_create( @@ -100,7 +109,8 @@ def test_update_destination_label( test_object_storage_key: ObjectStorageKeys, ): """ - Test that a LogsDestination label can be updated via save(). + Test that a LogsDestination label can be updated via save(), + and that history reflects both states. """ new_label = test_destination.label + "-upd" new_path = "updated/logs/path/" @@ -115,25 +125,17 @@ def test_update_destination_label( assert updated.label == new_label assert updated.details.path == new_path - -def test_destination_history(test_linode_client: LinodeClient, test_destination: LogsDestination): - """ - Test that LogsDestination.history returns version snapshots reflecting - the state before and after the label/path update performed in test_update_mutable_fields. - """ - dest = test_linode_client.load(LogsDestination, test_destination.id) - history = dest.history - + history = updated.history assert history is not None assert len(history) >= 2 snapshot_original = next(snap for snap in history if snap.version == 1) snapshot_updated = next(snap for snap in history if snap.version == 2) - assert snapshot_updated.label == test_destination.label + "-upd" - assert snapshot_updated.details.path == "updated/logs/path/" + assert snapshot_updated.label == new_label + assert snapshot_updated.details.path == new_path assert snapshot_updated.id == test_destination.id assert snapshot_original.label == test_destination.label - assert snapshot_original.details.path == None + assert snapshot_original.details.path is None assert snapshot_original.id == test_destination.id From f275874f1f85d8b3eaf58b0e7ca82eeb0d4ade9c Mon Sep 17 00:00:00 2001 From: sjerecze Date: Thu, 16 Apr 2026 15:59:58 +0200 Subject: [PATCH 10/36] Documentation tweaks --- linode_api4/groups/monitor.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/linode_api4/groups/monitor.py b/linode_api4/groups/monitor.py index b0b6cf137..4b8a58181 100644 --- a/linode_api4/groups/monitor.py +++ b/linode_api4/groups/monitor.py @@ -344,14 +344,16 @@ def destinations(self, *filters) -> PaginatedList: destinations; you can supply optional filter expressions to restrict the results, for example:: - # Get all active destinations - active_dests = client.monitor.destinations(LogsDestination.status == "active") + # 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 """ @@ -387,7 +389,7 @@ def destination_create( :param label: The name for this logs destination :type label: str - :param type: The type of destination for logs data sync. Currently, only akamai_object_storage is supported for use. + :param type: The type of destination for logs data sync. Currently, only ``akamai_object_storage`` is supported for use. :type type: str or LogsDestinationType :param access_key_id: The unique identifier assigned to the Object Storage key required for authentication to the bucket. :type access_key_id: str @@ -397,8 +399,11 @@ def destination_create( :type bucket_name: str :param host: The hostname where the Object Storage bucket can be accessed :type host: str - :param path: (Optional) Custom path for audit log storage in your Object Storage bucket. + :param path: (Optional) Custom path for log storage in your Object Storage bucket. :type path: Optional[str] + + :returns: The newly created logs destination. + :rtype: LogsDestination """ params = { "label": label, From 5e6be84a633d032a58e8b5fac2c5d8a775766909 Mon Sep 17 00:00:00 2001 From: sjerecze Date: Fri, 17 Apr 2026 09:54:50 +0200 Subject: [PATCH 11/36] Add negative integration test cases --- .../models/monitor/test_monitor_logs.py | 64 ++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/test/integration/models/monitor/test_monitor_logs.py b/test/integration/models/monitor/test_monitor_logs.py index 906db9ca0..2716e73ae 100644 --- a/test/integration/models/monitor/test_monitor_logs.py +++ b/test/integration/models/monitor/test_monitor_logs.py @@ -103,7 +103,7 @@ def test_get_destination_by_id(test_linode_client: LinodeClient, test_destinatio assert destination_by_id.type == test_destination.type -def test_update_destination_label( +def test_update_destination_label_and_version_history( test_linode_client: LinodeClient, test_destination: LogsDestination, test_object_storage_key: ObjectStorageKeys, @@ -139,3 +139,65 @@ def test_update_destination_label( assert snapshot_original.label == test_destination.label assert snapshot_original.details.path is None assert snapshot_original.id == test_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", + 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", + 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", + access_key_id="", + access_key_secret="", + bucket_name="", + host="", + ) + assert excinfo.value.status == 400 + len(excinfo.value.errors) == 4 + assert all( + error == "Length must be 1-255 characters" + for error in excinfo.value.errors + ) From df5f8c7b8999c5a799d3e1bd6c4a79910b6cf47c Mon Sep 17 00:00:00 2001 From: sjerecze Date: Tue, 21 Apr 2026 09:46:18 +0200 Subject: [PATCH 12/36] Fix assertion --- test/integration/models/monitor/test_monitor_logs.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/integration/models/monitor/test_monitor_logs.py b/test/integration/models/monitor/test_monitor_logs.py index 2716e73ae..ddfad9b47 100644 --- a/test/integration/models/monitor/test_monitor_logs.py +++ b/test/integration/models/monitor/test_monitor_logs.py @@ -12,7 +12,6 @@ from test.integration.helpers import ( get_test_label, send_request_when_resource_available, - wait_for_condition, ) @@ -196,7 +195,7 @@ def test_fails_to_create_destination_empty_required_fields(test_linode_client: L host="", ) assert excinfo.value.status == 400 - len(excinfo.value.errors) == 4 + assert len(excinfo.value.errors) == 4 assert all( error == "Length must be 1-255 characters" for error in excinfo.value.errors From cf5c2e2025b7e9bb66f08b7c32d66a02ecdde73a Mon Sep 17 00:00:00 2001 From: sjerecze Date: Tue, 21 Apr 2026 10:02:45 +0200 Subject: [PATCH 13/36] Formatting tweaks --- linode_api4/objects/monitor.py | 3 +++ test/integration/models/monitor/test_monitor_logs.py | 3 +++ test/unit/objects/monitor_test.py | 1 + 3 files changed, 7 insertions(+) diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index 2c6ae365c..fc0276534 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -530,6 +530,7 @@ class AlertChannel(Base): "updated_by": Property(), } + @dataclass class LogsDestinationDetails(JSONObject): """ @@ -546,6 +547,7 @@ class LogsDestinationDetails(JSONObject): host: str = "" path: str = "" + class LogsDestinationHistory(Base): """ Represents a read-only historical snapshot of a Logs Destination. @@ -565,6 +567,7 @@ class LogsDestinationHistory(Base): "version": Property(), } + class LogsDestination(Base): """ Represents a logs destination object. diff --git a/test/integration/models/monitor/test_monitor_logs.py b/test/integration/models/monitor/test_monitor_logs.py index ddfad9b47..48c075bcd 100644 --- a/test/integration/models/monitor/test_monitor_logs.py +++ b/test/integration/models/monitor/test_monitor_logs.py @@ -1,6 +1,7 @@ import urllib.request import pytest + from linode_api4 import LinodeClient, PaginatedList from linode_api4.objects import (ObjectStorageACL, ObjectStorageKeys, @@ -9,6 +10,7 @@ from linode_api4.objects.monitor import ( LogsDestination, ) + from test.integration.helpers import ( get_test_label, send_request_when_resource_available, @@ -178,6 +180,7 @@ def test_fails_to_create_destination_invalid_type(test_linode_client: LinodeClie 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 diff --git a/test/unit/objects/monitor_test.py b/test/unit/objects/monitor_test.py index 5cba5f330..e9b92154e 100644 --- a/test/unit/objects/monitor_test.py +++ b/test/unit/objects/monitor_test.py @@ -170,6 +170,7 @@ def test_alert_channels(self): ) self.assertEqual(channels[0].alerts.alert_count, 0) + class LogsDestinationTest(ClientBaseCase): """ Tests methods for LogsDestination class From e70a0e6e69bb9897079aac2841c0683687a4612e Mon Sep 17 00:00:00 2001 From: sjerecze Date: Tue, 21 Apr 2026 13:24:40 +0200 Subject: [PATCH 14/36] ACLP Logs stream - add model and group --- linode_api4/groups/monitor.py | 92 ++++++++++++++++++++++++++- linode_api4/objects/monitor.py | 112 ++++++++++++++++++++++++++++++++- 2 files changed, 200 insertions(+), 4 deletions(-) diff --git a/linode_api4/groups/monitor.py b/linode_api4/groups/monitor.py index 4b8a58181..ca55129bb 100644 --- a/linode_api4/groups/monitor.py +++ b/linode_api4/groups/monitor.py @@ -13,7 +13,10 @@ MonitorService, MonitorServiceToken, LogsDestination, - LogsDestinationType + LogsDestinationType, + LogsStream, + LogsStreamStatus, + LogsStreamType ) __all__ = [ @@ -340,7 +343,7 @@ 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 + describe logs destinations. By default, this method returns all available destinations; you can supply optional filter expressions to restrict the results, for example:: @@ -357,6 +360,7 @@ def destinations(self, *filters) -> PaginatedList: :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( @@ -405,6 +409,7 @@ def destination_create( :returns: The newly created logs destination. :rtype: LogsDestination """ + params = { "label": label, "type": type, @@ -427,4 +432,85 @@ def destination_create( json=result, ) - return LogsDestination(self.client, result["id"], result) \ No newline at end of file + 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 stream. 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 + ) -> LogsStream: + """ + Creates a new :any:`LogsStream` for logs on this account with + the given label, type, and object storage details. For example:: + + client = LinodeClient(TOKEN) + + new_stream = client.monitor.stream_create( + destinations= [1234], + label="Linode_services", + status="active", + type="audit_logs", + ) + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-stream + + :param destinations: List of unique identifiers for the sync points 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. Set this to ``audit_logs`` for logs consisting of all the control plane + operations for the services in your Linodes. + :type type: str + :param status: (Optional) The availability status of the stream. Possible values are: ``active``, ``inactive``. + Defaults to ``active``. + :type status: str + + :returns: The newly created logs stream. + :rtype: LogsStream + """ + + params = { + "label": label, + "type": type, + "destinations": destinations, + } + + if status is not None: + params["status"] = status + + 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 fc0276534..9f0ab5e8c 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -24,7 +24,12 @@ "LogsDestinationDetails", "LogsDestinationHistory", "LogsDestinationStatus", - "LogsDestinationType" + "LogsDestinationType", + "LogsStream", + "LogsStreamHistory", + "LogsStreamType", + "LogsStreamStatus", + "LogsStreamDestination" ] @@ -541,6 +546,7 @@ class LogsDestinationDetails(JSONObject): - host: str - The hostname where the Object Storage bucket can be accessed. - path: str - The specific path in an Object Storage bucket where audit logs files are uploaded. """ + access_key_id: str = "" access_key_secret: Optional[str] = None bucket_name: str = "" @@ -554,6 +560,7 @@ class LogsDestinationHistory(Base): API documentation: https://techdocs.akamai.com/linode-api/reference/get-destination-history """ + properties = { "created": Property(is_datetime=True), "created_by": Property(), @@ -597,7 +604,110 @@ def history(self): 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" + +class LogsStreamType(StrEnum): + audit_logs = "audit_logs" + +@dataclass +class LogsStreamDestination(JSONObject): + """ + Represents a destination attached to a LogsStream. + """ + + id: int = 0 + label: str = "" + type: Optional[LogsDestinationType] = None + details: Optional[LogsDestinationDetails] = None + +class LogsStreamHistory(Base): + """ + Represents a read-only historical snapshot of 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), + "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), + "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 + """ + destination_ids = [int(dest) for dest in destinations] + + payload = { + "destinations": destination_ids + } + + # 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 + ) From c6f5331075dc7069c682f95692213191776b3918 Mon Sep 17 00:00:00 2001 From: sjerecze Date: Tue, 21 Apr 2026 13:24:41 +0200 Subject: [PATCH 15/36] ACLP Logs Stream - unit tests --- test/fixtures/monitor_streams.json | 31 ++++ test/fixtures/monitor_streams_1_history.json | 31 ++++ test/unit/objects/monitor_test.py | 173 ++++++++++++++++++- 3 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/monitor_streams.json create mode 100644 test/fixtures/monitor_streams_1_history.json 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/unit/objects/monitor_test.py b/test/unit/objects/monitor_test.py index e9b92154e..b9b54d4b3 100644 --- a/test/unit/objects/monitor_test.py +++ b/test/unit/objects/monitor_test.py @@ -1,7 +1,15 @@ import datetime from test.unit.base import ClientBaseCase -from linode_api4.objects import AlertChannel, MonitorDashboard, MonitorService, LogsDestination +from linode_api4.objects import ( + AlertChannel, + MonitorDashboard, + MonitorService, + LogsDestination, + LogsDestinationHistory, + LogsStream, + LogsStreamDestination +) class MonitorTest(ClientBaseCase): @@ -224,6 +232,7 @@ def test_destination_history(self): 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") @@ -331,3 +340,165 @@ def test_delete_destination(self): self.assertEqual( m.call_url, "/monitor/streams/destinations/1" ) + +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,2,3]) + + self.assertEqual(m.call_url, "/monitor/streams/1") + self.assertEqual(m.call_data["destinations"], [1,2,3]) + 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) + assert "A Stream must have at least one destination attached." in 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") From aaa7aea24e75984c93837bb30046a7e7d71ea500 Mon Sep 17 00:00:00 2001 From: sjerecze Date: Tue, 21 Apr 2026 13:24:41 +0200 Subject: [PATCH 16/36] ACLP Logs stream - add integration tests --- .../models/monitor/test_monitor_logs.py | 232 +++++++++++++++++- 1 file changed, 222 insertions(+), 10 deletions(-) diff --git a/test/integration/models/monitor/test_monitor_logs.py b/test/integration/models/monitor/test_monitor_logs.py index 48c075bcd..72c940a55 100644 --- a/test/integration/models/monitor/test_monitor_logs.py +++ b/test/integration/models/monitor/test_monitor_logs.py @@ -1,21 +1,27 @@ +import os import urllib.request import pytest -from linode_api4 import LinodeClient, PaginatedList +from linode_api4 import LinodeClient, PaginatedList, LogsStreamType from linode_api4.objects import (ObjectStorageACL, ObjectStorageKeys, ObjectStorageBucket, Capability) from linode_api4.objects.monitor import ( LogsDestination, + LogsStream, + LogsStreamStatus, ) from test.integration.helpers import ( get_test_label, send_request_when_resource_available, + wait_for_condition, ) +RUN_ACLP_LOGS_STREAM_TESTS = "RUN_ACLP_LOGS_STREAM_TESTS" + @pytest.fixture(scope="session", autouse=True) def require_aclp_logs(test_linode_client: LinodeClient): @@ -39,26 +45,34 @@ def test_destination( test_linode_client: LinodeClient, test_object_storage_key: ObjectStorageKeys, ): - bucket = test_linode_client.object_storage.bucket_create( + dest, bucket = _create_destination_with_bucket(test_linode_client, test_object_storage_key) + yield dest + _delete_destination_with_bucket(test_linode_client, dest, bucket) + + +def _create_destination_with_bucket(client: LinodeClient, key: ObjectStorageKeys): + """Helper that creates an OBJ bucket and a logs destination backed by it.""" + bucket = client.object_storage.bucket_create( cluster_or_region="us-southeast", label=get_test_label(), acl=ObjectStorageACL.PRIVATE, cors_enabled=False, ) - - dest = test_linode_client.monitor.destination_create( + dest = client.monitor.destination_create( label=get_test_label(), type="akamai_object_storage", - access_key_id=test_object_storage_key.access_key, - access_key_secret=test_object_storage_key.secret_key, + access_key_id=key.access_key, + access_key_secret=key.secret_key, bucket_name=bucket.label, host=f"{bucket.label}.us-southeast-1.linodeobjects.com", ) + return dest, bucket - yield dest +def _delete_destination_with_bucket(client: LinodeClient, dest: LogsDestination, bucket: ObjectStorageBucket): + """Helper that deletes a logs destination and its backing OBJ bucket.""" send_request_when_resource_available(timeout=100, func=dest.delete) - _empty_bucket(test_linode_client, bucket) + _empty_bucket(client, bucket) send_request_when_resource_available(timeout=100, func=bucket.delete) @@ -117,6 +131,7 @@ def test_update_destination_label_and_version_history( new_path = "updated/logs/path/" dest = test_linode_client.load(LogsDestination, test_destination.id) + original_version = dest.version dest.label = new_label dest.details.path = new_path dest.details.access_key_secret = test_object_storage_key.secret_key @@ -130,8 +145,8 @@ def test_update_destination_label_and_version_history( assert history is not None assert len(history) >= 2 - snapshot_original = next(snap for snap in history if snap.version == 1) - snapshot_updated = next(snap for snap in history if snap.version == 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 @@ -203,3 +218,200 @@ def test_fails_to_create_destination_empty_required_fields(test_linode_client: L error == "Length must be 1-255 characters" for error in excinfo.value.errors ) + + +@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'", + ) +def test_fails_to_create_stream_invalid_destination(test_linode_client: LinodeClient): + """ + Test that creating a stream with a non-existent destination ID results in a 400 ApiError. + Requires no other streams to be present per account + """ + from linode_api4.errors import ApiError + + with pytest.raises(ApiError) as excinfo: + test_linode_client.monitor.stream_create( + label=get_test_label(), + type=LogsStreamType.audit_logs, + destinations=[999999999], + ) + assert excinfo.value.status == 400 + assert excinfo.value.errors == ['Destination not found'] + + +@pytest.fixture(scope="session") +def test_secondary_destination( + test_linode_client: LinodeClient, + test_object_storage_key: ObjectStorageKeys, +): + dest, bucket = _create_destination_with_bucket(test_linode_client, test_object_storage_key) + yield dest + _delete_destination_with_bucket(test_linode_client, dest, bucket) + + +@pytest.fixture(scope="session") +def test_stream_create(test_linode_client: LinodeClient, test_destination: LogsDestination): + stream = test_linode_client.monitor.stream_create( + label=get_test_label(), + destinations=[test_destination.id], + type=LogsStreamType.audit_logs + ) + assert stream.id is not None + assert stream.status == LogsStreamStatus.provisioning + yield stream + send_request_when_resource_available(timeout=100, func=stream.delete) + + + +@pytest.fixture(scope="session") +def test_stream_active(test_linode_client: LinodeClient, test_stream_create: 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, test_stream_create.id) + return stream.status in (LogsStreamStatus.active, LogsStreamStatus.inactive) + + wait_for_condition(60, 3600, is_stream_provisioned) + + return test_linode_client.load(LogsStream, test_stream_create.id) + + +@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'", +) +def test_list_streams(test_linode_client: LinodeClient, test_stream_active: 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 test_stream_active.id in ids + + +@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'", +) +def test_get_stream_by_id(test_linode_client: LinodeClient, test_stream_active: LogsStream): + """ + Test that loading a stream by ID returns the correct stream with expected fields. + """ + stream = test_linode_client.load(LogsStream, test_stream_active.id) + + assert isinstance(stream, LogsStream) + assert stream.id == test_stream_active.id + assert stream.label == test_stream_active.label + assert stream.status in (LogsStreamStatus.active, LogsStreamStatus.inactive) + assert len(stream.destinations) == 1 + + +@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'", +) +def test_update_stream_label(test_linode_client: LinodeClient, test_stream_active: LogsStream): + """ + Test that a LogsStream label can be updated via save() and that the version + history reflects the change. + """ + new_label = test_stream_active.label + "-upd" + + stream = test_linode_client.load(LogsStream, test_stream_active.id) + original_label = stream.label + version_before = stream.version + + stream.label = new_label + result = stream.save() + + assert result is True + + updated = test_linode_client.load(LogsStream, test_stream_active.id) + assert updated.label == new_label + + 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 == test_stream_active.id + + # Revert to original label + updated.label = original_label + updated.save() + + + +@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'", +) +def test_update_stream_status(test_linode_client: LinodeClient, test_stream_active: LogsStream): + """ + Test that a LogsStream status can be toggled between active and inactive via save(). + """ + stream = test_linode_client.load(LogsStream, test_stream_active.id) + original_status = stream.status + + new_status = ( + LogsStreamStatus.inactive + if original_status == LogsStreamStatus.active + else LogsStreamStatus.active + ) + + stream.status = new_status + result = stream.save() + assert result is True + + updated = test_linode_client.load(LogsStream, test_stream_active.id) + assert updated.status == new_status + + #Revert to original status + stream.status=original_status + stream.save() + + +@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'", +) +def test_update_stream_destinations( + test_linode_client: LinodeClient, + test_stream_active: LogsStream, + test_destination: LogsDestination, + test_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, test_stream_active.id) + original_destinations = [stream.destinations[0].id] + version_before = stream.version + + result = stream.update_destinations([test_secondary_destination.id]) + assert result is True + + updated = test_linode_client.load(LogsStream, test_stream_active.id) + assert len(updated.destinations) == 1 + assert updated.destinations[0].id == test_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 == test_secondary_destination.id + + # Revert to original destination + updated.update_destinations(original_destinations) From da52b006140a625d79cfd5f7352e889df7857aa0 Mon Sep 17 00:00:00 2001 From: sjerecze Date: Tue, 21 Apr 2026 13:46:31 +0200 Subject: [PATCH 17/36] ACLP Logs Stream - ensure update reverts on failed assertions --- .../models/monitor/test_monitor_logs.py | 57 ++++++++++--------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/test/integration/models/monitor/test_monitor_logs.py b/test/integration/models/monitor/test_monitor_logs.py index 72c940a55..bcdad1106 100644 --- a/test/integration/models/monitor/test_monitor_logs.py +++ b/test/integration/models/monitor/test_monitor_logs.py @@ -338,17 +338,18 @@ def test_update_stream_label(test_linode_client: LinodeClient, test_stream_activ updated = test_linode_client.load(LogsStream, test_stream_active.id) assert updated.label == new_label - 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 == test_stream_active.id + try: + 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) - # Revert to original label - updated.label = original_label - updated.save() + assert snapshot_original.label == original_label + assert snapshot_updated.label == new_label + assert snapshot_updated.id == test_stream_active.id + finally: + # Revert to original label + updated.label = original_label + updated.save() @@ -373,12 +374,13 @@ def test_update_stream_status(test_linode_client: LinodeClient, test_stream_acti result = stream.save() assert result is True - updated = test_linode_client.load(LogsStream, test_stream_active.id) - assert updated.status == new_status - - #Revert to original status - stream.status=original_status - stream.save() + try: + updated = test_linode_client.load(LogsStream, test_stream_active.id) + assert updated.status == new_status + finally: + # Revert to original status + stream.status = original_status + stream.save() @pytest.mark.skipif( @@ -402,16 +404,17 @@ def test_update_stream_destinations( result = stream.update_destinations([test_secondary_destination.id]) assert result is True - updated = test_linode_client.load(LogsStream, test_stream_active.id) - assert len(updated.destinations) == 1 - assert updated.destinations[0].id == test_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) + try: + updated = test_linode_client.load(LogsStream, test_stream_active.id) + assert len(updated.destinations) == 1 + assert updated.destinations[0].id == test_secondary_destination.id - assert snapshot_original.destinations[0].id == original_destinations[0] - assert snapshot_updated.destinations[0].id == test_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) - # Revert to original destination - updated.update_destinations(original_destinations) + assert snapshot_original.destinations[0].id == original_destinations[0] + assert snapshot_updated.destinations[0].id == test_secondary_destination.id + finally: + # Revert to original destination + stream.update_destinations(original_destinations) From 754489248472e98df8ed8ec99debdabdd4215ded Mon Sep 17 00:00:00 2001 From: sjerecze Date: Tue, 21 Apr 2026 13:56:47 +0200 Subject: [PATCH 18/36] ACLP Logs Stream - Formatting tweaks --- linode_api4/objects/monitor.py | 5 +++++ test/integration/models/monitor/test_monitor_logs.py | 5 ++--- test/unit/objects/monitor_test.py | 5 +++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index 9f0ab5e8c..42f69a58a 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -610,14 +610,17 @@ def history(self): LogsDestinationHistory ) + class LogsStreamStatus(StrEnum): active = "active" inactive = "inactive" provisioning = "provisioning" + class LogsStreamType(StrEnum): audit_logs = "audit_logs" + @dataclass class LogsStreamDestination(JSONObject): """ @@ -629,6 +632,7 @@ class LogsStreamDestination(JSONObject): type: Optional[LogsDestinationType] = None details: Optional[LogsDestinationDetails] = None + class LogsStreamHistory(Base): """ Represents a read-only historical snapshot of logs Stream. @@ -649,6 +653,7 @@ class LogsStreamHistory(Base): "version": Property(), } + class LogsStream(Base): """ Represents a logs stream object. diff --git a/test/integration/models/monitor/test_monitor_logs.py b/test/integration/models/monitor/test_monitor_logs.py index bcdad1106..79f9fd0c3 100644 --- a/test/integration/models/monitor/test_monitor_logs.py +++ b/test/integration/models/monitor/test_monitor_logs.py @@ -223,7 +223,7 @@ def test_fails_to_create_destination_empty_required_fields(test_linode_client: L @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'", - ) +) def test_fails_to_create_stream_invalid_destination(test_linode_client: LinodeClient): """ Test that creating a stream with a non-existent destination ID results in a 400 ApiError. @@ -264,13 +264,13 @@ def test_stream_create(test_linode_client: LinodeClient, test_destination: LogsD send_request_when_resource_available(timeout=100, func=stream.delete) - @pytest.fixture(scope="session") def test_stream_active(test_linode_client: LinodeClient, test_stream_create: 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, test_stream_create.id) return stream.status in (LogsStreamStatus.active, LogsStreamStatus.inactive) @@ -352,7 +352,6 @@ def test_update_stream_label(test_linode_client: LinodeClient, test_stream_activ updated.save() - @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'", diff --git a/test/unit/objects/monitor_test.py b/test/unit/objects/monitor_test.py index b9b54d4b3..041c9749c 100644 --- a/test/unit/objects/monitor_test.py +++ b/test/unit/objects/monitor_test.py @@ -341,6 +341,7 @@ def test_delete_destination(self): m.call_url, "/monitor/streams/destinations/1" ) + class LogsStreamTest(ClientBaseCase): """ Tests methods for LogsStream class. @@ -472,10 +473,10 @@ def test_update_stream_destinations(self): stream = self.client.load(LogsStream, 1) with self.mock_put({}) as m: - result = stream.update_destinations([1,2,3]) + result = stream.update_destinations([1, 2, 3]) self.assertEqual(m.call_url, "/monitor/streams/1") - self.assertEqual(m.call_data["destinations"], [1,2,3]) + self.assertEqual(m.call_data["destinations"], [1, 2, 3]) self.assertTrue(result) def test_fail_update_stream_destinations_when_no_destination_ids_passed(self): From 76a62afc8e5e30e67e677b20c5a3acb6032fecb5 Mon Sep 17 00:00:00 2001 From: sjerecze Date: Tue, 21 Apr 2026 14:29:03 +0200 Subject: [PATCH 19/36] ACLP Logs Stream - copilot review tweaks --- linode_api4/objects/monitor.py | 6 +++--- .../models/monitor/test_monitor_logs.py | 20 ++++++++++++------- test/unit/objects/monitor_test.py | 2 +- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index 42f69a58a..ce582a533 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -689,10 +689,10 @@ def update_destinations(self, destinations: List[int]): :returns: True if the update was successful. :rtype: bool """ - destination_ids = [int(dest) for dest in destinations] - + if not destinations: + raise ValueError("A destination id must be provided.") payload = { - "destinations": destination_ids + "destinations": destinations } # The Linode API PUT request expects the flat list of IDs diff --git a/test/integration/models/monitor/test_monitor_logs.py b/test/integration/models/monitor/test_monitor_logs.py index 79f9fd0c3..29bf69c99 100644 --- a/test/integration/models/monitor/test_monitor_logs.py +++ b/test/integration/models/monitor/test_monitor_logs.py @@ -227,10 +227,18 @@ def test_fails_to_create_destination_empty_required_fields(test_linode_client: L def test_fails_to_create_stream_invalid_destination(test_linode_client: LinodeClient): """ Test that creating a stream with a non-existent destination ID results in a 400 ApiError. - Requires no other streams to be present per account + Requires no other streams to be present on account. If a stream is already present test is skipped. """ from linode_api4.errors import ApiError + existing_streams = test_linode_client.monitor.streams() + if len(existing_streams) > 0: + stream_ids = [s.id for s in existing_streams] + pytest.skip( + f"Skipping: existing stream(s) found on this account " + f"(ID: {stream_ids}). Only one stream can be present per account. " + ) + with pytest.raises(ApiError) as excinfo: test_linode_client.monitor.stream_create( label=get_test_label(), @@ -332,13 +340,11 @@ def test_update_stream_label(test_linode_client: LinodeClient, test_stream_activ stream.label = new_label result = stream.save() - assert result is True - updated = test_linode_client.load(LogsStream, test_stream_active.id) - assert updated.label == new_label - try: + updated = test_linode_client.load(LogsStream, test_stream_active.id) + assert updated.label == new_label 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) @@ -348,8 +354,8 @@ def test_update_stream_label(test_linode_client: LinodeClient, test_stream_activ assert snapshot_updated.id == test_stream_active.id finally: # Revert to original label - updated.label = original_label - updated.save() + stream.label = original_label + stream.save() @pytest.mark.skipif( diff --git a/test/unit/objects/monitor_test.py b/test/unit/objects/monitor_test.py index 041c9749c..9e82f14d5 100644 --- a/test/unit/objects/monitor_test.py +++ b/test/unit/objects/monitor_test.py @@ -489,7 +489,7 @@ def test_fail_update_stream_destinations_when_no_destination_ids_passed(self): stream.update_destinations([]) self.assertFalse(m.called) - assert "A Stream must have at least one destination attached." in str( + assert "A destination id must be provided." in str( context.exception ) From 2b53808aea1cad48f78a22932d936d3a805c7e00 Mon Sep 17 00:00:00 2001 From: sjerecze Date: Tue, 21 Apr 2026 14:57:19 +0200 Subject: [PATCH 20/36] ACLP Logs Stream - review tweaks - pt. 2 --- linode_api4/groups/monitor.py | 4 +- linode_api4/objects/monitor.py | 4 +- .../models/monitor/test_monitor_logs.py | 68 +++++++++---------- test/unit/objects/monitor_test.py | 6 +- 4 files changed, 40 insertions(+), 42 deletions(-) diff --git a/linode_api4/groups/monitor.py b/linode_api4/groups/monitor.py index ca55129bb..853ec64a2 100644 --- a/linode_api4/groups/monitor.py +++ b/linode_api4/groups/monitor.py @@ -16,7 +16,7 @@ LogsDestinationType, LogsStream, LogsStreamStatus, - LogsStreamType + LogsStreamType, ) __all__ = [ @@ -439,7 +439,7 @@ def streams(self, *filters) -> PaginatedList: List available logs streams. Returns a paginated collection of :class:`LogsStream` objects which - describe logs stream. By default, this method returns all available + describe logs streams. By default, this method returns all available streams; you can supply optional filter expressions to restrict the results, for example:: diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index ce582a533..bcb81de5f 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -29,7 +29,7 @@ "LogsStreamHistory", "LogsStreamType", "LogsStreamStatus", - "LogsStreamDestination" + "LogsStreamDestination", ] @@ -635,7 +635,7 @@ class LogsStreamDestination(JSONObject): class LogsStreamHistory(Base): """ - Represents a read-only historical snapshot of logs Stream. + Represents a read-only historical snapshot of a logs stream. API documentation: https://techdocs.akamai.com/linode-api/reference/get-stream-history """ diff --git a/test/integration/models/monitor/test_monitor_logs.py b/test/integration/models/monitor/test_monitor_logs.py index 29bf69c99..a264678f1 100644 --- a/test/integration/models/monitor/test_monitor_logs.py +++ b/test/integration/models/monitor/test_monitor_logs.py @@ -32,7 +32,7 @@ def require_aclp_logs(test_linode_client: LinodeClient): @pytest.fixture(scope="session") -def test_object_storage_key(test_linode_client: LinodeClient): +def create_object_storage_key(test_linode_client: LinodeClient): key = test_linode_client.object_storage.keys_create( label=get_test_label(), ) @@ -43,9 +43,9 @@ def test_object_storage_key(test_linode_client: LinodeClient): @pytest.fixture(scope="session") def test_destination( test_linode_client: LinodeClient, - test_object_storage_key: ObjectStorageKeys, + create_object_storage_key: ObjectStorageKeys, ): - dest, bucket = _create_destination_with_bucket(test_linode_client, test_object_storage_key) + dest, bucket = _create_destination_with_bucket(test_linode_client, create_object_storage_key) yield dest _delete_destination_with_bucket(test_linode_client, dest, bucket) @@ -121,7 +121,7 @@ def test_get_destination_by_id(test_linode_client: LinodeClient, test_destinatio def test_update_destination_label_and_version_history( test_linode_client: LinodeClient, test_destination: LogsDestination, - test_object_storage_key: ObjectStorageKeys, + create_object_storage_key: ObjectStorageKeys, ): """ Test that a LogsDestination label can be updated via save(), @@ -134,7 +134,7 @@ def test_update_destination_label_and_version_history( original_version = dest.version dest.label = new_label dest.details.path = new_path - dest.details.access_key_secret = test_object_storage_key.secret_key + dest.details.access_key_secret = create_object_storage_key.secret_key dest.save() updated = test_linode_client.load(LogsDestination, test_destination.id) @@ -250,17 +250,17 @@ def test_fails_to_create_stream_invalid_destination(test_linode_client: LinodeCl @pytest.fixture(scope="session") -def test_secondary_destination( +def create_secondary_destination( test_linode_client: LinodeClient, - test_object_storage_key: ObjectStorageKeys, + create_object_storage_key: ObjectStorageKeys, ): - dest, bucket = _create_destination_with_bucket(test_linode_client, test_object_storage_key) + dest, bucket = _create_destination_with_bucket(test_linode_client, create_object_storage_key) yield dest _delete_destination_with_bucket(test_linode_client, dest, bucket) @pytest.fixture(scope="session") -def test_stream_create(test_linode_client: LinodeClient, test_destination: LogsDestination): +def create_stream(test_linode_client: LinodeClient, test_destination: LogsDestination): stream = test_linode_client.monitor.stream_create( label=get_test_label(), destinations=[test_destination.id], @@ -273,26 +273,26 @@ def test_stream_create(test_linode_client: LinodeClient, test_destination: LogsD @pytest.fixture(scope="session") -def test_stream_active(test_linode_client: LinodeClient, test_stream_create: LogsStream): +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, test_stream_create.id) + stream = test_linode_client.load(LogsStream, create_stream.id) return stream.status in (LogsStreamStatus.active, LogsStreamStatus.inactive) wait_for_condition(60, 3600, is_stream_provisioned) - return test_linode_client.load(LogsStream, test_stream_create.id) + yield test_linode_client.load(LogsStream, create_stream.id) @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'", ) -def test_list_streams(test_linode_client: LinodeClient, test_stream_active: LogsStream): +def test_list_streams(test_linode_client: LinodeClient, provisioned_stream: LogsStream): """ Test that listing streams returns a PaginatedList containing the previously created stream. """ @@ -303,23 +303,23 @@ def test_list_streams(test_linode_client: LinodeClient, test_stream_active: Logs assert all(isinstance(s, LogsStream) for s in streams) ids = [s.id for s in streams] - assert test_stream_active.id in ids + assert provisioned_stream.id in ids @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'", ) -def test_get_stream_by_id(test_linode_client: LinodeClient, test_stream_active: LogsStream): +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, test_stream_active.id) + stream = test_linode_client.load(LogsStream, provisioned_stream.id) assert isinstance(stream, LogsStream) - assert stream.id == test_stream_active.id - assert stream.label == test_stream_active.label - assert stream.status in (LogsStreamStatus.active, LogsStreamStatus.inactive) + assert stream.id == provisioned_stream.id + assert stream.label == provisioned_stream.label + assert stream.status == provisioned_stream.status assert len(stream.destinations) == 1 @@ -327,14 +327,14 @@ def test_get_stream_by_id(test_linode_client: LinodeClient, test_stream_active: 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'", ) -def test_update_stream_label(test_linode_client: LinodeClient, test_stream_active: LogsStream): +def test_update_stream_label(test_linode_client: LinodeClient, provisioned_stream: LogsStream): """ Test that a LogsStream label can be updated via save() and that the version history reflects the change. """ - new_label = test_stream_active.label + "-upd" + new_label = provisioned_stream.label + "-upd" - stream = test_linode_client.load(LogsStream, test_stream_active.id) + stream = test_linode_client.load(LogsStream, provisioned_stream.id) original_label = stream.label version_before = stream.version @@ -343,7 +343,7 @@ def test_update_stream_label(test_linode_client: LinodeClient, test_stream_activ assert result is True try: - updated = test_linode_client.load(LogsStream, test_stream_active.id) + updated = test_linode_client.load(LogsStream, provisioned_stream.id) assert updated.label == new_label history = updated.history snapshot_original = next(h for h in history if h.version == version_before) @@ -351,7 +351,7 @@ def test_update_stream_label(test_linode_client: LinodeClient, test_stream_activ assert snapshot_original.label == original_label assert snapshot_updated.label == new_label - assert snapshot_updated.id == test_stream_active.id + assert snapshot_updated.id == provisioned_stream.id finally: # Revert to original label stream.label = original_label @@ -362,11 +362,11 @@ def test_update_stream_label(test_linode_client: LinodeClient, test_stream_activ 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'", ) -def test_update_stream_status(test_linode_client: LinodeClient, test_stream_active: LogsStream): +def test_update_stream_status(test_linode_client: LinodeClient, provisioned_stream: LogsStream): """ Test that a LogsStream status can be toggled between active and inactive via save(). """ - stream = test_linode_client.load(LogsStream, test_stream_active.id) + stream = test_linode_client.load(LogsStream, provisioned_stream.id) original_status = stream.status new_status = ( @@ -380,7 +380,7 @@ def test_update_stream_status(test_linode_client: LinodeClient, test_stream_acti assert result is True try: - updated = test_linode_client.load(LogsStream, test_stream_active.id) + updated = test_linode_client.load(LogsStream, provisioned_stream.id) assert updated.status == new_status finally: # Revert to original status @@ -394,32 +394,32 @@ def test_update_stream_status(test_linode_client: LinodeClient, test_stream_acti ) def test_update_stream_destinations( test_linode_client: LinodeClient, - test_stream_active: LogsStream, + provisioned_stream: LogsStream, test_destination: LogsDestination, - test_secondary_destination: LogsDestination, + 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, test_stream_active.id) + stream = test_linode_client.load(LogsStream, provisioned_stream.id) original_destinations = [stream.destinations[0].id] version_before = stream.version - result = stream.update_destinations([test_secondary_destination.id]) + result = stream.update_destinations([create_secondary_destination.id]) assert result is True try: - updated = test_linode_client.load(LogsStream, test_stream_active.id) + updated = test_linode_client.load(LogsStream, provisioned_stream.id) assert len(updated.destinations) == 1 - assert updated.destinations[0].id == test_secondary_destination.id + 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 == test_secondary_destination.id + assert snapshot_updated.destinations[0].id == create_secondary_destination.id finally: # Revert to original destination stream.update_destinations(original_destinations) diff --git a/test/unit/objects/monitor_test.py b/test/unit/objects/monitor_test.py index 9e82f14d5..629c5ae69 100644 --- a/test/unit/objects/monitor_test.py +++ b/test/unit/objects/monitor_test.py @@ -8,7 +8,7 @@ LogsDestination, LogsDestinationHistory, LogsStream, - LogsStreamDestination + LogsStreamDestination, ) @@ -489,9 +489,7 @@ def test_fail_update_stream_destinations_when_no_destination_ids_passed(self): stream.update_destinations([]) self.assertFalse(m.called) - assert "A destination id must be provided." in str( - context.exception - ) + self.assertIn("A destination id must be provided.", str(context.exception)) def test_delete_stream(self): """ From e0d650f3218eec080af0f932f7c927438aaf9e41 Mon Sep 17 00:00:00 2001 From: sjerecze Date: Wed, 22 Apr 2026 09:01:01 +0200 Subject: [PATCH 21/36] ACLP Logs Stream - review tweaks - pt. 3 --- .../models/monitor/test_monitor_logs.py | 37 ++++++------------- 1 file changed, 11 insertions(+), 26 deletions(-) diff --git a/test/integration/models/monitor/test_monitor_logs.py b/test/integration/models/monitor/test_monitor_logs.py index a264678f1..6e010546e 100644 --- a/test/integration/models/monitor/test_monitor_logs.py +++ b/test/integration/models/monitor/test_monitor_logs.py @@ -20,7 +20,11 @@ wait_for_condition, ) -RUN_ACLP_LOGS_STREAM_TESTS = "RUN_ACLP_LOGS_STREAM_TESTS" +_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'", +) @pytest.fixture(scope="session", autouse=True) @@ -220,10 +224,7 @@ def test_fails_to_create_destination_empty_required_fields(test_linode_client: L ) -@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'", -) +@_SKIP_STREAM_TESTS def test_fails_to_create_stream_invalid_destination(test_linode_client: LinodeClient): """ Test that creating a stream with a non-existent destination ID results in a 400 ApiError. @@ -288,10 +289,7 @@ def is_stream_provisioned(): yield test_linode_client.load(LogsStream, create_stream.id) -@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'", -) +@_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. @@ -306,10 +304,7 @@ def test_list_streams(test_linode_client: LinodeClient, provisioned_stream: Logs assert provisioned_stream.id in ids -@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'", -) +@_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. @@ -323,10 +318,7 @@ def test_get_stream_by_id(test_linode_client: LinodeClient, provisioned_stream: assert len(stream.destinations) == 1 -@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'", -) +@_SKIP_STREAM_TESTS def test_update_stream_label(test_linode_client: LinodeClient, provisioned_stream: LogsStream): """ Test that a LogsStream label can be updated via save() and that the version @@ -358,10 +350,7 @@ def test_update_stream_label(test_linode_client: LinodeClient, provisioned_strea stream.save() -@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'", -) +@_SKIP_STREAM_TESTS def test_update_stream_status(test_linode_client: LinodeClient, provisioned_stream: LogsStream): """ Test that a LogsStream status can be toggled between active and inactive via save(). @@ -388,14 +377,10 @@ def test_update_stream_status(test_linode_client: LinodeClient, provisioned_stre stream.save() -@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'", -) +@_SKIP_STREAM_TESTS def test_update_stream_destinations( test_linode_client: LinodeClient, provisioned_stream: LogsStream, - test_destination: LogsDestination, create_secondary_destination: LogsDestination, ): """ From 0326e3e2f6c00f6752bb7f7f7cc7475c69c9be94 Mon Sep 17 00:00:00 2001 From: sjerecze Date: Wed, 22 Apr 2026 10:17:11 +0200 Subject: [PATCH 22/36] ACLP Logs Stream - remove redundant comma --- linode_api4/groups/monitor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linode_api4/groups/monitor.py b/linode_api4/groups/monitor.py index 853ec64a2..e00cb0fab 100644 --- a/linode_api4/groups/monitor.py +++ b/linode_api4/groups/monitor.py @@ -474,7 +474,7 @@ def stream_create( destinations= [1234], label="Linode_services", status="active", - type="audit_logs", + type="audit_logs" ) API Documentation: https://techdocs.akamai.com/linode-api/reference/post-stream From 3b13cece236d5ddddaab6d063f4a8b30e28f9ca1 Mon Sep 17 00:00:00 2001 From: sjerecze Date: Wed, 22 Apr 2026 15:27:13 +0200 Subject: [PATCH 23/36] ACLP Logs Stream - Merge save() method integration tests, add skip guard if stream already exists --- .../models/monitor/test_monitor_logs.py | 71 +++++++++---------- 1 file changed, 32 insertions(+), 39 deletions(-) diff --git a/test/integration/models/monitor/test_monitor_logs.py b/test/integration/models/monitor/test_monitor_logs.py index 6e010546e..7caf9ef25 100644 --- a/test/integration/models/monitor/test_monitor_logs.py +++ b/test/integration/models/monitor/test_monitor_logs.py @@ -80,6 +80,18 @@ def _delete_destination_with_bucket(client: LinodeClient, dest: LogsDestination, 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. @@ -232,13 +244,7 @@ def test_fails_to_create_stream_invalid_destination(test_linode_client: LinodeCl """ from linode_api4.errors import ApiError - existing_streams = test_linode_client.monitor.streams() - if len(existing_streams) > 0: - stream_ids = [s.id for s in existing_streams] - pytest.skip( - f"Skipping: existing stream(s) found on this account " - f"(ID: {stream_ids}). Only one stream can be present per account. " - ) + _skip_if_streams_exist(test_linode_client) with pytest.raises(ApiError) as excinfo: test_linode_client.monitor.stream_create( @@ -262,6 +268,8 @@ def create_secondary_destination( @pytest.fixture(scope="session") def create_stream(test_linode_client: LinodeClient, test_destination: LogsDestination): + _skip_if_streams_exist(test_linode_client) + stream = test_linode_client.monitor.stream_create( label=get_test_label(), destinations=[test_destination.id], @@ -319,60 +327,45 @@ def test_get_stream_by_id(test_linode_client: LinodeClient, provisioned_stream: @_SKIP_STREAM_TESTS -def test_update_stream_label(test_linode_client: LinodeClient, provisioned_stream: LogsStream): +def test_update_stream_label_and_status(test_linode_client: LinodeClient, provisioned_stream: LogsStream): """ - Test that a LogsStream label can be updated via save() and that the version - history reflects the change. + Test that a LogsStream label and status can both be updated via save(), and that + the version history reflects both the label and status changes across versions. """ - new_label = provisioned_stream.label + "-upd" - 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 + if original_status == LogsStreamStatus.active + else LogsStreamStatus.active + ) + stream.label = new_label + stream.status = new_status result = stream.save() assert result is True try: updated = test_linode_client.load(LogsStream, provisioned_stream.id) assert updated.label == new_label + assert updated.status == 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_original.status == original_status assert snapshot_updated.label == new_label + assert snapshot_updated.status == new_status assert snapshot_updated.id == provisioned_stream.id finally: - # Revert to original label + # Revert to original label and status stream.label = original_label - stream.save() - - -@_SKIP_STREAM_TESTS -def test_update_stream_status(test_linode_client: LinodeClient, provisioned_stream: LogsStream): - """ - Test that a LogsStream status can be toggled between active and inactive via save(). - """ - stream = test_linode_client.load(LogsStream, provisioned_stream.id) - original_status = stream.status - - new_status = ( - LogsStreamStatus.inactive - if original_status == LogsStreamStatus.active - else LogsStreamStatus.active - ) - - stream.status = new_status - result = stream.save() - assert result is True - - try: - updated = test_linode_client.load(LogsStream, provisioned_stream.id) - assert updated.status == new_status - finally: - # Revert to original status stream.status = original_status stream.save() From 05b299ecb63ff1f740c247c05c2a978ed0f45e08 Mon Sep 17 00:00:00 2001 From: sjer-akamai Date: Thu, 23 Apr 2026 08:30:08 +0200 Subject: [PATCH 24/36] Update test/unit/objects/monitor_test.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- test/unit/objects/monitor_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/unit/objects/monitor_test.py b/test/unit/objects/monitor_test.py index 629c5ae69..79e98a471 100644 --- a/test/unit/objects/monitor_test.py +++ b/test/unit/objects/monitor_test.py @@ -473,10 +473,10 @@ def test_update_stream_destinations(self): stream = self.client.load(LogsStream, 1) with self.mock_put({}) as m: - result = stream.update_destinations([1, 2, 3]) + result = stream.update_destinations([1]) self.assertEqual(m.call_url, "/monitor/streams/1") - self.assertEqual(m.call_data["destinations"], [1, 2, 3]) + self.assertEqual(m.call_data["destinations"], [1]) self.assertTrue(result) def test_fail_update_stream_destinations_when_no_destination_ids_passed(self): From 2549bf050b704eca6a1ec714248a7e37eaddc57f Mon Sep 17 00:00:00 2001 From: sjerecze Date: Thu, 23 Apr 2026 09:08:19 +0200 Subject: [PATCH 25/36] ACLP Logs Stream - refactor invalid destination test to use session-scoped fixture for deterministic execution --- .../models/monitor/test_monitor_logs.py | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/test/integration/models/monitor/test_monitor_logs.py b/test/integration/models/monitor/test_monitor_logs.py index 7caf9ef25..ca8360206 100644 --- a/test/integration/models/monitor/test_monitor_logs.py +++ b/test/integration/models/monitor/test_monitor_logs.py @@ -236,24 +236,37 @@ def test_fails_to_create_destination_empty_required_fields(test_linode_client: L ) -@_SKIP_STREAM_TESTS -def test_fails_to_create_stream_invalid_destination(test_linode_client: LinodeClient): +@pytest.fixture(scope="session") +def invalid_destination_error(test_linode_client: LinodeClient): """ - 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. If a stream is already present test is skipped. + 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) - with pytest.raises(ApiError) as excinfo: + try: test_linode_client.monitor.stream_create( label=get_test_label(), type=LogsStreamType.audit_logs, destinations=[999999999], ) - assert excinfo.value.status == 400 - assert excinfo.value.errors == ['Destination not found'] + 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") @@ -267,7 +280,10 @@ def create_secondary_destination( @pytest.fixture(scope="session") -def create_stream(test_linode_client: LinodeClient, test_destination: LogsDestination): +def create_stream(test_linode_client: LinodeClient, + test_destination: LogsDestination, + invalid_destination_error #This ensures run order to keep negative test case deterministic +): _skip_if_streams_exist(test_linode_client) stream = test_linode_client.monitor.stream_create( From ef7c13a7da2893d358398b58844db909782b9080 Mon Sep 17 00:00:00 2001 From: sjerecze Date: Thu, 23 Apr 2026 12:28:43 +0200 Subject: [PATCH 26/36] ACLP Logs - update assertion - stream status not tracked by version history --- test/integration/models/monitor/test_monitor_logs.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/integration/models/monitor/test_monitor_logs.py b/test/integration/models/monitor/test_monitor_logs.py index ca8360206..5f57d0219 100644 --- a/test/integration/models/monitor/test_monitor_logs.py +++ b/test/integration/models/monitor/test_monitor_logs.py @@ -346,7 +346,7 @@ def test_get_stream_by_id(test_linode_client: LinodeClient, provisioned_stream: 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 both the label and status changes across versions. + the version history reflects label changes across versions. """ stream = test_linode_client.load(LogsStream, provisioned_stream.id) original_label = stream.label @@ -375,9 +375,7 @@ def test_update_stream_label_and_status(test_linode_client: LinodeClient, provis snapshot_updated = next(h for h in history if h.version == updated.version) assert snapshot_original.label == original_label - assert snapshot_original.status == original_status assert snapshot_updated.label == new_label - assert snapshot_updated.status == new_status assert snapshot_updated.id == provisioned_stream.id finally: # Revert to original label and status From 2d4a217a2d2f36299493fa1b37f04f3e66bf9edf Mon Sep 17 00:00:00 2001 From: sjerecze Date: Thu, 23 Apr 2026 12:30:13 +0200 Subject: [PATCH 27/36] ACLP Logs - update e2e tests workflow --- .github/workflows/e2e-test-pr.yml | 10 +++++++++- .github/workflows/e2e-test.yml | 12 ++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml index f765b0a0d..b9464315f 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..d047010bd 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 @@ -18,7 +26,7 @@ on: type: choice options: - 'true' - - 'false' + - 'false'tox test_suite: description: 'Enter specific test suite. E.g. domain, linode_client' 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 }} From dd0df03de9d2a9290e461170a10391b68d3bf023 Mon Sep 17 00:00:00 2001 From: sjer-akamai Date: Fri, 24 Apr 2026 08:19:16 +0200 Subject: [PATCH 28/36] Update .github/workflows/e2e-test.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/e2e-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index d047010bd..a0350f2c3 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -26,7 +26,7 @@ on: type: choice options: - 'true' - - 'false'tox + - 'false' test_suite: description: 'Enter specific test suite. E.g. domain, linode_client' required: false From b1f7ea0098cbe28032143f6c666d00df659b68f9 Mon Sep 17 00:00:00 2001 From: sjer-akamai Date: Fri, 24 Apr 2026 08:20:07 +0200 Subject: [PATCH 29/36] Update linode_api4/objects/monitor.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- linode_api4/objects/monitor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index bcb81de5f..ab350db2f 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -544,14 +544,14 @@ class LogsDestinationDetails(JSONObject): - 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: str - The specific path in an Object Storage bucket where audit logs files are uploaded. + - 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: str = "" + path: Optional[str] = None class LogsDestinationHistory(Base): From cdf2c0481403bb1262e023405c03c7099a9aa3ef Mon Sep 17 00:00:00 2001 From: sjerecze Date: Fri, 24 Apr 2026 08:34:24 +0200 Subject: [PATCH 30/36] ACLP Logs - Clarify LogsDestinationType supporting only one value --- linode_api4/objects/monitor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index ab350db2f..49daa6d40 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -142,6 +142,9 @@ class AlertStatus(StrEnum): class LogsDestinationType(StrEnum): + """ + The type of destination for logs data sync. Currently, only ``akamai_object_storage`` is supported. + """ akamai_object_storage = "akamai_object_storage" From bf573541d72e999cd8d04f5be434105e39e47a6f Mon Sep 17 00:00:00 2001 From: sjerecze Date: Thu, 30 Apr 2026 08:54:44 +0200 Subject: [PATCH 31/36] ACLP Logs - Fix formatting --- linode_api4/groups/monitor.py | 36 ++-- linode_api4/objects/monitor.py | 16 +- .../models/monitor/test_monitor_logs.py | 158 ++++++++++++------ test/unit/objects/monitor_test.py | 58 ++++--- 4 files changed, 171 insertions(+), 97 deletions(-) diff --git a/linode_api4/groups/monitor.py b/linode_api4/groups/monitor.py index e00cb0fab..1f38aee82 100644 --- a/linode_api4/groups/monitor.py +++ b/linode_api4/groups/monitor.py @@ -8,15 +8,15 @@ AlertDefinition, AlertDefinitionEntity, AlertScope, - MonitorDashboard, - MonitorMetricsDefinition, - MonitorService, - MonitorServiceToken, LogsDestination, LogsDestinationType, LogsStream, LogsStreamStatus, LogsStreamType, + MonitorDashboard, + MonitorMetricsDefinition, + MonitorService, + MonitorServiceToken, ) __all__ = [ @@ -364,14 +364,14 @@ def destinations(self, *filters) -> PaginatedList: return self.client._get_and_filter(LogsDestination, *filters) def destination_create( - self, - label: str, - type: Union[LogsDestinationType, str], - access_key_id: str, - access_key_secret: str, - bucket_name: str, - host: str, - path: Optional[str] = None, + self, + label: str, + type: Union[LogsDestinationType, str], + access_key_id: str, + access_key_secret: str, + bucket_name: str, + host: str, + path: Optional[str] = None, ) -> LogsDestination: """ Creates a new :any:`LogsDestination` for logs on this account with @@ -418,7 +418,7 @@ def destination_create( "access_key_secret": access_key_secret, "bucket_name": bucket_name, "host": host, - } + }, } if path is not None: @@ -458,11 +458,11 @@ def streams(self, *filters) -> PaginatedList: 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 + self, + destinations: list[int], + label: str, + type: Union[LogsStreamType, str], + status: Optional[Union[LogsStreamStatus, str]] = None, ) -> LogsStream: """ Creates a new :any:`LogsStream` for logs on this account with diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index 49daa6d40..499cbc6f4 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -145,6 +145,7 @@ class LogsDestinationType(StrEnum): """ The type of destination for logs data sync. Currently, only ``akamai_object_storage`` is supported. """ + akamai_object_storage = "akamai_object_storage" @@ -609,8 +610,10 @@ def history(self): """ return self._client._get_objects( - "{}/history".format(LogsDestination.api_endpoint.format(id=self.id)), - LogsDestinationHistory + "{}/history".format( + LogsDestination.api_endpoint.format(id=self.id) + ), + LogsDestinationHistory, ) @@ -694,14 +697,11 @@ def update_destinations(self, destinations: List[int]): """ if not destinations: raise ValueError("A destination id must be provided.") - payload = { - "destinations": destinations - } + 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.api_endpoint.format(id=self.id), data=payload ) self._populate(result) @@ -717,5 +717,5 @@ def history(self): return self._client._get_objects( "{}/history".format(LogsStream.api_endpoint.format(id=self.id)), - LogsStreamHistory + LogsStreamHistory, ) diff --git a/test/integration/models/monitor/test_monitor_logs.py b/test/integration/models/monitor/test_monitor_logs.py index 5f57d0219..10e06a425 100644 --- a/test/integration/models/monitor/test_monitor_logs.py +++ b/test/integration/models/monitor/test_monitor_logs.py @@ -1,28 +1,30 @@ import os import urllib.request +from test.integration.helpers import ( + get_test_label, + send_request_when_resource_available, + wait_for_condition, +) import pytest -from linode_api4 import LinodeClient, PaginatedList, LogsStreamType -from linode_api4.objects import (ObjectStorageACL, - ObjectStorageKeys, - ObjectStorageBucket, - Capability) +from linode_api4 import LinodeClient, LogsStreamType, PaginatedList +from linode_api4.objects import ( + Capability, + ObjectStorageACL, + ObjectStorageBucket, + ObjectStorageKeys, +) from linode_api4.objects.monitor import ( LogsDestination, LogsStream, LogsStreamStatus, ) -from test.integration.helpers import ( - get_test_label, - send_request_when_resource_available, - wait_for_condition, -) - _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"}, + 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'", ) @@ -46,15 +48,19 @@ def create_object_storage_key(test_linode_client: LinodeClient): @pytest.fixture(scope="session") def test_destination( - test_linode_client: LinodeClient, - create_object_storage_key: ObjectStorageKeys, + test_linode_client: LinodeClient, + create_object_storage_key: ObjectStorageKeys, ): - dest, bucket = _create_destination_with_bucket(test_linode_client, create_object_storage_key) + dest, bucket = _create_destination_with_bucket( + test_linode_client, create_object_storage_key + ) yield dest _delete_destination_with_bucket(test_linode_client, dest, bucket) -def _create_destination_with_bucket(client: LinodeClient, key: ObjectStorageKeys): +def _create_destination_with_bucket( + client: LinodeClient, key: ObjectStorageKeys +): """Helper that creates an OBJ bucket and a logs destination backed by it.""" bucket = client.object_storage.bucket_create( cluster_or_region="us-southeast", @@ -73,7 +79,9 @@ def _create_destination_with_bucket(client: LinodeClient, key: ObjectStorageKeys return dest, bucket -def _delete_destination_with_bucket(client: LinodeClient, dest: LogsDestination, bucket: ObjectStorageBucket): +def _delete_destination_with_bucket( + client: LinodeClient, dest: LogsDestination, bucket: ObjectStorageBucket +): """Helper that deletes a logs destination and its backing OBJ bucket.""" send_request_when_resource_available(timeout=100, func=dest.delete) _empty_bucket(client, bucket) @@ -108,7 +116,9 @@ def _empty_bucket(client: LinodeClient, bucket: ObjectStorageBucket): ) -def test_list_destinations(test_linode_client: LinodeClient, test_destination: LogsDestination): +def test_list_destinations( + test_linode_client: LinodeClient, test_destination: LogsDestination +): """ Test that listing destinations returns a PaginatedList containing the previously created destination. """ @@ -122,11 +132,15 @@ def test_list_destinations(test_linode_client: LinodeClient, test_destination: L assert test_destination.id in ids -def test_get_destination_by_id(test_linode_client: LinodeClient, test_destination: LogsDestination): +def test_get_destination_by_id( + test_linode_client: LinodeClient, test_destination: LogsDestination +): """ Test that fetching destination with id filter returns correct destination. """ - destination_by_id = test_linode_client.load(LogsDestination, test_destination.id) + destination_by_id = test_linode_client.load( + LogsDestination, test_destination.id + ) assert isinstance(destination_by_id, LogsDestination) assert destination_by_id.id == test_destination.id @@ -135,9 +149,9 @@ def test_get_destination_by_id(test_linode_client: LinodeClient, test_destinatio def test_update_destination_label_and_version_history( - test_linode_client: LinodeClient, - test_destination: LogsDestination, - create_object_storage_key: ObjectStorageKeys, + test_linode_client: LinodeClient, + test_destination: LogsDestination, + create_object_storage_key: ObjectStorageKeys, ): """ Test that a LogsDestination label can be updated via save(), @@ -161,8 +175,12 @@ def test_update_destination_label_and_version_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) + 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 @@ -173,7 +191,9 @@ def test_update_destination_label_and_version_history( assert snapshot_original.id == test_destination.id -def test_fails_to_create_destination_invalid_secret(test_linode_client: LinodeClient): +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. """ @@ -189,10 +209,12 @@ def test_fails_to_create_destination_invalid_secret(test_linode_client: LinodeCl host="some-bucket.us-southeast-1.linodeobjects.com", ) assert excinfo.value.status == 400 - assert excinfo.value.errors == ['Invalid access key id or secret key'] + assert excinfo.value.errors == ["Invalid access key id or secret key"] -def test_fails_to_create_destination_invalid_type(test_linode_client: LinodeClient): +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. @@ -209,10 +231,14 @@ def test_fails_to_create_destination_invalid_type(test_linode_client: LinodeClie 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'] + 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): +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. @@ -257,39 +283,45 @@ def invalid_destination_error(test_linode_client: LinodeClient): 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 is not None + ), "Expected an ApiError but none was raised" assert invalid_destination_error.status == 400 - assert invalid_destination_error.errors == ['Destination not found'] + 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, + test_linode_client: LinodeClient, + create_object_storage_key: ObjectStorageKeys, ): - dest, bucket = _create_destination_with_bucket(test_linode_client, create_object_storage_key) + dest, bucket = _create_destination_with_bucket( + test_linode_client, create_object_storage_key + ) yield dest _delete_destination_with_bucket(test_linode_client, dest, bucket) @pytest.fixture(scope="session") -def create_stream(test_linode_client: LinodeClient, - test_destination: LogsDestination, - invalid_destination_error #This ensures run order to keep negative test case deterministic +def create_stream( + test_linode_client: LinodeClient, + test_destination: LogsDestination, + invalid_destination_error, # This ensures run order to keep negative test case deterministic ): _skip_if_streams_exist(test_linode_client) stream = test_linode_client.monitor.stream_create( label=get_test_label(), destinations=[test_destination.id], - type=LogsStreamType.audit_logs + type=LogsStreamType.audit_logs, ) assert stream.id is not None assert stream.status == LogsStreamStatus.provisioning @@ -298,7 +330,9 @@ def create_stream(test_linode_client: LinodeClient, @pytest.fixture(scope="session") -def provisioned_stream(test_linode_client: LinodeClient, create_stream: LogsStream): +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. @@ -306,7 +340,10 @@ def provisioned_stream(test_linode_client: LinodeClient, create_stream: LogsStre def is_stream_provisioned(): stream = test_linode_client.load(LogsStream, create_stream.id) - return stream.status in (LogsStreamStatus.active, LogsStreamStatus.inactive) + return stream.status in ( + LogsStreamStatus.active, + LogsStreamStatus.inactive, + ) wait_for_condition(60, 3600, is_stream_provisioned) @@ -314,7 +351,9 @@ def is_stream_provisioned(): @_SKIP_STREAM_TESTS -def test_list_streams(test_linode_client: LinodeClient, provisioned_stream: LogsStream): +def test_list_streams( + test_linode_client: LinodeClient, provisioned_stream: LogsStream +): """ Test that listing streams returns a PaginatedList containing the previously created stream. """ @@ -329,7 +368,9 @@ def test_list_streams(test_linode_client: LinodeClient, provisioned_stream: Logs @_SKIP_STREAM_TESTS -def test_get_stream_by_id(test_linode_client: LinodeClient, provisioned_stream: LogsStream): +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. """ @@ -343,7 +384,9 @@ def test_get_stream_by_id(test_linode_client: LinodeClient, provisioned_stream: @_SKIP_STREAM_TESTS -def test_update_stream_label_and_status(test_linode_client: LinodeClient, provisioned_stream: LogsStream): +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. @@ -371,8 +414,12 @@ def test_update_stream_label_and_status(test_linode_client: LinodeClient, provis assert updated.status == 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) + 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 @@ -386,9 +433,9 @@ def test_update_stream_label_and_status(test_linode_client: LinodeClient, provis @_SKIP_STREAM_TESTS def test_update_stream_destinations( - test_linode_client: LinodeClient, - provisioned_stream: LogsStream, - create_secondary_destination: LogsDestination, + test_linode_client: LinodeClient, + provisioned_stream: LogsStream, + create_secondary_destination: LogsDestination, ): """ Test that a stream destination can be replaced via update_destinations(), @@ -407,11 +454,18 @@ def test_update_stream_destinations( 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) + 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 + assert ( + snapshot_updated.destinations[0].id + == create_secondary_destination.id + ) finally: # Revert to original destination stream.update_destinations(original_destinations) diff --git a/test/unit/objects/monitor_test.py b/test/unit/objects/monitor_test.py index 79e98a471..b7669c610 100644 --- a/test/unit/objects/monitor_test.py +++ b/test/unit/objects/monitor_test.py @@ -3,12 +3,12 @@ from linode_api4.objects import ( AlertChannel, - MonitorDashboard, - MonitorService, LogsDestination, LogsDestinationHistory, LogsStream, LogsStreamDestination, + MonitorDashboard, + MonitorService, ) @@ -198,12 +198,8 @@ def test_list_destinations(self): 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, 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") @@ -337,9 +333,7 @@ def test_delete_destination(self): with self.mock_delete() as m: dest.delete() - self.assertEqual( - m.call_url, "/monitor/streams/destinations/1" - ) + self.assertEqual(m.call_url, "/monitor/streams/destinations/1") class LogsStreamTest(ClientBaseCase): @@ -361,8 +355,12 @@ def test_list_streams(self): 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, 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") @@ -382,7 +380,9 @@ def test_list_streams_destinations(self): 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.host, "primary-bucket.us-east-1.linodeobjects.com" + ) self.assertEqual(dest.details.path, "audit-logs") def test_stream_history(self): @@ -399,7 +399,9 @@ def test_stream_history(self): 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.assertEqual( + snapshot.updated, datetime.datetime(2024, 6, 2, 9, 0, 0) + ) self.assertIsNotNone(snapshot.destinations) def test_create_stream(self): @@ -411,7 +413,14 @@ def test_create_stream(self): "label": "new-stream", "type": "audit_logs", "status": "active", - "destinations": [{"id": 1, "label": "my-logs-destination", "type": "akamai_object_storage", "details": {}}], + "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", @@ -449,7 +458,14 @@ def test_update_stream_save(self): "label": "renamed-stream", "type": "audit_logs", "status": "inactive", - "destinations": [{"id": 1, "label": "my-logs-destination", "type": "akamai_object_storage", "details": {}}], + "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", @@ -479,7 +495,9 @@ def test_update_stream_destinations(self): self.assertEqual(m.call_data["destinations"], [1]) self.assertTrue(result) - def test_fail_update_stream_destinations_when_no_destination_ids_passed(self): + 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. """ @@ -489,7 +507,9 @@ def test_fail_update_stream_destinations_when_no_destination_ids_passed(self): stream.update_destinations([]) self.assertFalse(m.called) - self.assertIn("A destination id must be provided.", str(context.exception)) + self.assertIn( + "A destination id must be provided.", str(context.exception) + ) def test_delete_stream(self): """ From 29fe84790c2012a7ed6884abc3edb56197bc337b Mon Sep 17 00:00:00 2001 From: sjerecze Date: Mon, 4 May 2026 15:12:13 +0200 Subject: [PATCH 32/36] ACLP Logs - Extend destination API with new updates --- linode_api4/groups/monitor.py | 80 +++--- linode_api4/objects/monitor.py | 160 ++++++++++- test/fixtures/monitor_streams_2.json | 43 +++ .../monitor_streams_destinations_2.json | 36 +++ .../models/monitor/test_monitor_logs.py | 45 ++-- test/unit/objects/monitor_test.py | 248 +++++++++++++++++- 6 files changed, 545 insertions(+), 67 deletions(-) create mode 100644 test/fixtures/monitor_streams_2.json create mode 100644 test/fixtures/monitor_streams_destinations_2.json diff --git a/linode_api4/groups/monitor.py b/linode_api4/groups/monitor.py index 1f38aee82..02eec1a7e 100644 --- a/linode_api4/groups/monitor.py +++ b/linode_api4/groups/monitor.py @@ -18,6 +18,10 @@ MonitorService, MonitorServiceToken, ) +from linode_api4.objects.monitor import ( + AkamaiObjectStorageLogsDestinationDetails, + CustomHTTPSLogsDestinationDetails, +) __all__ = [ "MonitorGroup", @@ -367,44 +371,60 @@ def destination_create( self, label: str, type: Union[LogsDestinationType, str], - access_key_id: str, - access_key_secret: str, - bucket_name: str, - host: str, - path: Optional[str] = None, + details: Union[ + AkamaiObjectStorageLogsDestinationDetails, + CustomHTTPSLogsDestinationDetails, + ], ) -> LogsDestination: """ - Creates a new :any:`LogsDestination` for logs on this account with - the given label, type, and object storage details. For example:: + 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", - access_key_id="1ABCD23EFG4HIJKLMNO5", - access_key_secret="1aB2CD3e4fgHi5JK6lmnop7qR8STU9VxYzabcdefHh", - bucket_name="primary-bucket", - host="primary-bucket-1.us-east-12.linodeobjects.com", - path="audit-logs" - ) + 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 + :param label: The name for this logs destination. :type label: str - :param type: The type of destination for logs data sync. Currently, only ``akamai_object_storage`` is supported for use. + :param type: The type of destination — ``akamai_object_storage`` or ``custom_https``. :type type: str or LogsDestinationType - :param access_key_id: The unique identifier assigned to the Object Storage key required for authentication to the bucket. - :type access_key_id: str - :param access_key_secret: The Object Storage key's secret key. - :type access_key_secret: str - :param bucket_name: The name of the Object Storage bucket - :type bucket_name: str - :param host: The hostname where the Object Storage bucket can be accessed - :type host: str - :param path: (Optional) Custom path for log storage in your Object Storage bucket. - :type path: Optional[str] + :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 @@ -413,17 +433,9 @@ def destination_create( params = { "label": label, "type": type, - "details": { - "access_key_id": access_key_id, - "access_key_secret": access_key_secret, - "bucket_name": bucket_name, - "host": host, - }, + "details": details.dict, } - if path is not None: - params["details"]["path"] = path - result = self.client.post("/monitor/streams/destinations", data=params) if "id" not in result: diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index 499cbc6f4..3956adf2f 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,8 +20,10 @@ "MonitorServiceToken", "RuleCriteria", "TriggerConditions", + "AkamaiObjectStorageLogsDestinationDetails", + "CustomHTTPSLogsDestinationDetails", + "LogsDestinationDetailsBase", "LogsDestination", - "LogsDestinationDetails", "LogsDestinationHistory", "LogsDestinationStatus", "LogsDestinationType", @@ -143,10 +145,26 @@ class AlertStatus(StrEnum): class LogsDestinationType(StrEnum): """ - The type of destination for logs data sync. Currently, only ``akamai_object_storage`` is supported. + 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): @@ -541,9 +559,97 @@ class AlertChannel(Base): @dataclass -class LogsDestinationDetails(JSONObject): +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 LogsDestination. + 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. @@ -568,7 +674,7 @@ class LogsDestinationHistory(Base): properties = { "created": Property(is_datetime=True), "created_by": Property(), - "details": Property(json_object=LogsDestinationDetails), + "details": Property(), "id": Property(identifier=True), "label": Property(), "status": Property(), @@ -578,6 +684,17 @@ class LogsDestinationHistory(Base): "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): """ @@ -591,7 +708,7 @@ class LogsDestination(Base): properties = { "created": Property(is_datetime=True), "created_by": Property(), - "details": Property(mutable=True, json_object=LogsDestinationDetails), + "details": Property(mutable=True), "id": Property(identifier=True), "label": Property(mutable=True), "status": Property(), @@ -601,6 +718,17 @@ class LogsDestination(Base): "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): """ @@ -636,7 +764,23 @@ class LogsStreamDestination(JSONObject): id: int = 0 label: str = "" type: Optional[LogsDestinationType] = None - details: Optional[LogsDestinationDetails] = 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): 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_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 index 10e06a425..cf69e0379 100644 --- a/test/integration/models/monitor/test_monitor_logs.py +++ b/test/integration/models/monitor/test_monitor_logs.py @@ -1,5 +1,6 @@ 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, @@ -16,6 +17,7 @@ ObjectStorageKeys, ) from linode_api4.objects.monitor import ( + AkamaiObjectStorageLogsDestinationDetails, LogsDestination, LogsStream, LogsStreamStatus, @@ -62,8 +64,9 @@ def _create_destination_with_bucket( client: LinodeClient, key: ObjectStorageKeys ): """Helper that creates an OBJ bucket and a logs destination backed by it.""" + region = get_region(client, {"Object Storage"}) bucket = client.object_storage.bucket_create( - cluster_or_region="us-southeast", + cluster_or_region=region.id, label=get_test_label(), acl=ObjectStorageACL.PRIVATE, cors_enabled=False, @@ -71,10 +74,12 @@ def _create_destination_with_bucket( dest = client.monitor.destination_create( label=get_test_label(), type="akamai_object_storage", - access_key_id=key.access_key, - access_key_secret=key.secret_key, - bucket_name=bucket.label, - host=f"{bucket.label}.us-southeast-1.linodeobjects.com", + details=AkamaiObjectStorageLogsDestinationDetails( + access_key_id=key.access_key, + access_key_secret=key.secret_key, + bucket_name=bucket.label, + host=bucket.hostname, + ), ) return dest, bucket @@ -203,10 +208,12 @@ def test_fails_to_create_destination_invalid_secret( test_linode_client.monitor.destination_create( label=get_test_label(), type="akamai_object_storage", - access_key_id="1", - access_key_secret="1", - bucket_name="some-bucket", - host="some-bucket.us-southeast-1.linodeobjects.com", + 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"] @@ -225,10 +232,12 @@ def test_fails_to_create_destination_invalid_type( test_linode_client.monitor.destination_create( label=get_test_label(), type="invalid_type", - access_key_id="SOMEACCESSKEY", - access_key_secret="SOMESECRETKEY", - bucket_name="some-bucket", - host="some-bucket.us-southeast-1.linodeobjects.com", + 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 == [ @@ -249,10 +258,12 @@ def test_fails_to_create_destination_empty_required_fields( test_linode_client.monitor.destination_create( label=get_test_label(), type="akamai_object_storage", - access_key_id="", - access_key_secret="", - bucket_name="", - host="", + details=AkamaiObjectStorageLogsDestinationDetails( + access_key_id="", + access_key_secret="", + bucket_name="", + host="", + ), ) assert excinfo.value.status == 400 assert len(excinfo.value.errors) == 4 diff --git a/test/unit/objects/monitor_test.py b/test/unit/objects/monitor_test.py index b7669c610..42a191197 100644 --- a/test/unit/objects/monitor_test.py +++ b/test/unit/objects/monitor_test.py @@ -10,6 +10,12 @@ MonitorDashboard, MonitorService, ) +from linode_api4.objects.monitor import ( + AkamaiObjectStorageLogsDestinationDetails, + CustomHTTPSLogsDestinationDetails, + DestinationAuthentication, + LogsDestinationDetailsBase, +) class MonitorTest(ClientBaseCase): @@ -240,10 +246,10 @@ def test_destination_history(self): self.assertIsNotNone(snapshot.details) self.assertEqual(snapshot.details.bucket_name, "primary-bucket") - def test_create_destination(self): + def test_create_destination_akamai_object_storage(self): """ - Test that destination_create sends the right payload and returns - a LogsDestination object. + Test that destination_create with type=akamai_object_storage sends the right + payload and returns a LogsDestination object. """ create_response = { "id": 2, @@ -267,11 +273,13 @@ def test_create_destination(self): result = self.client.monitor.destination_create( label="new-dest", type="akamai_object_storage", - access_key_id="KEYID999", - access_key_secret="SUPERSECRET", - bucket_name="new-bucket", - host="new-bucket.us-east-1.linodeobjects.com", - path="logs/audit", + 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") @@ -521,3 +529,227 @@ def test_delete_stream(self): stream.delete() self.assertEqual(m.call_url, "/monitor/streams/1") + + +class CustomHTTPSLogsDestinationTest(ClientBaseCase): + """ + Tests for custom_https type LogsDestination and LogsStream destinations, + and for the LogsDestinationDetailsBase.load_by_type factory method. + """ + + def test_load_by_type_returns_akamai_details(self): + """ + load_by_type returns AkamaiObjectStorageLogsDestinationDetails for + the akamai_object_storage discriminator. + """ + json_dict = { + "access_key_id": "KEY123", + "bucket_name": "my-bucket", + "host": "my-bucket.us-east-1.linodeobjects.com", + "path": "audit-logs", + } + result = LogsDestinationDetailsBase.load_by_type( + "akamai_object_storage", json_dict + ) + + self.assertIsInstance(result, AkamaiObjectStorageLogsDestinationDetails) + self.assertEqual(result.access_key_id, "KEY123") + self.assertEqual(result.bucket_name, "my-bucket") + self.assertEqual(result.host, "my-bucket.us-east-1.linodeobjects.com") + self.assertEqual(result.path, "audit-logs") + + def test_load_by_type_returns_custom_https_details(self): + """ + load_by_type returns CustomHTTPSLogsDestinationDetails for the + custom_https discriminator. + """ + json_dict = { + "endpoint_url": "https://my-site.com/logs", + "authentication": {"type": "none"}, + "data_compression": "gzip", + "content_type": "application/json", + } + result = LogsDestinationDetailsBase.load_by_type( + "custom_https", json_dict + ) + + self.assertIsInstance(result, CustomHTTPSLogsDestinationDetails) + self.assertEqual(result.endpoint_url, "https://my-site.com/logs") + self.assertEqual(result.data_compression, "gzip") + self.assertEqual(result.content_type, "application/json") + + def test_load_by_type_returns_none_for_empty_dict(self): + """ + load_by_type returns None when json_dict is empty or None. + """ + self.assertIsNone( + LogsDestinationDetailsBase.load_by_type("custom_https", None) + ) + self.assertIsNone( + LogsDestinationDetailsBase.load_by_type("custom_https", {}) + ) + + def test_load_by_type_returns_none_for_unknown_type(self): + """ + load_by_type returns None for an unrecognized destination type. + """ + result = LogsDestinationDetailsBase.load_by_type( + "unknown_type", {"foo": "bar"} + ) + self.assertIsNone(result) + + def test_load_custom_https_destination(self): + """ + Loading a custom_https destination populates CustomHTTPSLogsDestinationDetails. + """ + dest = self.client.load(LogsDestination, 2) + + self.assertIsInstance(dest, LogsDestination) + self.assertEqual(dest.id, 2) + self.assertEqual(dest.label, "my-custom-https-destination") + self.assertEqual(dest.type, "custom_https") + self.assertEqual(dest.status, "active") + + 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") + + def test_custom_https_destination_authentication(self): + """ + Authentication block of a custom_https destination is parsed correctly. + """ + dest = self.client.load(LogsDestination, 2) + + auth = dest.details.authentication + self.assertIsNotNone(auth) + self.assertEqual(auth.type, "basic") + self.assertIsNotNone(auth.details) + self.assertEqual(auth.details.basic_authentication_user, "John_Q") + self.assertEqual(auth.details.basic_authentication_password, "p@$$w0Rd") + + def test_custom_https_destination_custom_headers(self): + """ + custom_headers list of a custom_https destination is parsed correctly. + """ + dest = self.client.load(LogsDestination, 2) + + self.assertIsNotNone(dest.details.custom_headers) + self.assertEqual(len(dest.details.custom_headers), 1) + self.assertEqual(dest.details.custom_headers[0].name, "Cache-Control") + self.assertEqual(dest.details.custom_headers[0].value, "max-age=0") + + def test_custom_https_destination_client_certificate_details(self): + """ + client_certificate_details of a custom_https destination is parsed correctly. + """ + dest = self.client.load(LogsDestination, 2) + + cert_details = dest.details.client_certificate_details + self.assertIsNotNone(cert_details) + self.assertEqual(cert_details.tls_hostname, "my-site.com") + self.assertIn("BEGIN CERTIFICATE", cert_details.client_ca_certificate) + self.assertIn("BEGIN CERTIFICATE", cert_details.client_certificate) + self.assertIn("BEGIN PRIVATE KEY", cert_details.client_private_key) + + def test_stream_custom_https_destination_details(self): + """ + The CustomHTTPSLogsDestinationDetails nested inside a LogsStreamDestination + are populated correctly. + """ + stream = self.client.load(LogsStream, 2) + details = stream.destinations[0].details + + self.assertEqual( + details.endpoint_url, + "https://my-site.com/log-storage/basicAuth", + ) + self.assertEqual(details.data_compression, "gzip") + self.assertEqual(details.content_type, "application/json") + self.assertEqual(details.authentication.type, "basic") + self.assertEqual( + details.authentication.details.basic_authentication_user, "John_Q" + ) + self.assertEqual(len(details.custom_headers), 1) + self.assertEqual(details.custom_headers[0].name, "Cache-Control") + + # ------------------------------------------------------------------ + # Ensure akamai type is unaffected (regression guard) + # ------------------------------------------------------------------ + + def test_akamai_destination_details_unaffected(self): + """ + Existing akamai_object_storage destination still deserializes as + AkamaiObjectStorageLogsDestinationDetails after the factory refactor. + """ + dest = self.client.load(LogsDestination, 1) + + self.assertIsInstance( + dest.details, AkamaiObjectStorageLogsDestinationDetails + ) + self.assertEqual(dest.details.access_key_id, "1ABCD23EFG4HIJKLMNO5") + self.assertEqual(dest.details.bucket_name, "primary-bucket") + + def test_akamai_stream_destination_details_unaffected(self): + """ + Existing akamai_object_storage stream destination still deserializes as + AkamaiObjectStorageLogsDestinationDetails after the factory refactor. + """ + stream = self.client.load(LogsStream, 1) + dest = stream.destinations[0] + + self.assertIsInstance( + dest.details, AkamaiObjectStorageLogsDestinationDetails + ) + self.assertEqual(dest.details.bucket_name, "primary-bucket") + + def test_create_custom_https_destination(self): + """ + destination_create with type=custom_https sends the correct payload + and returns a LogsDestination with CustomHTTPSLogsDestinationDetails. + """ + 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) + self.assertEqual( + result.details.endpoint_url, "https://example.com/logs" + ) From 7836172afcd9e30d64ab22094318e669a6cd4689 Mon Sep 17 00:00:00 2001 From: sjerecze Date: Mon, 4 May 2026 15:37:58 +0200 Subject: [PATCH 33/36] ACLP Logs - Extend Stream API with lke_audit_logs_type, reorder tests --- linode_api4/groups/monitor.py | 35 ++- linode_api4/objects/monitor.py | 31 +++ test/fixtures/monitor_streams_3.json | 29 ++ test/unit/objects/monitor_test.py | 392 ++++++++++++++------------- 4 files changed, 284 insertions(+), 203 deletions(-) create mode 100644 test/fixtures/monitor_streams_3.json diff --git a/linode_api4/groups/monitor.py b/linode_api4/groups/monitor.py index 02eec1a7e..08170c8d7 100644 --- a/linode_api4/groups/monitor.py +++ b/linode_api4/groups/monitor.py @@ -21,6 +21,7 @@ from linode_api4.objects.monitor import ( AkamaiObjectStorageLogsDestinationDetails, CustomHTTPSLogsDestinationDetails, + LogsStreamDetails, ) __all__ = [ @@ -475,34 +476,49 @@ def stream_create( 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 with - the given label, type, and object storage details. For example:: + 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], + 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: List of unique identifiers for the sync points that will receive logs data. + :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. Set this to ``audit_logs`` for logs consisting of all the control plane - operations for the services in your Linodes. - :type type: 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 @@ -517,6 +533,9 @@ def stream_create( 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: diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index 3956adf2f..c57a91742 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -31,6 +31,7 @@ "LogsStreamHistory", "LogsStreamType", "LogsStreamStatus", + "LogsStreamDetails", "LogsStreamDestination", ] @@ -753,6 +754,34 @@ class LogsStreamStatus(StrEnum): 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 @@ -794,6 +823,7 @@ class LogsStreamHistory(Base): "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(), @@ -817,6 +847,7 @@ class LogsStream(Base): "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), 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/unit/objects/monitor_test.py b/test/unit/objects/monitor_test.py index 42a191197..43985a172 100644 --- a/test/unit/objects/monitor_test.py +++ b/test/unit/objects/monitor_test.py @@ -15,6 +15,8 @@ CustomHTTPSLogsDestinationDetails, DestinationAuthentication, LogsDestinationDetailsBase, + LogsStreamDetails, + LogsStreamType, ) @@ -344,6 +346,123 @@ def test_delete_destination(self): 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. @@ -531,225 +650,108 @@ def test_delete_stream(self): self.assertEqual(m.call_url, "/monitor/streams/1") -class CustomHTTPSLogsDestinationTest(ClientBaseCase): +class LkeAuditLogsStreamTest(ClientBaseCase): """ - Tests for custom_https type LogsDestination and LogsStream destinations, - and for the LogsDestinationDetailsBase.load_by_type factory method. + Tests for lke_audit_logs stream type and LogsStreamDetails model. """ - def test_load_by_type_returns_akamai_details(self): - """ - load_by_type returns AkamaiObjectStorageLogsDestinationDetails for - the akamai_object_storage discriminator. - """ - json_dict = { - "access_key_id": "KEY123", - "bucket_name": "my-bucket", - "host": "my-bucket.us-east-1.linodeobjects.com", - "path": "audit-logs", - } - result = LogsDestinationDetailsBase.load_by_type( - "akamai_object_storage", json_dict - ) - - self.assertIsInstance(result, AkamaiObjectStorageLogsDestinationDetails) - self.assertEqual(result.access_key_id, "KEY123") - self.assertEqual(result.bucket_name, "my-bucket") - self.assertEqual(result.host, "my-bucket.us-east-1.linodeobjects.com") - self.assertEqual(result.path, "audit-logs") - - def test_load_by_type_returns_custom_https_details(self): - """ - load_by_type returns CustomHTTPSLogsDestinationDetails for the - custom_https discriminator. - """ - json_dict = { - "endpoint_url": "https://my-site.com/logs", - "authentication": {"type": "none"}, - "data_compression": "gzip", - "content_type": "application/json", - } - result = LogsDestinationDetailsBase.load_by_type( - "custom_https", json_dict - ) - - self.assertIsInstance(result, CustomHTTPSLogsDestinationDetails) - self.assertEqual(result.endpoint_url, "https://my-site.com/logs") - self.assertEqual(result.data_compression, "gzip") - self.assertEqual(result.content_type, "application/json") + 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_by_type_returns_none_for_empty_dict(self): + def test_load_lke_audit_logs_stream(self): """ - load_by_type returns None when json_dict is empty or None. + Loading an lke_audit_logs stream deserializes type and details correctly. """ - self.assertIsNone( - LogsDestinationDetailsBase.load_by_type("custom_https", None) - ) - self.assertIsNone( - LogsDestinationDetailsBase.load_by_type("custom_https", {}) - ) + stream = self.client.load(LogsStream, 3) - def test_load_by_type_returns_none_for_unknown_type(self): - """ - load_by_type returns None for an unrecognized destination type. - """ - result = LogsDestinationDetailsBase.load_by_type( - "unknown_type", {"foo": "bar"} - ) - self.assertIsNone(result) + 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_load_custom_https_destination(self): - """ - Loading a custom_https destination populates CustomHTTPSLogsDestinationDetails. - """ - dest = self.client.load(LogsDestination, 2) - - self.assertIsInstance(dest, LogsDestination) - self.assertEqual(dest.id, 2) - self.assertEqual(dest.label, "my-custom-https-destination") - self.assertEqual(dest.type, "custom_https") - self.assertEqual(dest.status, "active") - - 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") - - def test_custom_https_destination_authentication(self): - """ - Authentication block of a custom_https destination is parsed correctly. - """ - dest = self.client.load(LogsDestination, 2) - - auth = dest.details.authentication - self.assertIsNotNone(auth) - self.assertEqual(auth.type, "basic") - self.assertIsNotNone(auth.details) - self.assertEqual(auth.details.basic_authentication_user, "John_Q") - self.assertEqual(auth.details.basic_authentication_password, "p@$$w0Rd") - - def test_custom_https_destination_custom_headers(self): - """ - custom_headers list of a custom_https destination is parsed correctly. - """ - dest = self.client.load(LogsDestination, 2) - - self.assertIsNotNone(dest.details.custom_headers) - self.assertEqual(len(dest.details.custom_headers), 1) - self.assertEqual(dest.details.custom_headers[0].name, "Cache-Control") - self.assertEqual(dest.details.custom_headers[0].value, "max-age=0") - - def test_custom_https_destination_client_certificate_details(self): - """ - client_certificate_details of a custom_https destination is parsed correctly. - """ - dest = self.client.load(LogsDestination, 2) - - cert_details = dest.details.client_certificate_details - self.assertIsNotNone(cert_details) - self.assertEqual(cert_details.tls_hostname, "my-site.com") - self.assertIn("BEGIN CERTIFICATE", cert_details.client_ca_certificate) - self.assertIn("BEGIN CERTIFICATE", cert_details.client_certificate) - self.assertIn("BEGIN PRIVATE KEY", cert_details.client_private_key) - - def test_stream_custom_https_destination_details(self): - """ - The CustomHTTPSLogsDestinationDetails nested inside a LogsStreamDestination - are populated correctly. - """ - stream = self.client.load(LogsStream, 2) - details = stream.destinations[0].details - - self.assertEqual( - details.endpoint_url, - "https://my-site.com/log-storage/basicAuth", - ) - self.assertEqual(details.data_compression, "gzip") - self.assertEqual(details.content_type, "application/json") - self.assertEqual(details.authentication.type, "basic") - self.assertEqual( - details.authentication.details.basic_authentication_user, "John_Q" - ) - self.assertEqual(len(details.custom_headers), 1) - self.assertEqual(details.custom_headers[0].name, "Cache-Control") - - # ------------------------------------------------------------------ - # Ensure akamai type is unaffected (regression guard) - # ------------------------------------------------------------------ - - def test_akamai_destination_details_unaffected(self): - """ - Existing akamai_object_storage destination still deserializes as - AkamaiObjectStorageLogsDestinationDetails after the factory refactor. - """ - dest = self.client.load(LogsDestination, 1) - - self.assertIsInstance( - dest.details, AkamaiObjectStorageLogsDestinationDetails - ) - self.assertEqual(dest.details.access_key_id, "1ABCD23EFG4HIJKLMNO5") - self.assertEqual(dest.details.bucket_name, "primary-bucket") - - def test_akamai_stream_destination_details_unaffected(self): - """ - Existing akamai_object_storage stream destination still deserializes as - AkamaiObjectStorageLogsDestinationDetails after the factory refactor. - """ + def test_audit_logs_stream_details_is_none(self): + """An audit_logs stream has no details block.""" stream = self.client.load(LogsStream, 1) - dest = stream.destinations[0] - - self.assertIsInstance( - dest.details, AkamaiObjectStorageLogsDestinationDetails - ) - self.assertEqual(dest.details.bucket_name, "primary-bucket") + self.assertIsNone(stream.details) - def test_create_custom_https_destination(self): + def test_create_lke_audit_logs_stream(self): """ - destination_create with type=custom_https sends the correct payload - and returns a LogsDestination with CustomHTTPSLogsDestinationDetails. + stream_create with lke_audit_logs sends details in the payload. """ create_response = { - "id": 3, - "label": "new-custom-dest", - "type": "custom_https", + "id": 4, + "label": "new-lke-stream", + "type": "lke_audit_logs", "status": "active", + "destinations": [ + { + "id": 1, + "label": "d", + "type": "akamai_object_storage", + "details": {}, + } + ], "details": { - "endpoint_url": "https://example.com/logs", - "authentication": {"type": "none"}, - "data_compression": "none", - "content_type": "application/json", + "cluster_ids": [1111, 2222], + "is_auto_add_all_clusters_enabled": False, }, - "created": "2024-09-01T00:00:00", - "updated": "2024-09-01T00:00:00", + "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.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", + 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_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.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) - self.assertIsInstance(result, LogsDestination) - self.assertEqual(result.id, 3) - self.assertIsInstance(result.details, CustomHTTPSLogsDestinationDetails) - self.assertEqual( - result.details.endpoint_url, "https://example.com/logs" - ) + 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 a973cdf214f448dbe248286b7d5b48635e1d8dda Mon Sep 17 00:00:00 2001 From: sjerecze Date: Mon, 4 May 2026 16:09:18 +0200 Subject: [PATCH 34/36] ACLP Logs - fix __all__ statement --- linode_api4/objects/monitor.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index c57a91742..3aa62ddd7 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -21,7 +21,14 @@ "RuleCriteria", "TriggerConditions", "AkamaiObjectStorageLogsDestinationDetails", + "AuthenticationType", + "BasicAuthenticationDetails", + "ClientCertificateDetails", + "ContentType", + "CustomHeader", "CustomHTTPSLogsDestinationDetails", + "DataCompressionType", + "DestinationAuthentication", "LogsDestinationDetailsBase", "LogsDestination", "LogsDestinationHistory", From e12157d7405c389444fc917217cceaa5dec3b540 Mon Sep 17 00:00:00 2001 From: sjerecze Date: Tue, 5 May 2026 14:22:04 +0200 Subject: [PATCH 35/36] ACLP Logs - Stabilize stream tests --- .../models/monitor/test_monitor_logs.py | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/test/integration/models/monitor/test_monitor_logs.py b/test/integration/models/monitor/test_monitor_logs.py index cf69e0379..74e896311 100644 --- a/test/integration/models/monitor/test_monitor_logs.py +++ b/test/integration/models/monitor/test_monitor_logs.py @@ -337,7 +337,7 @@ def create_stream( assert stream.id is not None assert stream.status == LogsStreamStatus.provisioning yield stream - send_request_when_resource_available(timeout=100, func=stream.delete) + send_request_when_resource_available(timeout=1800, func=stream.delete) @pytest.fixture(scope="session") @@ -361,6 +361,25 @@ def 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. + """ + + def is_stream_updatable(): + stream = test_linode_client.load(LogsStream, provisioned_stream.id) + return stream.status in ( + LogsStreamStatus.active, + LogsStreamStatus.inactive, + ) + + wait_for_condition(30, 1800, is_stream_updatable) + + @_SKIP_STREAM_TESTS def test_list_streams( test_linode_client: LinodeClient, provisioned_stream: LogsStream @@ -395,6 +414,7 @@ def test_get_stream_by_id( @_SKIP_STREAM_TESTS +@pytest.mark.usefixtures("wait_for_updatable_status") def test_update_stream_label_and_status( test_linode_client: LinodeClient, provisioned_stream: LogsStream ): @@ -443,6 +463,7 @@ def test_update_stream_label_and_status( @_SKIP_STREAM_TESTS +@pytest.mark.usefixtures("wait_for_updatable_status") def test_update_stream_destinations( test_linode_client: LinodeClient, provisioned_stream: LogsStream, From 9c190c0bcb111fe949214290a071be9f053760c8 Mon Sep 17 00:00:00 2001 From: sjerecze Date: Tue, 5 May 2026 15:10:06 +0200 Subject: [PATCH 36/36] ACLP Logs - Reduce integration tests runtime - remove unnecessary updates --- .../models/monitor/test_monitor_logs.py | 61 +++++++------------ 1 file changed, 21 insertions(+), 40 deletions(-) diff --git a/test/integration/models/monitor/test_monitor_logs.py b/test/integration/models/monitor/test_monitor_logs.py index 74e896311..d6d223d88 100644 --- a/test/integration/models/monitor/test_monitor_logs.py +++ b/test/integration/models/monitor/test_monitor_logs.py @@ -408,8 +408,8 @@ def test_get_stream_by_id( assert isinstance(stream, LogsStream) assert stream.id == provisioned_stream.id - assert stream.label == provisioned_stream.label - assert stream.status == provisioned_stream.status + assert provisioned_stream.label in stream.label + assert stream.status in (LogsStreamStatus.active, LogsStreamStatus.inactive) assert len(stream.destinations) == 1 @@ -439,27 +439,17 @@ def test_update_stream_label_and_status( result = stream.save() assert result is True - try: - updated = test_linode_client.load(LogsStream, provisioned_stream.id) - assert updated.label == new_label - assert updated.status == new_status + updated = test_linode_client.load(LogsStream, provisioned_stream.id) + assert updated.label == new_label + assert updated.status == 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 - ) + 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 - finally: - # Revert to original label and status - stream.label = original_label - stream.status = original_status - stream.save() + assert snapshot_original.label == original_label + assert snapshot_updated.label == new_label + assert snapshot_updated.id == provisioned_stream.id @_SKIP_STREAM_TESTS @@ -480,24 +470,15 @@ def test_update_stream_destinations( result = stream.update_destinations([create_secondary_destination.id]) assert result is True - try: - updated = test_linode_client.load(LogsStream, provisioned_stream.id) - assert len(updated.destinations) == 1 - assert updated.destinations[0].id == create_secondary_destination.id + 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 - ) + 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 - ) - finally: - # Revert to original destination - stream.update_destinations(original_destinations) + assert snapshot_original.destinations[0].id == original_destinations[0] + assert ( + snapshot_updated.destinations[0].id == create_secondary_destination.id + )