From f8fa49b6c93a475e976ab8e9414d36f438523548 Mon Sep 17 00:00:00 2001 From: ezilber-akamai Date: Wed, 16 Apr 2025 14:12:25 -0400 Subject: [PATCH 1/3] Added support for changes in Account Events, Maintenance, and Settings --- linode_api4/groups/account.py | 2 +- linode_api4/objects/account.py | 9 ++++- test/fixtures/account_events_123.json | 56 ++++++++++++++------------ test/fixtures/account_maintenance.json | 45 +++++++++++++-------- test/fixtures/account_settings.json | 3 +- test/unit/linode_client_test.py | 31 +++++++++++++- test/unit/objects/account_test.py | 47 +++++++++++++++++++-- 7 files changed, 141 insertions(+), 52 deletions(-) diff --git a/linode_api4/groups/account.py b/linode_api4/groups/account.py index 564e55eea..6f8c6528e 100644 --- a/linode_api4/groups/account.py +++ b/linode_api4/groups/account.py @@ -201,7 +201,7 @@ def maintenance(self): """ Returns a collection of Maintenance objects for any entity a user has permissions to view. Cancelled Maintenance objects are not returned. - API Documentation: https://techdocs.akamai.com/linode-api/reference/get-account-logins + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-maintenance :returns: A list of Maintenance objects on this account. :rtype: List of Maintenance objects as MappedObjects diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py index 375e5fc03..dc169ee2b 100644 --- a/linode_api4/objects/account.py +++ b/linode_api4/objects/account.py @@ -197,6 +197,7 @@ class AccountSettings(Base): ), "object_storage": Property(), "backups_enabled": Property(mutable=True), + "maintenance_policy_id": Property(mutable=True), } @@ -219,12 +220,18 @@ class Event(Base): "user_id": Property(), "username": Property(), "entity": Property(), - "time_remaining": Property(), + "time_remaining": Property(), # Deprecated "rate": Property(), "status": Property(), "duration": Property(), "secondary_entity": Property(), "message": Property(), + "maintenance_policy_set": Property(), + "description": Property(), + "source": Property(), + "not_before": Property(is_datetime=True), + "start_time": Property(is_datetime=True), + "complete_time": Property(is_datetime=True), } @property diff --git a/test/fixtures/account_events_123.json b/test/fixtures/account_events_123.json index 4c2b7141d..b24156f90 100644 --- a/test/fixtures/account_events_123.json +++ b/test/fixtures/account_events_123.json @@ -1,27 +1,31 @@ { - "action": "ticket_create", - "created": "2018-01-01T00:01:01", - "duration": 300.56, - "entity": { - "id": 11111, - "label": "Problem booting my Linode", - "type": "ticket", - "url": "/v4/support/tickets/11111" - }, - "id": 123, - "message": "None", - "percent_complete": null, - "rate": null, - "read": true, - "secondary_entity": { - "id": "linode/debian9", - "label": "linode1234", - "type": "linode", - "url": "/v4/linode/instances/1234" - }, - "seen": true, - "status": null, - "time_remaining": null, - "username": "exampleUser" - } - \ No newline at end of file + "action": "ticket_create", + "created": "2025-03-25T12:00:00", + "duration": 300.56, + "entity": { + "id": 11111, + "label": "Problem booting my Linode", + "type": "ticket", + "url": "/v4/support/tickets/11111" + }, + "id": 123, + "message": "Ticket created for user issue.", + "percent_complete": null, + "rate": null, + "read": true, + "secondary_entity": { + "id": "linode/debian9", + "label": "linode1234", + "type": "linode", + "url": "/v4/linode/instances/1234" + }, + "seen": true, + "status": "completed", + "username": "exampleUser", + "maintenance_policy_set": "Tentative", + "description": "Scheduled maintenance", + "source": "user", + "not_before": "2025-03-25T12:00:00", + "start_time": "2025-03-25T12:30:00", + "complete_time": "2025-03-25T13:00:00" +} \ No newline at end of file diff --git a/test/fixtures/account_maintenance.json b/test/fixtures/account_maintenance.json index aeeab91e6..2856ec9f9 100644 --- a/test/fixtures/account_maintenance.json +++ b/test/fixtures/account_maintenance.json @@ -1,19 +1,30 @@ { - "data": [ - { - "entity": { - "id": 123, - "label": "demo-linode", - "type": "Linode", - "url": "https://api.linode.com/v4/linode/instances/{linodeId}" - }, - "reason": "This maintenance will allow us to update the BIOS on the host's motherboard.", - "status": "started", - "type": "reboot", - "when": "2020-07-09T00:01:01" - } - ], - "page": 1, - "pages": 1, - "results": 1 + "pages": 1, + "page": 1, + "results": 1, + "data": [ + { + "body": "Scheduled upgrade to faster NVMe hardware. This will affect Linode #1234.", + "entity": { + "id": 1234, + "label": "Linode #1234", + "type": "linode", + "url": "/linodes/1234" + }, + "label": "Scheduled Maintenance for Linode #1234", + "message": "Scheduled upgrade to faster NVMe hardware.", + "severity": "major", + "type": "maintenance_scheduled", + "event_type": "linode_migrate", + "maintenance_policy_set": "Power on/off", + "description": "Scheduled Maintenance", + "source": "platform", + "not_before": "2025-03-25T10:00:00", + "start_time": "2025-03-25T12:00:00", + "complete_time": "2025-03-25T14:00:00", + "status": "scheduled", + "when": "2025-03-25T12:00:00", + "until": "2025-03-25T14:00:00" + } + ] } \ No newline at end of file diff --git a/test/fixtures/account_settings.json b/test/fixtures/account_settings.json index 77a2fdac3..db135de53 100644 --- a/test/fixtures/account_settings.json +++ b/test/fixtures/account_settings.json @@ -3,5 +3,6 @@ "managed": false, "network_helper": false, "object_storage": "active", - "backups_enabled": true + "backups_enabled": true, + "maintenance_policy_id": 1 } diff --git a/test/unit/linode_client_test.py b/test/unit/linode_client_test.py index c79c0a88d..d51347eb2 100644 --- a/test/unit/linode_client_test.py +++ b/test/unit/linode_client_test.py @@ -353,12 +353,39 @@ def test_maintenance(self): """ with self.mock_get("/account/maintenance") as m: result = self.client.account.maintenance() + self.assertEqual(m.call_url, "/account/maintenance") self.assertEqual(len(result), 1) + + maintenance = result[0] + + self.assertEqual( + maintenance.body, + "Scheduled upgrade to faster NVMe hardware. This will affect Linode #1234.", + ) + self.assertEqual(maintenance.entity.id, 1234) + self.assertEqual(maintenance.entity.label, "Linode #1234") + self.assertEqual(maintenance.entity.type, "linode") + self.assertEqual(maintenance.entity.url, "/linodes/1234") + self.assertEqual( + maintenance.label, "Scheduled Maintenance for Linode #1234" + ) self.assertEqual( - result[0].reason, - "This maintenance will allow us to update the BIOS on the host's motherboard.", + maintenance.message, + "Scheduled upgrade to faster NVMe hardware.", ) + self.assertEqual(maintenance.severity, "major") + self.assertEqual(maintenance.type, "maintenance_scheduled") + self.assertEqual(maintenance.event_type, "linode_migrate") + self.assertEqual(maintenance.maintenance_policy_set, "Power on/off") + self.assertEqual(maintenance.description, "Scheduled Maintenance") + self.assertEqual(maintenance.source, "platform") + self.assertEqual(maintenance.not_before, "2025-03-25T10:00:00") + self.assertEqual(maintenance.start_time, "2025-03-25T12:00:00") + self.assertEqual(maintenance.complete_time, "2025-03-25T14:00:00") + self.assertEqual(maintenance.status, "scheduled") + self.assertEqual(maintenance.when, "2025-03-25T12:00:00") + self.assertEqual(maintenance.until, "2025-03-25T14:00:00") def test_notifications(self): """ diff --git a/test/unit/objects/account_test.py b/test/unit/objects/account_test.py index 1f9da98fb..e059f99e2 100644 --- a/test/unit/objects/account_test.py +++ b/test/unit/objects/account_test.py @@ -121,6 +121,25 @@ def test_get_account_settings(self): self.assertEqual(settings.network_helper, False) self.assertEqual(settings.object_storage, "active") self.assertEqual(settings.backups_enabled, True) + self.assertEqual(settings.maintenance_policy_id, 1) + + def test_update_account_settings(self): + """ + Tests that account settings can be updated + """ + with self.mock_put("account/settings") as m: + settings = AccountSettings(self.client, False, {}) + + settings.maintenance_policy_id = 1 + settings.save() + + self.assertEqual(m.call_url, "/account/settings") + self.assertEqual( + m.call_data, + { + "maintenance_policy_id": 1, + }, + ) def test_get_event(self): """ @@ -129,20 +148,40 @@ def test_get_event(self): event = Event(self.client, 123, {}) self.assertEqual(event.action, "ticket_create") - self.assertEqual(event.created, datetime(2018, 1, 1, 0, 1, 1)) + self.assertEqual(event.created, datetime(2025, 3, 25, 12, 0, 0)) self.assertEqual(event.duration, 300.56) + self.assertIsNotNone(event.entity) + self.assertEqual(event.entity.id, 11111) + self.assertEqual(event.entity.label, "Problem booting my Linode") + self.assertEqual(event.entity.type, "ticket") + self.assertEqual(event.entity.url, "/v4/support/tickets/11111") + self.assertEqual(event.id, 123) - self.assertEqual(event.message, "None") + self.assertEqual(event.message, "Ticket created for user issue.") self.assertIsNone(event.percent_complete) self.assertIsNone(event.rate) self.assertTrue(event.read) + self.assertIsNotNone(event.secondary_entity) + self.assertEqual(event.secondary_entity.id, "linode/debian9") + self.assertEqual(event.secondary_entity.label, "linode1234") + self.assertEqual(event.secondary_entity.type, "linode") + self.assertEqual( + event.secondary_entity.url, "/v4/linode/instances/1234" + ) + self.assertTrue(event.seen) - self.assertIsNone(event.status) - self.assertIsNone(event.time_remaining) + self.assertEqual(event.status, "completed") self.assertEqual(event.username, "exampleUser") + self.assertEqual(event.maintenance_policy_set, "Tentative") + self.assertEqual(event.description, "Scheduled maintenance") + self.assertEqual(event.source, "user") + self.assertEqual(event.not_before, datetime(2025, 3, 25, 12, 0, 0)) + self.assertEqual(event.start_time, datetime(2025, 3, 25, 12, 30, 0)) + self.assertEqual(event.complete_time, datetime(2025, 3, 25, 13, 0, 0)) + def test_get_invoice(self): """ Tests that an invoice is loaded correctly by ID From fd90d675a4be28cb3594b5721483476812b23237 Mon Sep 17 00:00:00 2001 From: ezilber-akamai Date: Thu, 17 Apr 2025 13:41:16 -0400 Subject: [PATCH 2/3] Added support for changes in Instance and added Maintenance group --- linode_api4/groups/__init__.py | 1 + linode_api4/groups/linode.py | 5 +++ linode_api4/groups/maintenance.py | 19 +++++++++++ linode_api4/linode_client.py | 5 +++ linode_api4/objects/linode.py | 1 + test/fixtures/linode_instances.json | 6 ++-- test/fixtures/maintenance_policies.json | 18 ++++++++++ test/unit/groups/linode_test.py | 14 ++++++++ test/unit/linode_client_test.py | 45 +++++++++++++++++++++++++ test/unit/objects/linode_test.py | 3 ++ 10 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 linode_api4/groups/maintenance.py create mode 100644 test/fixtures/maintenance_policies.json diff --git a/linode_api4/groups/__init__.py b/linode_api4/groups/__init__.py index e50eeab66..9a93e428d 100644 --- a/linode_api4/groups/__init__.py +++ b/linode_api4/groups/__init__.py @@ -10,6 +10,7 @@ from .lke import * from .lke_tier import * from .longview import * +from .maintenance import * from .networking import * from .nodebalancer import * from .object_storage import * diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index 48f0d43b6..69f1be6f1 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -153,6 +153,7 @@ def instance_create( int, ] ] = None, + maintenance_policy_id: Optional[int] = None, **kwargs, ): """ @@ -296,6 +297,9 @@ def instance_create( :type interfaces: list[ConfigInterface] or list[dict[str, Any]] :param placement_group: A Placement Group to create this Linode under. :type placement_group: Union[InstancePlacementGroupAssignment, PlacementGroup, Dict[str, Any], int] + :param maintenance_policy_id: The ID of the maintenance policy to apply during maintenance. + If not provided, the default policy (migrate) will be applied. + :type maintenance_policy_id: int :returns: A new Instance object, or a tuple containing the new Instance and the generated password. @@ -327,6 +331,7 @@ def instance_create( "firewall_id": firewall, "backup_id": backup, "stackscript_id": stackscript, + "maintenance_policy_id": maintenance_policy_id, # Special cases "disk_encryption": ( str(disk_encryption) if disk_encryption else None diff --git a/linode_api4/groups/maintenance.py b/linode_api4/groups/maintenance.py new file mode 100644 index 000000000..c13eaafbb --- /dev/null +++ b/linode_api4/groups/maintenance.py @@ -0,0 +1,19 @@ +from linode_api4 import MappedObject +from linode_api4.groups import Group + + +class MaintenanceGroup(Group): + def maintenance_policies(self): + """ + Returns a collection of MaintenancePolicy objects representing + available maintenance policies that can be applied to Linodes + + API Documentation: TODO + + :returns: A list of Maintenance Policies that can be applied to Linodes + :rtype: List of MaintenancePolicy objects as MappedObjects + """ + + result = self.client.get("/maintenance/policies", model=self) + + return [MappedObject(**r) for r in result] diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 19e6f3900..fe8493b6d 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -19,6 +19,7 @@ LinodeGroup, LKEGroup, LongviewGroup, + MaintenanceGroup, NetworkingGroup, NodeBalancerGroup, ObjectStorageGroup, @@ -149,6 +150,10 @@ def __init__( #: more information self.account = AccountGroup(self) + #: Access methods related to Maintenance Policies - see :any:`MaintenanceGroup` for + #: more information + self.maintenance = MaintenanceGroup(self) + #: Access methods related to networking on your account - see #: :any:`NetworkingGroup` for more information self.networking = NetworkingGroup(self) diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 46af5d970..55c43c8ce 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -686,6 +686,7 @@ class Instance(Base): "disk_encryption": Property(), "lke_cluster_id": Property(), "capabilities": Property(unordered=True), + "maintenance_policy_id": Property(mutable=True), } @property diff --git a/test/fixtures/linode_instances.json b/test/fixtures/linode_instances.json index 38a3cf912..4be6e343f 100644 --- a/test/fixtures/linode_instances.json +++ b/test/fixtures/linode_instances.json @@ -48,7 +48,8 @@ "label": "test", "placement_group_type": "anti_affinity:local", "placement_group_policy": "strict" - } + }, + "maintenance_policy_id" : 1 }, { "group": "test", @@ -90,7 +91,8 @@ "watchdog_enabled": false, "disk_encryption": "enabled", "lke_cluster_id": 18881, - "placement_group": null + "placement_group": null, + "maintenance_policy_id" : 2 } ] } diff --git a/test/fixtures/maintenance_policies.json b/test/fixtures/maintenance_policies.json new file mode 100644 index 000000000..206b27776 --- /dev/null +++ b/test/fixtures/maintenance_policies.json @@ -0,0 +1,18 @@ +[ + { + "id": "1", + "name": "Default Migrate", + "description": "predefined maintenance policy default for all linodes", + "type": "migrate", + "notification_period_sec": 3600, + "is_default": true + }, + { + "id": "2", + "name": "Default Power On/Off", + "description": "predefined maintenance policy for general use cases", + "type": "power on/off", + "notification_period_sec": 1800, + "is_default": false + } +] diff --git a/test/unit/groups/linode_test.py b/test/unit/groups/linode_test.py index 8112a5d93..3908bef83 100644 --- a/test/unit/groups/linode_test.py +++ b/test/unit/groups/linode_test.py @@ -96,6 +96,20 @@ def test_create_with_placement_group(self): m.call_data["placement_group"], {"id": 123, "compliant_only": True} ) + def test_create_with_mainteance_policy_id(self): + """ + Tests that you can create a Linode with a maintenance policy + """ + + with self.mock_post("linode/instances/123") as m: + self.client.linode.instance_create( + "g6-nanode-1", + "eu-west", + maintenance_policy_id=1, + ) + + self.assertEqual(m.call_data["maintenance_policy_id"], 1) + class TypeTest(ClientBaseCase): def test_get_types(self): diff --git a/test/unit/linode_client_test.py b/test/unit/linode_client_test.py index d51347eb2..0300b855a 100644 --- a/test/unit/linode_client_test.py +++ b/test/unit/linode_client_test.py @@ -307,6 +307,51 @@ def get_mock(*params, verify=True, **kwargs): assert called +class MaintenanceGroupTest(ClientBaseCase): + """ + Tests methods of the MaintenanceGroup + """ + + def test_maintenance(self): + """ + Tests that maintenance can be retrieved + """ + with self.mock_get("/maintenance/policies") as m: + result = self.client.maintenance.maintenance_policies() + + self.assertEqual(m.call_url, "/maintenance/policies") + self.assertEqual(len(result), 2) + + policy_default_migrate = result[0] + policy_default_power_on_off = result[1] + + self.assertEqual(policy_default_migrate.id, "1") + self.assertEqual(policy_default_migrate.name, "Default Migrate") + self.assertEqual( + policy_default_migrate.description, + "predefined maintenance policy default for all linodes", + ) + self.assertEqual(policy_default_migrate.type, "migrate") + self.assertEqual( + policy_default_migrate.notification_period_sec, 3600 + ) + self.assertEqual(policy_default_migrate.is_default, True) + + self.assertEqual(policy_default_power_on_off.id, "2") + self.assertEqual( + policy_default_power_on_off.name, "Default Power On/Off" + ) + self.assertEqual( + policy_default_power_on_off.description, + "predefined maintenance policy for general use cases", + ) + self.assertEqual(policy_default_power_on_off.type, "power on/off") + self.assertEqual( + policy_default_power_on_off.notification_period_sec, 1800 + ) + self.assertEqual(policy_default_power_on_off.is_default, False) + + class AccountGroupTest(ClientBaseCase): """ Tests methods of the AccountGroup diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index 6016d2776..f6b1dd670 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -40,6 +40,7 @@ def test_get_linode(self): linode.disk_encryption, InstanceDiskEncryptionType.disabled ) self.assertEqual(linode.lke_cluster_id, None) + self.assertEqual(linode.maintenance_policy_id, 1) json = linode._raw_json self.assertIsNotNone(json) @@ -153,6 +154,7 @@ def test_update_linode(self): linode.label = "NewLinodeLabel" linode.group = "new_group" + linode.maintenance_policy_id = 2 linode.save() self.assertEqual(m.call_url, "/linode/instances/123") @@ -174,6 +176,7 @@ def test_update_linode(self): "group": "new_group", "tags": ["something"], "watchdog_enabled": True, + "maintenance_policy_id": 2, }, ) From c261d3d057af97ac7f07567126d32415fb12a7b2 Mon Sep 17 00:00:00 2001 From: ezilber-akamai Date: Thu, 17 Apr 2025 14:14:25 -0400 Subject: [PATCH 3/3] Add docstring and fix imports --- linode_api4/groups/maintenance.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/linode_api4/groups/maintenance.py b/linode_api4/groups/maintenance.py index c13eaafbb..5613a87d3 100644 --- a/linode_api4/groups/maintenance.py +++ b/linode_api4/groups/maintenance.py @@ -1,8 +1,12 @@ -from linode_api4 import MappedObject from linode_api4.groups import Group +from linode_api4.objects import MappedObject class MaintenanceGroup(Group): + """ + Collections related to Maintenance. + """ + def maintenance_policies(self): """ Returns a collection of MaintenancePolicy objects representing