From 63bb91baa967f23c939128272d0e8434d6498d45 Mon Sep 17 00:00:00 2001 From: sjerecze Date: Mon, 13 Apr 2026 13:41:56 +0200 Subject: [PATCH 01/30] 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/30] 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/30] 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/30] `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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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 b2de6cdd8daee70c602f9ca65c1e7523fa4b1108 Mon Sep 17 00:00:00 2001 From: sjerecze Date: Fri, 24 Apr 2026 08:34:24 +0200 Subject: [PATCH 30/30] 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"